Files
streamdeck-go/cmd/streamdeck/main.go

950 lines
25 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"
"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.
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)
}
for i := 0; i < sd.KeyCount(); i++ {
if err := sd.ClearKey(i); err != nil {
log.Printf("warn: clear key %d: %v", i, err)
}
}
// Track animation/poll goroutines so we can wait for them on exit.
var wg sync.WaitGroup
// triggers lets button presses immediately re-poll toggle keys.
triggers := make(map[int]chan struct{})
for keyIdx, keyCfg := range cfg.Keys {
// Resolve module-based commands to shell strings before any other logic.
// keyCfg is a copy from the map, so we must write it back after modification.
if keyCfg.Module != "" {
resolved, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
if err != nil {
log.Printf("key %d: module: %v", keyIdx, err)
continue
}
keyCfg.Command = resolved
cfg.Keys[keyIdx] = keyCfg
}
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
resolved, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
if err != nil {
log.Printf("key %d: poll module: %v", keyIdx, err)
continue
}
keyCfg.Poll.Command = resolved
cfg.Keys[keyIdx] = keyCfg
}
// 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_on and icon_off", keyIdx)
continue
}
trigger := make(chan struct{}, 1)
triggers[keyIdx] = trigger
wg.Add(1)
go func(idx int, kCfg config.KeyConfig, trig chan struct{}) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("panic in pollKey %d: %v", idx, r)
}
}()
pollKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig)
}(keyIdx, keyCfg, trigger)
continue
}
// 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)
}
}
continue
}
iconPath := filepath.Join(cfg.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)
continue
}
wg.Add(1)
go func(idx int, f [][]byte, d []time.Duration) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("panic in animateKey %d: %v", idx, r)
}
}()
animateKey(ctx, sd, idx, f, d)
}(keyIdx, frames, delays)
} else {
img, err := loadImage(iconPath)
if err != nil {
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
continue
}
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)
}
}
}
log.Printf("stream deck ready (%d keys)", sd.KeyCount())
prev := make([]bool, sd.KeyCount())
consecutiveErrors := 0
for {
select {
case <-ctx.Done():
wg.Wait()
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)
// Cancel animations, wait for them to stop, then signal device death.
wg.Wait()
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] {
keyCfg, ok := cfg.Keys[i]
if !ok || keyCfg.Command == "" {
continue
}
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 := triggers[i]; ok {
select {
case ch <- struct{}{}:
default:
}
}
}
}
prev = buttons
}
}
// 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
}
// 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.WarnErrorMode)
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, 0755); err != nil {
return err
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0644)
}
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: {}
`
}