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: {} ` }