950 lines
25 KiB
Go
950 lines
25 KiB
Go
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.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, 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: {}
|
||
`
|
||
}
|
||
|