Files
streamdeck-go/cmd/streamdeck/main.go
Levi Woodard 4158165410 Add folder support: keys that open sub-pages with a back key
A key with `folder: NAME` swaps the deck to the keys defined under
`folders.NAME`. Every folder shows a back key (slot 0 by default, drawn
with a built-in chevron) that pops back to the parent; folders nest.

The daemon now runs one page at a time: per-key setup moved into
setupKey(), openPage() draws a page under its own context, and
page.stop() tears it down before the next page is drawn (and on exit,
so device death no longer waits on uncancelled goroutines).

streamdeck-ctl gains `key set -folder`, reports kind "folder", lists
every folder in status, and refuses to `key press` a navigation-only
key. The Omarchy grid shows a folder glyph.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ARLa4moGb154fzc9TevgV
2026-10-01 18:39:24 -06:00

1265 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bufio"
"context"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
_ "image/jpeg"
_ "image/png"
"log"
"math"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"git.i0t.app/lwoodard/streamdeck-go/internal/config"
"git.i0t.app/lwoodard/streamdeck-go/internal/device"
"git.i0t.app/lwoodard/streamdeck-go/internal/modules"
"github.com/fsnotify/fsnotify"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"gopkg.in/yaml.v3"
)
// helperSocketPath returns the Unix socket path for the privileged helper.
// /run is the standard location on Linux; /var/run is used on macOS.
func helperSocketPath() string {
if runtime.GOOS == "darwin" {
return "/var/run/streamdeck-go/helper.sock"
}
return "/run/streamdeck-go/helper.sock"
}
func main() {
cfgPath := flag.String("config", defaultConfigPath(), "path to config file")
flag.Parse()
if err := ensureConfigDir(*cfgPath); err != nil {
log.Fatalf("setup: %v", err)
}
// Load .env file from the config directory so secrets (tokens, passwords)
// are available via {{env "VAR"}} in module templates without requiring
// shell exports or service-level environment configuration.
envPath := filepath.Join(filepath.Dir(*cfgPath), ".env")
if err := loadEnvFile(envPath); err != nil {
log.Printf("warn: %v", err)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
log.Fatalf("config: %v", err)
}
reg, err := modules.LoadRegistry(config.ModulesPath(*cfgPath))
if err != nil {
log.Fatalf("modules: %v", err)
}
// Open device — if not present at startup, wait for it.
sd := mustConnect(cfg.Device.VendorID, cfg.Device.ProductID)
// runDone receives true if run() exited because the device died,
// false if it exited cleanly due to context cancellation.
runDone := make(chan bool, 1)
ctx, cancel := context.WithCancel(context.Background())
startRun := func() {
go func() {
deviceDied := run(ctx, sd, cfg, reg)
runDone <- deviceDied
}()
}
startRun()
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatalf("watcher: %v", err)
}
defer watcher.Close()
_ = watcher.Add(*cfgPath)
_ = watcher.Add(filepath.Dir(*cfgPath))
for {
select {
// Device died (disconnect, USB suspend, etc.) — reconnect and restart.
case deviceDied := <-runDone:
if !deviceDied {
// Clean cancellation from a config reload below; already restarted.
continue
}
log.Println("device disconnected — waiting for reconnect...")
sd.Close()
sd = mustConnect(cfg.Device.VendorID, cfg.Device.ProductID)
log.Println("device reconnected")
ctx, cancel = context.WithCancel(context.Background())
startRun()
// Config file or modules file changed — reload and restart run().
case event, ok := <-watcher.Events:
if !ok {
cancel()
return
}
cfgChanged := filepath.Clean(event.Name) == filepath.Clean(*cfgPath)
modChanged := filepath.Clean(event.Name) == filepath.Clean(config.ModulesPath(*cfgPath))
if !cfgChanged && !modChanged {
continue
}
if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
continue
}
log.Println("config changed, reloading...")
cancel()
<-runDone // wait for run() to fully exit before restarting
newCfg, err := config.Load(*cfgPath)
if err != nil {
log.Printf("reload: bad config: %v — keeping current config", err)
} else {
cfg = newCfg
}
newReg, err := modules.LoadRegistry(config.ModulesPath(*cfgPath))
if err != nil {
log.Printf("reload: bad modules: %v — keeping current modules", err)
} else {
reg = newReg
}
ctx, cancel = context.WithCancel(context.Background())
startRun()
case err, ok := <-watcher.Errors:
if !ok {
cancel()
return
}
log.Printf("watcher error: %v", err)
}
}
}
// run initialises the deck and handles button presses until ctx is cancelled
// or the device dies. Returns true if the device died, false if ctx was cancelled.
//
// The deck shows one page at a time: the root page (cfg.Keys) or a folder
// (cfg.Folders[name]). Pressing a key with a `folder:` field pushes that folder;
// pressing a folder's back key pops it. Each page owns its own goroutines
// (animations, polls, live text) and is torn down completely before the next
// page is drawn, so a folder's keys never fight the root page's for a slot.
func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry) (deviceDied bool) {
// Recover from any panic inside this goroutine tree.
defer func() {
if r := recover(); r != nil {
log.Printf("panic in run: %v — treating as device error", r)
deviceDied = true
}
}()
if err := sd.Reset(); err != nil {
log.Printf("warn: reset: %v", err)
}
if err := sd.SetBrightness(cfg.Brightness); err != nil {
log.Printf("warn: brightness: %v", err)
}
// Navigation stack of folder names; empty means the root page is showing.
var stack []string
current := openPage(ctx, sd, cfg, reg, "")
defer func() { current.stop() }()
showPage := func(name string) {
current.stop()
current = openPage(ctx, sd, cfg, reg, name)
}
log.Printf("stream deck ready (%d keys)", sd.KeyCount())
prev := make([]bool, sd.KeyCount())
consecutiveErrors := 0
for {
select {
case <-ctx.Done():
return false
default:
}
buttons, err := sd.ReadButtons()
if err != nil {
consecutiveErrors++
if consecutiveErrors >= 3 {
log.Printf("device error (gave up after %d attempts): %v", consecutiveErrors, err)
return true
}
log.Printf("read error (%d/3): %v", consecutiveErrors, err)
time.Sleep(200 * time.Millisecond)
continue
}
consecutiveErrors = 0
if buttons == nil {
continue // 250 ms timeout, no data
}
for i, pressed := range buttons {
if !pressed || prev[i] {
continue
}
if i == current.backKey {
stack = stack[:len(stack)-1]
parent := ""
if len(stack) > 0 {
parent = stack[len(stack)-1]
}
log.Printf("key %d pressed → back to %s", i, pageName(parent))
showPage(parent)
break // this report belongs to the old page
}
keyCfg, ok := current.keys[i]
if !ok {
continue
}
if keyCfg.Command != "" {
log.Printf("key %d pressed → %s", i, keyCfg.Command)
go func(cmd string) {
defer func() {
if r := recover(); r != nil {
log.Printf("panic running command %q: %v", cmd, r)
}
}()
runCommand(cmd)
}(keyCfg.Command)
}
// For toggle keys, signal the poll goroutine to re-check state
// shortly after the command runs (gives the system time to update).
if ch, ok := current.triggers[i]; ok {
select {
case ch <- struct{}{}:
default:
}
}
if keyCfg.Folder != "" {
if _, defined := cfg.Folders[keyCfg.Folder]; !defined {
log.Printf("key %d: folder %q is not defined under folders:", i, keyCfg.Folder)
continue
}
log.Printf("key %d pressed → open folder %q", i, keyCfg.Folder)
stack = append(stack, keyCfg.Folder)
showPage(keyCfg.Folder)
break // this report belongs to the old page
}
}
// Keys still held across a page swap stay "pressed" here, so the new
// page sees no edge for them until they are physically released.
prev = buttons
}
}
// pageName names a page for log lines.
func pageName(folder string) string {
if folder == "" {
return "root page"
}
return fmt.Sprintf("folder %q", folder)
}
// page is one screenful of keys and the goroutines keeping them drawn.
type page struct {
keys map[int]config.KeyConfig // module templates already resolved
triggers map[int]chan struct{} // per-key re-poll channels
backKey int // slot that navigates up, -1 on the root page
cancel context.CancelFunc
wg sync.WaitGroup
}
// stop tears the page down: cancels its goroutines, waits for them, and blanks
// every key so the next page starts from an empty deck.
func (p *page) stop() {
p.cancel()
p.wg.Wait()
}
// openPage clears the deck and draws the root page (folder == "") or the named
// folder, starting whatever goroutines its keys need. Keys that fail to set up
// are logged and skipped — one bad icon must not blank the whole page.
func openPage(parent context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry, folder string) *page {
ctx, cancel := context.WithCancel(parent)
p := &page{
keys: map[int]config.KeyConfig{},
triggers: map[int]chan struct{}{},
backKey: -1,
cancel: cancel,
}
for i := 0; i < sd.KeyCount(); i++ {
if err := sd.ClearKey(i); err != nil {
log.Printf("warn: clear key %d: %v", i, err)
}
}
source := cfg.Keys
if folder != "" {
f := cfg.Folders[folder]
source = f.Keys
p.backKey = f.BackKey()
if _, shadowed := source[p.backKey]; shadowed {
log.Printf("folder %q: key %d is the back key — its own definition is ignored", folder, p.backKey)
}
drawBackKey(sd, p.backKey, f.Back, cfg.IconsDir)
}
for keyIdx, keyCfg := range source {
if keyIdx == p.backKey {
continue
}
if keyCfg.Folder != "" {
if _, defined := cfg.Folders[keyCfg.Folder]; !defined {
log.Printf("key %d: folder %q is not defined under folders:", keyIdx, keyCfg.Folder)
}
}
resolved, ok := setupKey(ctx, sd, p, keyIdx, keyCfg, cfg.IconsDir, reg)
if ok {
p.keys[keyIdx] = resolved
}
}
return p
}
// setupKey resolves module templates, draws the key's initial image, and starts
// any goroutine the key needs (animation, poll, live text). It returns the key
// config with commands resolved — the button loop dispatches from that copy.
// ok is false when the key could not be set up at all.
func setupKey(ctx context.Context, sd *device.StreamDeck, p *page, keyIdx int, keyCfg config.KeyConfig, iconsDir string, reg *modules.Registry) (resolved config.KeyConfig, ok bool) {
// Resolve module-based commands to shell strings before any other logic.
if keyCfg.Module != "" {
cmd, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
if err != nil {
log.Printf("key %d: module: %v", keyIdx, err)
return keyCfg, false
}
keyCfg.Command = cmd
}
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
cmd, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
if err != nil {
log.Printf("key %d: poll module: %v", keyIdx, err)
return keyCfg, false
}
poll := *keyCfg.Poll // don't mutate the shared config
poll.Command = cmd
keyCfg.Poll = &poll
}
// Optional $(...) sugar → text_command.
if keyCfg.TextCommand == "" {
if inner, ok := unwrapCmdSubst(keyCfg.Text); ok {
keyCfg.TextCommand = inner
keyCfg.Text = ""
}
}
spawn := func(name string, fn func()) {
p.wg.Add(1)
go func() {
defer p.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("panic in %s %d: %v", name, keyIdx, r)
}
}()
fn()
}()
}
// Dynamic text key: a refresh goroutine owns this key's image. It also
// handles an optional poll block (choosing icon_true/icon_false per tick),
// so it must take precedence over the plain toggle path below.
if keyCfg.TextCommand != "" {
baseIcon := keyCfg.Icon
if keyCfg.Poll != nil {
baseIcon = keyCfg.IconTrue
}
if strings.ToLower(filepath.Ext(baseIcon)) == ".gif" ||
strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif" {
log.Printf("key %d: text_command not supported with GIF icon — skipping refresh", keyIdx)
return keyCfg, true
}
trigger := make(chan struct{}, 1)
p.triggers[keyIdx] = trigger
kCfg := keyCfg
spawn("refreshTextKey", func() { refreshTextKey(ctx, sd, keyIdx, kCfg, iconsDir, trigger) })
return keyCfg, true
}
// Toggle/status key: managed by a polling goroutine.
if keyCfg.Poll != nil {
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
log.Printf("key %d: toggle key requires icon_true and icon_false", keyIdx)
return keyCfg, true
}
trigger := make(chan struct{}, 1)
p.triggers[keyIdx] = trigger
kCfg := keyCfg
spawn("pollKey", func() { pollKey(ctx, sd, keyIdx, kCfg, iconsDir, trigger) })
return keyCfg, true
}
// Regular key: load icon once.
if keyCfg.Icon == "" {
// No icon — render text-only key on a black background.
if keyCfg.Text != "" {
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
}
return keyCfg, true
}
iconPath := filepath.Join(iconsDir, keyCfg.Icon)
if strings.ToLower(filepath.Ext(keyCfg.Icon)) == ".gif" {
frames, delays, err := loadGIF(sd, iconPath)
if err != nil {
log.Printf("key %d: load gif %q: %v", keyIdx, keyCfg.Icon, err)
return keyCfg, true
}
spawn("animateKey", func() { animateKey(ctx, sd, keyIdx, frames, delays) })
return keyCfg, true
}
img, err := loadImage(iconPath)
if err != nil {
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
return keyCfg, true
}
if keyCfg.Text != "" {
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
return keyCfg, true
}
// drawBackKey renders a folder's back key: the configured icon if there is
// one, otherwise a built-in chevron on black, with a "Back" label by default.
func drawBackKey(sd *device.StreamDeck, keyIdx int, back config.BackConfig, iconsDir string) {
if keyIdx < 0 || keyIdx >= sd.KeyCount() {
log.Printf("back key %d is outside the deck (%d keys)", keyIdx, sd.KeyCount())
return
}
var img image.Image
if back.Icon != "" {
loaded, err := loadImage(filepath.Join(iconsDir, back.Icon))
if err != nil {
log.Printf("back key %d: load icon %q: %v — using built-in arrow", keyIdx, back.Icon, err)
} else {
img = loaded
}
}
if img == nil {
img = backArrow(sd.ImageWidth(), sd.ImageHeight(), parseTextColor(back.TextColor))
}
text := back.Text
if text == "" && back.Icon == "" {
text = "Back"
}
if text != "" {
img = overlayText(img, text, back.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("back key %d: set image: %v", keyIdx, err)
}
}
// backArrow draws a left-pointing chevron in the upper part of a black key,
// leaving the bottom clear for the label.
func backArrow(w, h int, c color.Color) image.Image {
img := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(img, img.Bounds(), image.Black, image.Point{}, draw.Src)
fw, fh := float64(w), float64(h)
tip := [2]float64{fw * 0.36, fh * 0.40}
top := [2]float64{fw * 0.62, fh * 0.16}
bottom := [2]float64{fw * 0.62, fh * 0.64}
thickness := fw * 0.075
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
px, py := float64(x)+0.5, float64(y)+0.5
d := math.Min(distToSegment(px, py, top, tip), distToSegment(px, py, tip, bottom))
if d <= thickness {
img.Set(x, y, c)
}
}
}
return img
}
// distToSegment is the distance from point p to the segment a→b.
func distToSegment(px, py float64, a, b [2]float64) float64 {
dx, dy := b[0]-a[0], b[1]-a[1]
lenSq := dx*dx + dy*dy
t := 0.0
if lenSq > 0 {
t = ((px-a[0])*dx + (py-a[1])*dy) / lenSq
t = math.Max(0, math.Min(1, t))
}
cx, cy := a[0]+t*dx, a[1]+t*dy
return math.Hypot(px-cx, py-cy)
}
// pollKey watches the state of a toggle key and keeps its icon up to date.
// It polls on an interval and also re-polls when triggered (e.g. after a button press).
func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
interval := 2 * time.Second
if keyCfg.Poll.Interval != "" {
if d, err := time.ParseDuration(keyCfg.Poll.Interval); err == nil {
interval = d
} else {
log.Printf("key %d: invalid poll interval %q, using 2s", keyIdx, keyCfg.Poll.Interval)
}
}
// Check if icon_true is an animated GIF.
trueIsGIF := strings.ToLower(filepath.Ext(keyCfg.IconTrue)) == ".gif"
falseIsGIF := strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif"
// Pre-load GIF frames for whichever icons are GIFs.
var trueFrames [][]byte
var trueDelays []time.Duration
var falseFrames [][]byte
var falseDelays []time.Duration
var imgTrue, imgFalse image.Image
if trueIsGIF {
var err error
trueFrames, trueDelays, err = loadGIF(sd, filepath.Join(iconsDir, keyCfg.IconTrue))
if err != nil {
log.Printf("key %d: load icon_true gif %q: %v", keyIdx, keyCfg.IconTrue, err)
return
}
} else {
var err error
imgTrue, err = loadImage(filepath.Join(iconsDir, keyCfg.IconTrue))
if err != nil {
log.Printf("key %d: load icon_true %q: %v", keyIdx, keyCfg.IconTrue, err)
return
}
}
if falseIsGIF {
var err error
falseFrames, falseDelays, err = loadGIF(sd, filepath.Join(iconsDir, keyCfg.IconFalse))
if err != nil {
log.Printf("key %d: load icon_false gif %q: %v", keyIdx, keyCfg.IconFalse, err)
return
}
} else {
var err error
imgFalse, err = loadImage(filepath.Join(iconsDir, keyCfg.IconFalse))
if err != nil {
log.Printf("key %d: load icon_false %q: %v", keyIdx, keyCfg.IconFalse, err)
return
}
}
if keyCfg.Text != "" {
imgTrue = overlayText(imgTrue, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
imgFalse = overlayText(imgFalse, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
lastState := -1 // unknown, forces initial icon set
var animCancel context.CancelFunc
var animWg sync.WaitGroup
stopAnim := func() {
if animCancel != nil {
animCancel()
animWg.Wait()
animCancel = nil
}
}
applyState := func() {
state := queryPollState(keyCfg.Poll)
if state == lastState {
return
}
stopAnim()
lastState = state
if state == 1 {
if trueIsGIF {
animCtx, cancel := context.WithCancel(ctx)
animCancel = cancel
animWg.Add(1)
go func() {
defer animWg.Done()
animateKey(animCtx, sd, keyIdx, trueFrames, trueDelays)
}()
} else {
if err := sd.SetKeyImage(keyIdx, imgTrue); err != nil {
log.Printf("key %d: set poll icon: %v", keyIdx, err)
}
}
} else {
if falseIsGIF {
animCtx, cancel := context.WithCancel(ctx)
animCancel = cancel
animWg.Add(1)
go func() {
defer animWg.Done()
animateKey(animCtx, sd, keyIdx, falseFrames, falseDelays)
}()
} else {
if err := sd.SetKeyImage(keyIdx, imgFalse); err != nil {
log.Printf("key %d: set poll icon: %v", keyIdx, err)
}
}
}
}
applyState() // set icon immediately on startup
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
stopAnim()
return
case <-ticker.C:
applyState()
case <-trigger:
// Wait briefly for the toggle command to take effect, then re-poll.
select {
case <-ctx.Done():
stopAnim()
return
case <-time.After(400 * time.Millisecond):
}
applyState()
}
}
}
// queryPollState runs the poll command and returns 1 (on) or 0 (off).
// If Match is set, checks for that substring in stdout.
// If Match is empty, uses exit code: 0 → on, non-zero → off.
func queryPollState(poll *config.PollConfig) int {
cmd := exec.Command("sh", "-c", poll.Command)
output, err := cmd.CombinedOutput()
if poll.Match == "" {
if err == nil {
return 1
}
return 0
}
if strings.Contains(string(output), poll.Match) {
return 1
}
return 0
}
// refreshTextKey periodically runs keyCfg.TextCommand and renders its stdout as
// the key's text overlay, compositing over the key's base icon each tick (never
// accumulating — overlayText always renders onto a fresh image from the base).
// If the key also has a Poll block, the base icon is chosen from the polled
// state (icon_true/icon_false) so a single goroutine owns the key's image.
func refreshTextKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
// Interval: default 1s, floor 250ms.
interval := time.Second
if keyCfg.Refresh != "" {
if d, err := time.ParseDuration(keyCfg.Refresh); err == nil && d > 0 {
interval = d
} else if err != nil {
log.Printf("key %d: invalid refresh %q, using 1s", keyIdx, keyCfg.Refresh)
}
}
if interval < 250*time.Millisecond {
interval = 250 * time.Millisecond
}
// Preload immutable base image(s). An empty name yields a black background.
load := func(name string) (image.Image, bool) {
if name == "" {
return image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight())), true
}
img, err := loadImage(filepath.Join(iconsDir, name))
if err != nil {
log.Printf("key %d: load icon %q: %v", keyIdx, name, err)
return nil, false
}
return img, true
}
pollMode := keyCfg.Poll != nil && keyCfg.IconTrue != "" && keyCfg.IconFalse != ""
var baseImg, imgTrue, imgFalse image.Image
if pollMode {
var ok1, ok2 bool
imgTrue, ok1 = load(keyCfg.IconTrue)
imgFalse, ok2 = load(keyCfg.IconFalse)
if !ok1 || !ok2 {
return
}
} else {
var ok bool
baseImg, ok = load(keyCfg.Icon) // "" → black background
if !ok {
return
}
}
render := func() {
text := runTextCommand(keyCfg.TextCommand)
if text == "" {
text = keyCfg.Text // static fallback
}
base := baseImg
if pollMode {
if queryPollState(keyCfg.Poll) == 1 {
base = imgTrue
} else {
base = imgFalse
}
}
img := overlayText(base, text, keyCfg.TextColor, sd.ImageWidth())
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set dynamic text image: %v", keyIdx, err)
}
}
render() // set immediately on startup
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
render()
case <-trigger:
// Wait briefly for a press-triggered command to take effect, then re-render.
select {
case <-ctx.Done():
return
case <-time.After(300 * time.Millisecond):
}
render()
}
}
}
// runTextCommand runs cmd via sh -c and returns its stdout with trailing
// whitespace/newlines trimmed. Stderr is dropped so it can't pollute the label.
func runTextCommand(cmd string) string {
out, err := exec.Command("sh", "-c", cmd).Output()
if err != nil {
log.Printf("text_command %q: %v", cmd, err)
}
return strings.TrimRight(string(out), " \t\r\n")
}
// unwrapCmdSubst returns the inner command if s is exactly "$( ... )".
func unwrapCmdSubst(s string) (string, bool) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "$(") && strings.HasSuffix(s, ")") {
inner := strings.TrimSpace(s[2 : len(s)-1])
if inner != "" {
return inner, true
}
}
return "", false
}
// mustConnect blocks until the device opens successfully.
//
// Strategy: try quickly at first (device may just be enumerating), then settle
// into a short fixed interval. We deliberately avoid long exponential backoff
// because in KVM setups the device is guaranteed to come back — we just don't
// know when. A 2s poll means the deck is live within ~2s of switching back.
func mustConnect(vendorID, productID uint16) *device.StreamDeck {
const (
quickRetries = 5
quickInterval = 500 * time.Millisecond
pollInterval = 2 * time.Second
)
for i := 0; ; i++ {
sd, err := device.Open(vendorID, productID)
if err == nil {
return sd
}
wait := pollInterval
if i < quickRetries {
wait = quickInterval
}
log.Printf("could not open device: %v — retrying in %s", err, wait)
time.Sleep(wait)
}
}
// animateKey cycles pre-encoded GIF frames on a key until ctx is cancelled.
//
// Transient HID write errors (USB bus contention, especially with multiple
// concurrent GIFs) are suppressed after the first occurrence to avoid log spam.
// A summary is logged when errors stop or when they become persistent enough
// to indicate a real device problem.
func animateKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, frames [][]byte, delays []time.Duration) {
var writeErrors int // consecutive HID write failures
for {
for i, frame := range frames {
select {
case <-ctx.Done():
return
default:
}
if err := sd.SetKeyFrame(keyIdx, frame); err != nil {
writeErrors++
if writeErrors == 50 {
log.Printf("key %d: %d consecutive frame write errors — device may be unhealthy", keyIdx, writeErrors)
}
} else if writeErrors > 0 {
writeErrors = 0
}
select {
case <-ctx.Done():
return
case <-time.After(delays[i]):
}
}
}
}
// loadGIF decodes a GIF and pre-encodes every frame as JPEG bytes.
func loadGIF(sd *device.StreamDeck, path string) (frames [][]byte, delays []time.Duration, err error) {
f, err := os.Open(path)
if err != nil {
return nil, nil, err
}
defer f.Close()
g, err := gif.DecodeAll(f)
if err != nil {
return nil, nil, err
}
canvas := image.NewRGBA(image.Rect(0, 0, g.Config.Width, g.Config.Height))
for i, frame := range g.Image {
draw.Draw(canvas, frame.Bounds(), frame, frame.Bounds().Min, draw.Over)
encoded, err := sd.EncodeFrame(canvas)
if err != nil {
return nil, nil, err
}
frames = append(frames, encoded)
d := g.Delay[i]
if d <= 0 {
d = 10
}
delays = append(delays, time.Duration(d)*10*time.Millisecond)
}
return frames, delays, nil
}
// --- Text overlay ---
var (
parsedFont *opentype.Font
fontOnce sync.Once
)
func getFont() *opentype.Font {
fontOnce.Do(func() {
f, err := opentype.Parse(gobold.TTF)
if err != nil {
log.Fatalf("parse embedded font: %v", err)
}
parsedFont = f
})
return parsedFont
}
// parseTextColor converts a color name or hex string to a color.Color.
// Supported names: white (default), black, red, blue. Hex: "#RRGGBB".
func parseTextColor(s string) color.Color {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "white":
return color.White
case "black":
return color.Black
case "red":
return color.RGBA{R: 255, A: 255}
case "blue":
return color.RGBA{B: 255, A: 255}
default:
s = strings.TrimPrefix(s, "#")
if len(s) == 6 {
b, err := hex.DecodeString(s)
if err == nil && len(b) == 3 {
return color.RGBA{R: b[0], G: b[1], B: b[2], A: 255}
}
}
log.Printf("unknown text_color %q, using white", s)
return color.White
}
}
// contrastColor returns black or white depending on which contrasts better
// with the given color. Used for the text outline/shadow.
func contrastColor(c color.Color) color.Color {
r, g, b, _ := c.RGBA()
// Perceived luminance (values are 16-bit, so divide by 257 to get 8-bit).
lum := 0.299*float64(r/257) + 0.587*float64(g/257) + 0.114*float64(b/257)
if lum > 128 {
return color.Black
}
return color.White
}
// splitLines splits text on explicit newlines only. No automatic word wrapping.
func splitLines(text string) []string {
return strings.Split(text, "\n")
}
// overlayText renders text onto img at keySize resolution with auto-sizing.
// The image is first scaled to keySize×keySize so font sizes are consistent
// regardless of source image dimensions.
// textColor is parsed from the config; shadow/outline uses the contrasting color.
func overlayText(img image.Image, text, textColorStr string, keySize int) image.Image {
if text == "" {
return img
}
// Scale to key resolution so font sizing is consistent.
dst := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
xdraw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), xdraw.Over, nil)
w, h := keySize, keySize
f := getFont()
marginX := int(float64(w) * 0.06)
marginY := int(float64(h) * 0.06)
availW := w - 2*marginX
availH := h - 2*marginY
// Split on explicit newlines only — no automatic word wrapping.
lines := splitLines(text)
// Find the largest font size where all lines fit.
// Cap at h*0.16 (~15pt on 96px key) so text stays label-sized.
var bestFace font.Face
maxSize := float64(h) * 0.16
const minSize = 6.0
for size := maxSize; size >= minSize; size -= 0.5 {
face, err := opentype.NewFace(f, &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
continue
}
lineH := face.Metrics().Height.Ceil()
fits := lineH*len(lines) <= availH
if fits {
for _, line := range lines {
if font.MeasureString(face, line).Ceil() > availW {
fits = false
break
}
}
}
if fits {
bestFace = face
break
}
}
if bestFace == nil {
bestFace, _ = opentype.NewFace(f, &opentype.FaceOptions{
Size: minSize, DPI: 72, Hinting: font.HintingFull,
})
}
metrics := bestFace.Metrics()
lineH := metrics.Height.Ceil()
totalH := lineH * len(lines)
// Bottom-align so the icon stays visible above.
startY := h - marginY - totalH + metrics.Ascent.Ceil()
txtColor := parseTextColor(textColorStr)
shadow := contrastColor(txtColor)
// Outline offsets for readability on any background.
offsets := [8]image.Point{
{-1, -1},
{0, -1},
{1, -1},
{-1, 0},
{1, 0},
{-1, 1},
{0, 1},
{1, 1},
}
for i, line := range lines {
adv := font.MeasureString(bestFace, line)
x := (w - adv.Ceil()) / 2
y := startY + i*lineH
// Draw outline.
for _, off := range offsets {
d := &font.Drawer{
Dst: dst,
Src: image.NewUniform(shadow),
Face: bestFace,
Dot: fixed.P(x+off.X, y+off.Y),
}
d.DrawString(line)
}
// Draw text.
d := &font.Drawer{
Dst: dst,
Src: image.NewUniform(txtColor),
Face: bestFace,
Dot: fixed.P(x, y),
}
d.DrawString(line)
}
return dst
}
func loadImage(path string) (image.Image, error) {
if strings.ToLower(filepath.Ext(path)) == ".svg" {
return loadSVG(path)
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
return img, err
}
func loadSVG(path string) (image.Image, error) {
icon, err := oksvg.ReadIcon(path, oksvg.IgnoreErrorMode)
if err != nil {
return nil, err
}
const size = 96
icon.SetTarget(0, 0, size, size)
rgba := image.NewRGBA(image.Rect(0, 0, size, size))
scanner := rasterx.NewScannerGV(size, size, rgba, rgba.Bounds())
raster := rasterx.NewDasher(size, size, scanner)
icon.Draw(raster, 1.0)
return rgba, nil
}
func runCommand(cmd string) {
if strings.HasPrefix(cmd, "priv:") {
name := strings.TrimPrefix(cmd, "priv:")
if err := runPrivileged(name); err != nil {
log.Printf("privileged command %q: %v", name, err)
}
return
}
c := exec.Command("sh", "-c", cmd)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
if err := c.Run(); err != nil {
log.Printf("command %q: %v", cmd, err)
}
}
// runPrivileged dispatches a named privileged command.
// On macOS: reads the user's local whitelist and runs via osascript (admin auth dialog).
// On Linux: sends to the root helper daemon over its Unix socket.
func runPrivileged(name string) error {
if runtime.GOOS == "darwin" {
return runPrivilegedDarwin(name)
}
return runPrivilegedHelper(name)
}
// runPrivilegedDarwin looks up name in ~/.config/streamdeck-go/privileged.yaml
// and executes it via osascript, which shows the standard macOS admin auth dialog.
func runPrivilegedDarwin(name string) error {
wlPath := darwinWhitelistPath()
commands, err := loadPrivilegedCommands(wlPath)
if err != nil {
return fmt.Errorf("load whitelist %q: %w", wlPath, err)
}
shell, ok := commands[name]
if !ok {
return fmt.Errorf("unknown command %q — add it to %s", name, wlPath)
}
script := fmt.Sprintf(`do shell script %q with administrator privileges`, shell)
out, err := exec.Command("osascript", "-e", script).CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, out)
}
return nil
}
// runPrivilegedHelper sends a named command to the root helper daemon over its Unix socket.
func runPrivilegedHelper(name string) error {
conn, err := net.Dial("unix", helperSocketPath())
if err != nil {
return fmt.Errorf("helper unavailable (is streamdeck-go-helper.service running?): %w", err)
}
defer conn.Close()
req, _ := json.Marshal(map[string]string{"command": name})
if _, err := fmt.Fprintf(conn, "%s\n", req); err != nil {
return fmt.Errorf("send: %w", err)
}
scanner := bufio.NewScanner(conn)
if !scanner.Scan() {
return fmt.Errorf("no response from helper")
}
var resp struct {
OK bool `json:"ok"`
Error string `json:"error"`
}
if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil {
return fmt.Errorf("bad response: %w", err)
}
if !resp.OK {
return fmt.Errorf("%s", resp.Error)
}
return nil
}
func darwinWhitelistPath() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, _ := os.UserHomeDir()
base = filepath.Join(home, ".config")
}
return filepath.Join(base, "streamdeck-go", "privileged.yaml")
}
func loadPrivilegedCommands(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var wl struct {
Commands map[string]string `yaml:"commands"`
}
if err := yaml.Unmarshal(data, &wl); err != nil {
return nil, err
}
if wl.Commands == nil {
return map[string]string{}, nil
}
return wl.Commands, nil
}
// loadEnvFile reads a .env file and sets each KEY=VALUE pair in the process
// environment. Blank lines and lines starting with # are ignored. Quoted values
// (single or double) are unquoted. A missing file is silently ignored.
func loadEnvFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("load env %q: %w", path, err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
// Strip matching quotes around value.
if len(val) >= 2 &&
((val[0] == '"' && val[len(val)-1] == '"') ||
(val[0] == '\'' && val[len(val)-1] == '\'')) {
val = val[1 : len(val)-1]
}
os.Setenv(key, val)
}
return nil
}
func defaultConfigPath() string {
return config.DefaultConfigPath()
}
func ensureConfigDir(cfgPath string) error {
dir := filepath.Dir(cfgPath)
iconsDir := filepath.Join(dir, "icons")
if err := os.MkdirAll(iconsDir, 0o755); err != nil {
return err
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0o644)
}
return nil
}
func defaultConfig(iconsDir string) string {
return `# streamdeck-go configuration
# https://git.i0t.app/lwoodard/streamdeck-go
icons_dir: ` + iconsDir + `
brightness: 70
# USB IDs — defaults match Stream Deck XL v2.
# Run: lsusb | grep Elgato
device:
vendor_id: 0x0fd9
product_id: 0x00ba
# Keys are 0-indexed, left-to-right, top-to-bottom.
# Stream Deck XL layout (8 columns x 4 rows):
#
# 0 1 2 3 4 5 6 7
# 8 9 10 11 12 13 14 15
# 16 17 18 19 20 21 22 23
# 24 25 26 27 28 29 30 31
#
# icon: filename inside icons_dir (PNG, JPEG, or GIF)
# command: shell command to run on press
keys: {}
`
}