1
0
streamdeck-lets-go/action.go
Maksim Totmin 8df8b65163 Dynamic key generators: plugin system for auto-generated pages
Add dynamic_keys support to PageConfig — a contract-based plugin
system where any executable outputs a JSON array of KeyConfig to stdout.
The daemon runs the generator on a configurable interval and renders
the keys dynamically on the Stream Deck.

Core changes:
- config: DynamicKeyGen struct (command/script, interval, timeout, max_keys)
- config: full validation for dynamic_keys fields
- action: execDynamicKeys() with safe JSON parsing (stderr logged separately,
  env vars STREAMDECK_KEY_COUNT / STREAMDECK_CONFIG_DIR)
- page: activeKeys() merges static + dynamic keys (static wins by index)
- page: startDynamicKeys / stopDynamicKeys lifecycle, per-page results cache
- page: rerenderActivePage() with deadlock-safe lock ordering
- daemon: event handler uses activeKeys() for dynamic key actions

Web UI:
- GET /api/pages returns effective_keys (merged static + cached dynamic)
- app.js: gridKeys uses effective_keys when available
- app.js: guard against editing dynamic keys through the UI
- index.html: " dynamic" badge + effective key count in page switcher
- polls /api/pages every 10s to keep the UI in sync

Image rendering:
- loadImage: gift.ResizeToFill instead of gift.Resize (preserves aspect
  ratio, crops to fill — no more stretched cover art)

Example & docs:
- config.example.json: games page with dynamic_keys + back button
- README.md: comprehensive Dynamic Key Generators section (architecture
  diagram, config reference, generator contract, merge behavior,
  env vars, error handling, Python + Shell examples, tips)
2026-06-21 21:05:32 +07:00

373 lines
8.3 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"streamdeck-lets-go/internal/config"
)
func ExecuteAction(a *config.Action, deck *Deck, pm *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 execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cmd *exec.Cmd
if d.Command != "" {
cmd = exec.CommandContext(ctx, "sh", "-c", d.Command)
} else {
cmd = exec.CommandContext(ctx, d.Script)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := stdout.String()
if stderr.Len() > 0 {
if output != "" {
output += "\n"
}
output += stderr.String()
}
return output, err
}
func execDynamicKeys(dg *config.DynamicKeyGen, timeout time.Duration, keyCount int) ([]config.KeyConfig, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cmd *exec.Cmd
if dg.Command != "" {
cmd = exec.CommandContext(ctx, "sh", "-c", dg.Command)
} else {
script := dg.Script
if !filepath.IsAbs(script) {
script = filepath.Join(configDir(), script)
}
cmd = exec.CommandContext(ctx, script)
}
// Set environment variables for the script
cmd.Env = append(os.Environ(),
fmt.Sprintf("STREAMDECK_KEY_COUNT=%d", keyCount),
fmt.Sprintf("STREAMDECK_CONFIG_DIR=%s", configDir()),
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
// Log stderr separately (don't merge into stdout for JSON parsing)
if stderr.Len() > 0 {
slog.Debug("dynamic_keys script stderr", "output", stderr.String())
}
if err != nil {
return nil, fmt.Errorf("execute dynamic_keys: %w", err)
}
// Parse JSON from stdout
var keys []config.KeyConfig
output := stdout.String()
if err := json.Unmarshal([]byte(output), &keys); err != nil {
return nil, fmt.Errorf("parse dynamic_keys JSON: %w", err)
}
return keys, nil
}
func execBuiltin(a *config.Action, deck *Deck, pm *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 *PageManager) error {
switch action {
case "next":
pageNames := pm.PageNames()
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.PageNames()
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) 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()
}
func (pm *PageManager) PageNames() []string {
pm.mu.RLock()
defer pm.mu.RUnlock()
names := make([]string, 0, len(pm.pages))
for name := range pm.pages {
names = append(names, name)
}
return names
}