refactor: restructure to standard Go project layout (cmd/ + internal/)
- Move entry point to cmd/streamdeck-lets-go/ - Split package main into internal packages: - internal/deck: hardware control (Deck, events, brightness) - internal/render: key rendering, icons, fonts, PageManager - internal/web: HTTP API + embedded SPA - internal/daemon: event loop, autoswitch, screensaver, actions - Add ConfigDir() to internal/config - Export cross-package API (Deck, PageManager, WebServer, etc.) - Move dist/arch/ → packaging/ with updated PKGBUILD/.SRCINFO - Add Makefile (build, test, vet, fmt, install) - Update README with new build commands and project structure - Delete unused media.go stub
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
"streamdeck-lets-go/internal/render"
|
||||
)
|
||||
|
||||
func ExecuteAction(a *config.Action, deck *deck.Deck, pm *render.PageManager) error {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Debug("execute action", "type", a.Type)
|
||||
|
||||
switch a.Type {
|
||||
case "command":
|
||||
return execCommand(a)
|
||||
case "builtin":
|
||||
return execBuiltin(a, deck, pm)
|
||||
case "script":
|
||||
return execScript(a)
|
||||
case "page":
|
||||
if pm != nil {
|
||||
return pm.ActivatePage(a.Page)
|
||||
}
|
||||
return fmt.Errorf("page manager not available")
|
||||
case "keyboard":
|
||||
return execKeyboard(a)
|
||||
default:
|
||||
return fmt.Errorf("unknown action type: %s", a.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func execCommand(a *config.Action) error {
|
||||
cmd := exec.Command("sh", "-c", a.Command)
|
||||
|
||||
if a.Background {
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start command: %w", err)
|
||||
}
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
slog.Warn("command finished", "cmd", a.Command, "error", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("run command: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execScript(a *config.Action) error {
|
||||
cmd := exec.Command(a.Script)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("run script: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execBuiltin(a *config.Action, deck *deck.Deck, pm *render.PageManager) error {
|
||||
parts := strings.SplitN(a.Builtin, ":", 2)
|
||||
if len(parts) < 1 {
|
||||
return fmt.Errorf("invalid builtin: %s", a.Builtin)
|
||||
}
|
||||
|
||||
category := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
slog.Debug("builtin", "category", category, "action", action)
|
||||
|
||||
switch category {
|
||||
case "media":
|
||||
return mediaAction(action)
|
||||
case "volume":
|
||||
return volumeAction(action)
|
||||
case "brightness":
|
||||
return brightnessAction(action)
|
||||
case "page":
|
||||
if pm != nil {
|
||||
return pageBuiltinAction(action, pm)
|
||||
}
|
||||
return fmt.Errorf("page manager not available")
|
||||
case "deck":
|
||||
return deckBuiltinAction(action, deck)
|
||||
default:
|
||||
return fmt.Errorf("unknown builtin category: %s", category)
|
||||
}
|
||||
}
|
||||
|
||||
func mediaAction(action string) error {
|
||||
var cmd string
|
||||
switch action {
|
||||
case "playpause":
|
||||
cmd = "playerctl play-pause"
|
||||
case "next":
|
||||
cmd = "playerctl next"
|
||||
case "prev":
|
||||
cmd = "playerctl previous"
|
||||
case "stop":
|
||||
cmd = "playerctl stop"
|
||||
default:
|
||||
return fmt.Errorf("unknown media action: %s", action)
|
||||
}
|
||||
return exec.Command("sh", "-c", cmd).Run()
|
||||
}
|
||||
|
||||
func volumeAction(action string) error {
|
||||
var cmd string
|
||||
switch action {
|
||||
case "up":
|
||||
cmd = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+"
|
||||
case "down":
|
||||
cmd = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"
|
||||
case "mute":
|
||||
cmd = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
|
||||
default:
|
||||
return fmt.Errorf("unknown volume action: %s", action)
|
||||
}
|
||||
return exec.Command("sh", "-c", cmd).Run()
|
||||
}
|
||||
|
||||
func brightnessAction(action string) error {
|
||||
var cmd string
|
||||
switch action {
|
||||
case "up":
|
||||
cmd = "brightnessctl s +10%"
|
||||
case "down":
|
||||
cmd = "brightnessctl s 10%-"
|
||||
default:
|
||||
return fmt.Errorf("unknown brightness action: %s", action)
|
||||
}
|
||||
return exec.Command("sh", "-c", cmd).Run()
|
||||
}
|
||||
|
||||
func pageBuiltinAction(action string, pm *render.PageManager) error {
|
||||
switch action {
|
||||
case "next":
|
||||
pageNames := pm.GetPageNames()
|
||||
current := pm.ActivePageName()
|
||||
for i, name := range pageNames {
|
||||
if name == current {
|
||||
next := (i + 1) % len(pageNames)
|
||||
return pm.ActivatePage(pageNames[next])
|
||||
}
|
||||
}
|
||||
if len(pageNames) > 0 {
|
||||
return pm.ActivatePage(pageNames[0])
|
||||
}
|
||||
case "prev":
|
||||
pageNames := pm.GetPageNames()
|
||||
current := pm.ActivePageName()
|
||||
for i, name := range pageNames {
|
||||
if name == current {
|
||||
prev := (i - 1 + len(pageNames)) % len(pageNames)
|
||||
return pm.ActivatePage(pageNames[prev])
|
||||
}
|
||||
}
|
||||
if len(pageNames) > 0 {
|
||||
return pm.ActivatePage(pageNames[0])
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown page action: %s", action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deckBuiltinAction(action string, deck *deck.Deck) error {
|
||||
switch action {
|
||||
case "brightness-up":
|
||||
current := deck.Brightness()
|
||||
next := current + 25
|
||||
if next > 100 {
|
||||
next = 25
|
||||
}
|
||||
return deck.SetBrightness(next)
|
||||
case "brightness-down":
|
||||
current := deck.Brightness()
|
||||
next := current - 25
|
||||
if next < 25 {
|
||||
next = 100
|
||||
}
|
||||
return deck.SetBrightness(next)
|
||||
default:
|
||||
return fmt.Errorf("unknown deck action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func keyboardTool() string {
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
if _, err := exec.LookPath("ydotool"); err == nil {
|
||||
return "ydotool"
|
||||
}
|
||||
if _, err := exec.LookPath("wtype"); err == nil {
|
||||
return "wtype"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if _, err := exec.LookPath("xdotool"); err == nil {
|
||||
return "xdotool"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func execKeyboard(a *config.Action) error {
|
||||
tool := keyboardTool()
|
||||
if tool == "" {
|
||||
wayland := os.Getenv("WAYLAND_DISPLAY") != ""
|
||||
if wayland {
|
||||
return fmt.Errorf("keyboard action requires wtype — install it (e.g. apk add wtype) and restart")
|
||||
}
|
||||
return fmt.Errorf("keyboard action requires xdotool — install it (e.g. apk add xdotool) and restart")
|
||||
}
|
||||
|
||||
keys := strings.ToLower(strings.TrimSpace(a.Keys))
|
||||
if keys == "" {
|
||||
return fmt.Errorf("keyboard action: keys is empty")
|
||||
}
|
||||
|
||||
parts := strings.Split(keys, "+")
|
||||
if len(parts) == 0 {
|
||||
return fmt.Errorf("keyboard action: invalid keys format %q", a.Keys)
|
||||
}
|
||||
|
||||
switch tool {
|
||||
case "ydotool":
|
||||
return execYDOTool(keys)
|
||||
case "wtype":
|
||||
return execWType(parts)
|
||||
case "xdotool":
|
||||
return execXDoTool(keys)
|
||||
default:
|
||||
return fmt.Errorf("keyboard action: unsupported tool %q", tool)
|
||||
}
|
||||
}
|
||||
|
||||
func execWType(parts []string) error {
|
||||
mainKey := parts[len(parts)-1]
|
||||
mods := parts[:len(parts)-1]
|
||||
|
||||
args := make([]string, 0, 2+len(mods)*2)
|
||||
|
||||
for _, m := range mods {
|
||||
args = append(args, "-M", m)
|
||||
}
|
||||
args = append(args, "-P", mainKey, "-p", mainKey)
|
||||
for i := len(mods) - 1; i >= 0; i-- {
|
||||
args = append(args, "-m", mods[i])
|
||||
}
|
||||
|
||||
cmd := exec.Command("wtype", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func execXDoTool(keys string) error {
|
||||
cmd := exec.Command("xdotool", "key", keys)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func execYDOTool(keys string) error {
|
||||
// Use 'type' command instead of 'key' for better compatibility with Wine/games on Wayland
|
||||
cmd := exec.Command("ydotool", "type", keys)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"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
|
||||
paused atomic.Bool // disables auto-switch when device is disconnected
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Pause disables auto-switch evaluation — use when the device is disconnected
|
||||
// to prevent unnecessary page switches on a closed deck.
|
||||
func (m *AutoSwitchManager) Pause() {
|
||||
m.paused.Store(true)
|
||||
slog.Debug("auto-switch: paused")
|
||||
}
|
||||
|
||||
// Resume re-enables auto-switch evaluation after reconnect.
|
||||
// Stale window events should be drained from the channel before resuming.
|
||||
func (m *AutoSwitchManager) Resume() {
|
||||
m.paused.Store(false)
|
||||
slog.Debug("auto-switch: resumed")
|
||||
}
|
||||
|
||||
// IsPaused returns true when auto-switch evaluation is paused.
|
||||
func (m *AutoSwitchManager) IsPaused() bool {
|
||||
return m.paused.Load()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
)
|
||||
|
||||
func TestFindFocusedSwayNode(t *testing.T) {
|
||||
input := `{
|
||||
"id": 0,
|
||||
"type": "root",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "output",
|
||||
"name": "eDP-1",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "workspace",
|
||||
"name": "1",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "con",
|
||||
"focused": false,
|
||||
"app_id": "firefox",
|
||||
"name": "Firefox",
|
||||
"nodes": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "workspace",
|
||||
"name": "2",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "con",
|
||||
"focused": true,
|
||||
"app_id": "Alacritty",
|
||||
"name": "Alacritty",
|
||||
"nodes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"floating_nodes": []
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var root swayNode
|
||||
if err := json.Unmarshal([]byte(input), &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
node := findFocusedSwayNode(&root)
|
||||
if node == nil {
|
||||
t.Fatal("expected focused node, got nil")
|
||||
}
|
||||
if node.AppID == nil || *node.AppID != "Alacritty" {
|
||||
t.Fatalf("expected Alacritty, got %v", node.AppID)
|
||||
}
|
||||
if node.Name != "Alacritty" {
|
||||
t.Fatalf("expected name Alacritty, got %s", node.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFocusedSwayNode_XWayland(t *testing.T) {
|
||||
input := `{
|
||||
"id": 0,
|
||||
"type": "root",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "output",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "workspace",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "con",
|
||||
"focused": true,
|
||||
"app_id": null,
|
||||
"name": "Steam",
|
||||
"window_properties": {"class": "steam"},
|
||||
"nodes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var root swayNode
|
||||
json.Unmarshal([]byte(input), &root)
|
||||
|
||||
node := findFocusedSwayNode(&root)
|
||||
if node == nil {
|
||||
t.Fatal("expected focused node, got nil")
|
||||
}
|
||||
if node.Props == nil || node.Props.Class != "steam" {
|
||||
t.Fatal("expected window_properties.class = steam")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFocusedSwayNode_NoFocus(t *testing.T) {
|
||||
input := `{
|
||||
"id": 0,
|
||||
"type": "root",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "output",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "workspace",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "con",
|
||||
"focused": false,
|
||||
"app_id": "kitty",
|
||||
"name": "kitty",
|
||||
"nodes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var root swayNode
|
||||
json.Unmarshal([]byte(input), &root)
|
||||
node := findFocusedSwayNode(&root)
|
||||
if node != nil {
|
||||
t.Fatal("expected nil when no focused node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoSwitchManager_Evaluate(t *testing.T) {
|
||||
rules := []config.SwitchRule{
|
||||
{WMClass: "firefox", Page: "browser"},
|
||||
{WMClass: "Alacritty|kitty", Title: ".*vim.*", Page: "coding", Stay: true},
|
||||
}
|
||||
|
||||
m := NewAutoSwitchManager(rules, "default")
|
||||
|
||||
page, ok := m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "default")
|
||||
if !ok || page != "browser" {
|
||||
t.Fatalf("expected browser, got %s/%v", page, ok)
|
||||
}
|
||||
|
||||
page, ok = m.Evaluate(Window{WMClass: "Alacritty", Title: "nvim main.go"}, "default")
|
||||
if !ok || page != "coding" {
|
||||
t.Fatalf("expected coding, got %s/%v", page, ok)
|
||||
}
|
||||
|
||||
page, ok = m.Evaluate(Window{WMClass: "firefox", Title: "YouTube"}, "browser")
|
||||
if !ok || page != "browser" {
|
||||
t.Fatalf("expected browser again, got %s/%v", page, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoSwitchManager_Stay(t *testing.T) {
|
||||
rules := []config.SwitchRule{
|
||||
{WMClass: "firefox", Page: "browser", Stay: false},
|
||||
}
|
||||
|
||||
m := NewAutoSwitchManager(rules, "home")
|
||||
|
||||
m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home")
|
||||
|
||||
page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser")
|
||||
if !ok || page != "home" {
|
||||
t.Fatalf("expected revert to home, got %s/%v", page, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoSwitchManager_StayTrue(t *testing.T) {
|
||||
rules := []config.SwitchRule{
|
||||
{WMClass: "firefox", Page: "browser", Stay: true},
|
||||
}
|
||||
|
||||
m := NewAutoSwitchManager(rules, "home")
|
||||
|
||||
m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home")
|
||||
|
||||
page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser")
|
||||
if ok {
|
||||
t.Fatalf("expected no switch (stay=true), got %s", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoSwitchManager_ManualOverride(t *testing.T) {
|
||||
rules := []config.SwitchRule{
|
||||
{WMClass: "firefox", Page: "browser", Stay: false},
|
||||
}
|
||||
|
||||
m := NewAutoSwitchManager(rules, "home")
|
||||
|
||||
// 1. Firefox opens → auto-switch to "browser"
|
||||
m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home")
|
||||
|
||||
// 2. User manually switches to "settings"
|
||||
m.NotifyManualPage("settings")
|
||||
|
||||
// 3. Window changes to thunar on "settings" → no match → stay on settings
|
||||
page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "settings")
|
||||
if ok {
|
||||
t.Fatalf("expected no switch (already on manual page), got %s", page)
|
||||
}
|
||||
|
||||
// 4. Firefox comes back → auto-switch to "browser" again
|
||||
page, ok = m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "settings")
|
||||
if !ok || page != "browser" {
|
||||
t.Fatalf("expected browser, got %s/%v", page, ok)
|
||||
}
|
||||
|
||||
// 5. Thunar again, current page is "browser" (auto) → no match → revert to "settings"
|
||||
page, ok = m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser")
|
||||
if !ok || page != "settings" {
|
||||
t.Fatalf("expected revert to settings, got %s/%v", page, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
"streamdeck-lets-go/internal/render"
|
||||
"streamdeck-lets-go/internal/web"
|
||||
)
|
||||
|
||||
type RunOptions struct {
|
||||
ConfigPath string
|
||||
HTTPAddr string
|
||||
HTTPEnabled bool
|
||||
NoDeck bool
|
||||
StartPage string
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||
web := web.NewWebServer(cfg, opts.ConfigPath)
|
||||
|
||||
if !opts.NoDeck || opts.HTTPEnabled {
|
||||
checkKeyboardTool()
|
||||
}
|
||||
|
||||
if opts.HTTPEnabled {
|
||||
go func() {
|
||||
if err := web.Serve(ctx, opts.HTTPAddr); err != nil {
|
||||
slog.Error("web server error", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.NoDeck {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
var decks []*deck.Deck
|
||||
var err error
|
||||
|
||||
for {
|
||||
decks, err = deck.OpenAllDecks()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
slog.Warn("no stream deck found, retrying in 5s", "error", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
pageMgrs := make([]*render.PageManager, len(decks))
|
||||
for i, d := range decks {
|
||||
pageMgrs[i] = render.NewPageManager(d)
|
||||
pageMgrs[i].DefaultFont = cfg.Font
|
||||
pageMgrs[i].ShowLabelBackground = cfg.ShowLabelBackground
|
||||
pageMgrs[i].LoadPages(cfg.Pages)
|
||||
}
|
||||
primaryPM := pageMgrs[0]
|
||||
primaryDeck := decks[0]
|
||||
|
||||
startPage := opts.StartPage
|
||||
if startPage == "" {
|
||||
startPage = cfg.DefaultPage
|
||||
}
|
||||
if !pageExists(cfg.Pages, startPage) {
|
||||
slog.Warn("start page not found in pages, falling back to default",
|
||||
"requested", startPage, "default", cfg.DefaultPage)
|
||||
startPage = cfg.DefaultPage
|
||||
}
|
||||
|
||||
defer func() {
|
||||
for _, d := range decks {
|
||||
d.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for _, d := range decks {
|
||||
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
|
||||
}
|
||||
|
||||
web.SetDecks(decks)
|
||||
web.SetPageManager(primaryPM)
|
||||
web.SetExtraPageManagers(pageMgrs[1:])
|
||||
|
||||
for _, pm := range pageMgrs {
|
||||
if err := pm.ActivatePage(startPage); err != nil {
|
||||
slog.Warn("activate start page", "error", err)
|
||||
}
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
|
||||
var windowCh <-chan Window
|
||||
var detector WindowDetector
|
||||
|
||||
defer func() {
|
||||
if detector != nil {
|
||||
detector.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if len(cfg.AutoSwitch) > 0 {
|
||||
detector = NewWindowDetector()
|
||||
windowCh, _ = detector.Start(ctx)
|
||||
}
|
||||
|
||||
asm := NewAutoSwitchManager(cfg.AutoSwitch, startPage)
|
||||
|
||||
ssCtrl := NewScreensaver(&cfg.Screensaver)
|
||||
|
||||
ge := NewGestureEngine(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs, func(idx int, a *config.Action) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
oldPage := primaryPM.ActivePageName()
|
||||
if err := ExecuteAction(a, primaryDeck, primaryPM); err != nil {
|
||||
slog.Error("execute action", "error", err)
|
||||
}
|
||||
newPage := primaryPM.ActivePageName()
|
||||
if newPage != oldPage {
|
||||
if a.Type == "page" {
|
||||
asm.NotifyManualPage(a.Page)
|
||||
}
|
||||
for _, pm := range pageMgrs {
|
||||
pm.StopPeriodicKeys()
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
web.BroadcastPageChange(newPage)
|
||||
} else {
|
||||
primaryPM.RefreshDisplayKey(idx)
|
||||
}
|
||||
})
|
||||
|
||||
reconnectTicker := time.NewTicker(2 * time.Second)
|
||||
defer reconnectTicker.Stop()
|
||||
|
||||
ssTicker := time.NewTicker(5 * time.Second)
|
||||
defer ssTicker.Stop()
|
||||
|
||||
configTicker := time.NewTicker(3 * time.Second)
|
||||
defer configTicker.Stop()
|
||||
var lastConfigMod time.Time
|
||||
|
||||
slog.Info("daemon started", "decks", len(decks))
|
||||
defer slog.Info("daemon stopped")
|
||||
|
||||
for {
|
||||
select {
|
||||
case evt, ok := <-primaryDeck.Events():
|
||||
if !ok {
|
||||
slog.Warn("deck event channel closed, attempting reconnect...")
|
||||
|
||||
// Stop all periodic scripts to avoid wasting cycles on a closed device.
|
||||
primaryPM.StopPeriodicKeys()
|
||||
asm.Pause()
|
||||
|
||||
primaryDeck.Close()
|
||||
newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage)
|
||||
if newDeck == nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Update all references to the new device and page manager.
|
||||
pageMgrs[0] = primaryPM
|
||||
decks[0] = newDeck
|
||||
primaryDeck = newDeck
|
||||
web.SetDecks(decks)
|
||||
web.SetPageManager(primaryPM)
|
||||
|
||||
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
|
||||
asm.NotifyManualPage(startPage)
|
||||
asm.Resume()
|
||||
continue
|
||||
}
|
||||
|
||||
wasSsActive := ssCtrl.IsActive()
|
||||
ssCtrl.NotifyInput()
|
||||
|
||||
if evt.Kind == deck.EventKeyPressed {
|
||||
if wasSsActive {
|
||||
ssCtrl.Deactivate(primaryDeck)
|
||||
|
||||
savedOutputs := primaryPM.GetDisplayOutputs()
|
||||
|
||||
if page := primaryPM.ActivePage(); page != nil {
|
||||
for _, pm := range pageMgrs {
|
||||
if err := pm.ActivatePage(primaryPM.ActivePageName()); err != nil {
|
||||
slog.Warn("screensaver: re-render page", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for idx, dout := range savedOutputs {
|
||||
if dout != nil {
|
||||
primaryPM.ReRenderDisplayKey(idx, dout.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page := primaryPM.ActivePage()
|
||||
if page == nil {
|
||||
continue
|
||||
}
|
||||
for _, k := range primaryPM.ActiveKeys() {
|
||||
if k.Index == evt.Index {
|
||||
if len(k.Actions) > 0 {
|
||||
ge.HandleEvent(evt, k.Actions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case win := <-windowCh:
|
||||
if asm.IsPaused() {
|
||||
// Drain buffered window events accumulated while disconnected.
|
||||
for len(windowCh) > 0 {
|
||||
<-windowCh
|
||||
}
|
||||
continue
|
||||
}
|
||||
if page, ok := asm.Evaluate(win, primaryPM.ActivePageName()); ok {
|
||||
for _, pm := range pageMgrs {
|
||||
if err := pm.ActivatePage(page); err != nil {
|
||||
slog.Warn("auto-switch: activate page", "error", err)
|
||||
}
|
||||
pm.StopPeriodicKeys()
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
web.BroadcastPageChange(page)
|
||||
}
|
||||
|
||||
case <-configTicker.C:
|
||||
path := config.ConfigPath(opts.ConfigPath)
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mt := fi.ModTime()
|
||||
if mt.After(lastConfigMod) && !lastConfigMod.IsZero() {
|
||||
lastConfigMod = mt
|
||||
slog.Info("config file changed, reloading")
|
||||
newCfg, err := config.LoadConfig(opts.ConfigPath)
|
||||
if err != nil {
|
||||
slog.Error("reload config", "error", err)
|
||||
continue
|
||||
}
|
||||
cfg = newCfg
|
||||
web.UpdateConfig(cfg)
|
||||
ssCtrl = NewScreensaver(&cfg.Screensaver)
|
||||
for _, d := range decks {
|
||||
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
|
||||
}
|
||||
var activePages []string
|
||||
for _, pm := range pageMgrs {
|
||||
activePages = append(activePages, pm.ActivePageName())
|
||||
pm.StopPeriodicKeys()
|
||||
pm.DefaultFont = cfg.Font
|
||||
pm.ShowLabelBackground = cfg.ShowLabelBackground
|
||||
pm.LoadPages(cfg.Pages)
|
||||
}
|
||||
ge.ReloadTiming(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs)
|
||||
asm.Reload(cfg.AutoSwitch)
|
||||
if len(cfg.AutoSwitch) > 0 && detector == nil {
|
||||
detector = NewWindowDetector()
|
||||
windowCh, _ = detector.Start(ctx)
|
||||
}
|
||||
for i, pm := range pageMgrs {
|
||||
page := cfg.DefaultPage
|
||||
if i < len(activePages) && activePages[i] != "" {
|
||||
for _, p := range cfg.Pages {
|
||||
if p.Name == activePages[i] {
|
||||
page = activePages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := pm.ActivatePage(page); err != nil {
|
||||
slog.Warn("reload: activate page", "error", err)
|
||||
}
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
}
|
||||
if lastConfigMod.IsZero() {
|
||||
lastConfigMod = mt
|
||||
}
|
||||
|
||||
case <-reconnectTicker.C:
|
||||
if err := primaryDeck.SetBrightness(primaryDeck.Brightness()); err != nil {
|
||||
slog.Warn("deck connection lost, reconnecting...", "error", err)
|
||||
|
||||
// Stop all periodic scripts to avoid wasting cycles on a closed device.
|
||||
primaryPM.StopPeriodicKeys()
|
||||
asm.Pause()
|
||||
|
||||
primaryDeck.Close()
|
||||
newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage)
|
||||
if newDeck == nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Update all references to the new device and page manager.
|
||||
pageMgrs[0] = primaryPM
|
||||
decks[0] = newDeck
|
||||
primaryDeck = newDeck
|
||||
web.SetDecks(decks)
|
||||
web.SetPageManager(primaryPM)
|
||||
|
||||
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
|
||||
asm.NotifyManualPage(startPage)
|
||||
asm.Resume()
|
||||
}
|
||||
|
||||
case <-ssTicker.C:
|
||||
if ssCtrl.Check() {
|
||||
for _, d := range decks {
|
||||
ssCtrl.Activate(d, &cfg.Screensaver)
|
||||
}
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkKeyboardTool() {
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
// On Wayland, prefer ydotool over wtype
|
||||
if _, err := exec.LookPath("ydotool"); err == nil {
|
||||
// Start ydotoold daemon if not already running
|
||||
cmd := exec.Command("ydotoold")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
// Run in background, don't wait
|
||||
_ = cmd.Start()
|
||||
slog.Info("ydotoold daemon started for keyboard input")
|
||||
} else if _, err := exec.LookPath("wtype"); err == nil {
|
||||
slog.Warn("ydotool not found, falling back to wtype — consider installing ydotool for better Wayland support (e.g. apk add ydotool)")
|
||||
} else {
|
||||
slog.Warn("keyboard actions require ydotool or wtype on Wayland — install one (e.g. apk add ydotool) and restart")
|
||||
}
|
||||
} else {
|
||||
if _, err := exec.LookPath("xdotool"); err != nil {
|
||||
slog.Warn("keyboard actions require xdotool on X11 — install it (e.g. apk add xdotool) and restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deviceBrightness(cfg *config.Config, serial string) int {
|
||||
for _, d := range cfg.Devices {
|
||||
if d.Serial == serial || d.Serial == "" {
|
||||
if d.Brightness > 0 {
|
||||
return d.Brightness
|
||||
}
|
||||
return 75
|
||||
}
|
||||
}
|
||||
return 75
|
||||
}
|
||||
|
||||
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **render.PageManager, startPage string) *deck.Deck {
|
||||
for {
|
||||
newDeck, err := deck.OpenDeck("")
|
||||
if err == nil {
|
||||
*pm = render.NewPageManager(newDeck)
|
||||
(*pm).DefaultFont = cfg.Font
|
||||
(*pm).ShowLabelBackground = cfg.ShowLabelBackground
|
||||
(*pm).LoadPages(cfg.Pages)
|
||||
(*pm).ActivatePage(startPage)
|
||||
(*pm).StartPeriodicKeys()
|
||||
return newDeck
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(1 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pageExists reports whether pages contains a page with the given name.
|
||||
func pageExists(pages []config.PageConfig, name string) bool {
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
)
|
||||
|
||||
type ActionCallback func(idx int, a *config.Action)
|
||||
|
||||
type gestureKeyState struct {
|
||||
mu sync.Mutex
|
||||
pressedAt time.Time
|
||||
lastTapAt time.Time
|
||||
holdTimer *time.Timer
|
||||
holdActive bool
|
||||
pendingTap *time.Timer
|
||||
}
|
||||
|
||||
type GestureEngine struct {
|
||||
mu sync.Mutex
|
||||
states map[int]*gestureKeyState
|
||||
longMs time.Duration
|
||||
doubleMs time.Duration
|
||||
onAction ActionCallback
|
||||
}
|
||||
|
||||
func NewGestureEngine(longMs, doubleMs int, cb ActionCallback) *GestureEngine {
|
||||
if longMs <= 0 {
|
||||
longMs = config.DefaultLongPressMs
|
||||
}
|
||||
if doubleMs <= 0 {
|
||||
doubleMs = config.DefaultDoubleTapMs
|
||||
}
|
||||
return &GestureEngine{
|
||||
states: make(map[int]*gestureKeyState),
|
||||
longMs: time.Duration(longMs) * time.Millisecond,
|
||||
doubleMs: time.Duration(doubleMs) * time.Millisecond,
|
||||
onAction: cb,
|
||||
}
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) ReloadTiming(longMs, doubleMs int) {
|
||||
ge.mu.Lock()
|
||||
defer ge.mu.Unlock()
|
||||
if longMs > 0 {
|
||||
ge.longMs = time.Duration(longMs) * time.Millisecond
|
||||
}
|
||||
if doubleMs > 0 {
|
||||
ge.doubleMs = time.Duration(doubleMs) * time.Millisecond
|
||||
}
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) Reset() {
|
||||
ge.mu.Lock()
|
||||
defer ge.mu.Unlock()
|
||||
for _, s := range ge.states {
|
||||
s.mu.Lock()
|
||||
s.cancelTimers()
|
||||
s.holdActive = false
|
||||
s.lastTapAt = time.Time{}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *gestureKeyState) cancelTimers() {
|
||||
if s.holdTimer != nil {
|
||||
s.holdTimer.Stop()
|
||||
s.holdTimer = nil
|
||||
}
|
||||
if s.pendingTap != nil {
|
||||
s.pendingTap.Stop()
|
||||
s.pendingTap = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) getState(index int) *gestureKeyState {
|
||||
ge.mu.Lock()
|
||||
defer ge.mu.Unlock()
|
||||
s, ok := ge.states[index]
|
||||
if !ok {
|
||||
s = &gestureKeyState{}
|
||||
ge.states[index] = s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func findAction(actions []config.KeyAction, trigger string) *config.Action {
|
||||
for _, a := range actions {
|
||||
if a.Trigger == trigger {
|
||||
return &a.Action
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) HandleEvent(evt deck.Event, actions []config.KeyAction) {
|
||||
state := ge.getState(evt.Index)
|
||||
|
||||
if len(actions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
switch evt.Kind {
|
||||
case deck.EventKeyPressed:
|
||||
ge.handleKeyPress(state, evt, actions)
|
||||
case deck.EventKeyReleased:
|
||||
ge.handleKeyRelease(state, evt, actions)
|
||||
}
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) {
|
||||
state.mu.Lock()
|
||||
state.cancelTimers()
|
||||
state.holdActive = false
|
||||
state.pressedAt = evt.At
|
||||
state.holdTimer = time.AfterFunc(ge.longMs, func() {
|
||||
state.mu.Lock()
|
||||
state.holdActive = true
|
||||
state.holdTimer = nil
|
||||
state.mu.Unlock()
|
||||
|
||||
if a := findAction(actions, "hold_start"); a != nil {
|
||||
ge.onAction(evt.Index, a)
|
||||
} else if a := findAction(actions, "long_press"); a != nil {
|
||||
ge.onAction(evt.Index, a)
|
||||
}
|
||||
})
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
func (ge *GestureEngine) handleKeyRelease(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) {
|
||||
state.mu.Lock()
|
||||
state.cancelTimers()
|
||||
|
||||
if state.holdActive {
|
||||
state.holdActive = false
|
||||
state.mu.Unlock()
|
||||
if a := findAction(actions, "hold_end"); a != nil {
|
||||
ge.onAction(evt.Index, a)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !state.lastTapAt.IsZero() && evt.At.Sub(state.lastTapAt) <= ge.doubleMs {
|
||||
state.lastTapAt = time.Time{}
|
||||
state.mu.Unlock()
|
||||
if a := findAction(actions, "double_tap"); a != nil {
|
||||
ge.onAction(evt.Index, a)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lastTap := evt.At
|
||||
state.lastTapAt = lastTap
|
||||
|
||||
state.pendingTap = time.AfterFunc(ge.doubleMs, func() {
|
||||
state.mu.Lock()
|
||||
if state.lastTapAt.Equal(lastTap) {
|
||||
state.lastTapAt = time.Time{}
|
||||
state.pendingTap = nil
|
||||
state.mu.Unlock()
|
||||
if a := findAction(actions, "tap"); a != nil {
|
||||
ge.onAction(evt.Index, a)
|
||||
}
|
||||
return
|
||||
}
|
||||
state.mu.Unlock()
|
||||
})
|
||||
state.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
"streamdeck-lets-go/internal/render"
|
||||
)
|
||||
|
||||
type Screensaver struct {
|
||||
enabled bool
|
||||
idleAfter time.Duration
|
||||
lastInput time.Time
|
||||
active bool
|
||||
savedBrightness int
|
||||
}
|
||||
|
||||
func NewScreensaver(cfg *config.ScreensaverCfg) *Screensaver {
|
||||
ss := &Screensaver{
|
||||
enabled: cfg.Enabled,
|
||||
idleAfter: time.Duration(cfg.IdleSeconds) * time.Second,
|
||||
lastInput: time.Now(),
|
||||
}
|
||||
if ss.idleAfter <= 0 {
|
||||
ss.idleAfter = 30 * time.Second
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
func (ss *Screensaver) NotifyInput() {
|
||||
ss.lastInput = time.Now()
|
||||
if ss.active {
|
||||
ss.active = false
|
||||
slog.Debug("screensaver deactivated")
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *Screensaver) Check() bool {
|
||||
if !ss.enabled {
|
||||
return false
|
||||
}
|
||||
if time.Since(ss.lastInput) > ss.idleAfter {
|
||||
if !ss.active {
|
||||
ss.active = true
|
||||
slog.Debug("screensaver activated")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
|
||||
ss.savedBrightness = deck.Brightness()
|
||||
|
||||
brightness := cfg.Brightness
|
||||
if brightness <= 0 {
|
||||
brightness = 10
|
||||
}
|
||||
if err := deck.SetBrightness(brightness); err != nil {
|
||||
slog.Warn("screensaver: set brightness", "error", err)
|
||||
}
|
||||
|
||||
if cfg.Image != "" {
|
||||
img, err := render.LoadImage(cfg.Image, 0, 0)
|
||||
if err != nil {
|
||||
slog.Warn("screensaver: load image", "path", cfg.Image, "error", err)
|
||||
return
|
||||
}
|
||||
if err := deck.FillPanel(img); err != nil {
|
||||
slog.Warn("screensaver: fill panel", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("screensaver activated")
|
||||
}
|
||||
|
||||
func (ss *Screensaver) Deactivate(deck *deck.Deck) {
|
||||
brightness := ss.savedBrightness
|
||||
if brightness <= 0 {
|
||||
brightness = 75
|
||||
}
|
||||
if err := deck.SetBrightness(brightness); err != nil {
|
||||
slog.Warn("screensaver: restore brightness", "error", err)
|
||||
}
|
||||
ss.savedBrightness = 0
|
||||
slog.Info("screensaver deactivated")
|
||||
}
|
||||
|
||||
func (ss *Screensaver) IsActive() bool {
|
||||
return ss.active
|
||||
}
|
||||
Reference in New Issue
Block a user