Initial commit: Stream Deck daemon with web UI
- Web-based key editor (Alpine.js SPA) - Actions: shell commands, scripts, built-in media/volume/brightness, page switching - Keyboard shortcut action (wtype/xdotool) - On-device display with periodic command output - Auto page switching (Hyprland, Sway, Niri, GNOME, KDE, X11) - Screensaver with idle detection - Config hot-reload - Multi-device support
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
)
|
||||
|
||||
type RunOptions struct {
|
||||
ConfigPath string
|
||||
HTTPAddr string
|
||||
HTTPEnabled bool
|
||||
NoDeck bool
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||
web := NewWebServer(cfg, opts.ConfigPath)
|
||||
|
||||
if !opts.NoDeck || opts.HTTPEnabled {
|
||||
checkKeyboardTool()
|
||||
}
|
||||
|
||||
if opts.HTTPEnabled {
|
||||
go func() {
|
||||
if err := web.Serve(ctx, opts.HTTPAddr); err != nil {
|
||||
slog.Error("web server error", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.NoDeck {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
var deck *Deck
|
||||
var err error
|
||||
|
||||
for {
|
||||
deck, err = OpenDeck("")
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
slog.Warn("no stream deck found, retrying in 5s", "error", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
defer deck.Close()
|
||||
|
||||
deck.SetBrightness(deviceBrightness(cfg))
|
||||
|
||||
pm := NewPageManager(deck)
|
||||
pm.LoadPages(cfg.Pages)
|
||||
web.SetPageManager(pm)
|
||||
|
||||
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
|
||||
slog.Warn("activate default page", "error", err)
|
||||
}
|
||||
pm.startPeriodicKeys()
|
||||
|
||||
var windowCh <-chan Window
|
||||
var detector WindowDetector
|
||||
|
||||
defer func() {
|
||||
if detector != nil {
|
||||
detector.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if len(cfg.AutoSwitch) > 0 {
|
||||
detector = NewWindowDetector()
|
||||
windowCh, _ = detector.Start(ctx)
|
||||
}
|
||||
|
||||
asm := NewAutoSwitchManager(cfg.AutoSwitch, cfg.DefaultPage)
|
||||
|
||||
ssCtrl := NewScreensaver(&cfg.Screensaver)
|
||||
|
||||
reconnectTicker := time.NewTicker(5 * time.Second)
|
||||
defer reconnectTicker.Stop()
|
||||
|
||||
ssTicker := time.NewTicker(5 * time.Second)
|
||||
defer ssTicker.Stop()
|
||||
|
||||
configTicker := time.NewTicker(3 * time.Second)
|
||||
defer configTicker.Stop()
|
||||
var lastConfigMod time.Time
|
||||
|
||||
slog.Info("daemon started")
|
||||
defer slog.Info("daemon stopped")
|
||||
|
||||
for {
|
||||
select {
|
||||
case evt, ok := <-deck.Events():
|
||||
if !ok {
|
||||
slog.Warn("deck event channel closed, attempting reconnect...")
|
||||
deck.Close()
|
||||
deck = reconnectDeck(ctx, cfg, &pm, asm)
|
||||
if deck == nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
deck.SetBrightness(deviceBrightness(cfg))
|
||||
continue
|
||||
}
|
||||
|
||||
wasSsActive := ssCtrl.IsActive()
|
||||
ssCtrl.NotifyInput()
|
||||
|
||||
if evt.Kind == EventKeyPressed {
|
||||
if wasSsActive {
|
||||
ssCtrl.Deactivate(deck)
|
||||
|
||||
savedOutputs := pm.GetDisplayOutputs()
|
||||
|
||||
if page := pm.ActivePage(); page != nil {
|
||||
if err := pm.ActivatePage(pm.ActivePageName()); err != nil {
|
||||
slog.Warn("screensaver: re-render page", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
for idx, output := range savedOutputs {
|
||||
pm.ReRenderDisplayKey(idx, output)
|
||||
}
|
||||
}
|
||||
|
||||
page := pm.ActivePage()
|
||||
if page == nil {
|
||||
continue
|
||||
}
|
||||
for _, k := range page.Keys {
|
||||
if k.Index == evt.Index && k.Action != nil {
|
||||
slog.Debug("key pressed", "index", evt.Index, "action", k.Action.Type)
|
||||
|
||||
if k.Action.Type == "page" {
|
||||
asm.NotifyManualPage(k.Action.Page)
|
||||
}
|
||||
|
||||
go func(a *config.Action) {
|
||||
if err := ExecuteAction(a, deck, pm); err != nil {
|
||||
slog.Error("execute action", "error", err)
|
||||
}
|
||||
}(k.Action)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case win := <-windowCh:
|
||||
if page, ok := asm.Evaluate(win, pm.ActivePageName()); ok {
|
||||
if err := pm.ActivatePage(page); err != nil {
|
||||
slog.Warn("auto-switch: activate page", "error", err)
|
||||
}
|
||||
pm.startPeriodicKeys()
|
||||
}
|
||||
|
||||
case <-configTicker.C:
|
||||
path := config.ConfigPath(opts.ConfigPath)
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mt := fi.ModTime()
|
||||
if mt.After(lastConfigMod) && !lastConfigMod.IsZero() {
|
||||
lastConfigMod = mt
|
||||
slog.Info("config file changed, reloading")
|
||||
newCfg, err := config.LoadConfig(opts.ConfigPath)
|
||||
if err != nil {
|
||||
slog.Error("reload config", "error", err)
|
||||
continue
|
||||
}
|
||||
cfg = newCfg
|
||||
web.UpdateConfig(cfg)
|
||||
ssCtrl = NewScreensaver(&cfg.Screensaver)
|
||||
deck.SetBrightness(deviceBrightness(cfg))
|
||||
pm.stopPeriodicKeys()
|
||||
pm.LoadPages(cfg.Pages)
|
||||
asm.Reload(cfg.AutoSwitch)
|
||||
if len(cfg.AutoSwitch) > 0 && detector == nil {
|
||||
detector = NewWindowDetector()
|
||||
windowCh, _ = detector.Start(ctx)
|
||||
}
|
||||
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
|
||||
slog.Warn("reload: activate default page", "error", err)
|
||||
}
|
||||
pm.startPeriodicKeys()
|
||||
}
|
||||
if lastConfigMod.IsZero() {
|
||||
lastConfigMod = mt
|
||||
}
|
||||
|
||||
case <-reconnectTicker.C:
|
||||
if err := deck.SetBrightness(deck.Brightness()); err != nil {
|
||||
slog.Warn("deck connection lost, reconnecting...", "error", err)
|
||||
deck.Close()
|
||||
deck = reconnectDeck(ctx, cfg, &pm, asm)
|
||||
if deck == nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
deck.SetBrightness(deviceBrightness(cfg))
|
||||
}
|
||||
|
||||
case <-ssTicker.C:
|
||||
if ssCtrl.Check() {
|
||||
ssCtrl.Activate(deck, &cfg.Screensaver)
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkKeyboardTool() {
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
if _, err := exec.LookPath("wtype"); err != nil {
|
||||
slog.Warn("keyboard actions require wtype on Wayland — install it (e.g. apk add wtype) and restart")
|
||||
}
|
||||
} else {
|
||||
if _, err := exec.LookPath("xdotool"); err != nil {
|
||||
slog.Warn("keyboard actions require xdotool on X11 — install it (e.g. apk add xdotool) and restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deviceBrightness(cfg *config.Config) int {
|
||||
if len(cfg.Devices) > 0 && cfg.Devices[0].Brightness > 0 {
|
||||
return cfg.Devices[0].Brightness
|
||||
}
|
||||
return 75
|
||||
}
|
||||
|
||||
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager, asm *AutoSwitchManager) *Deck {
|
||||
for {
|
||||
newDeck, err := OpenDeck("")
|
||||
if err == nil {
|
||||
*pm = NewPageManager(newDeck)
|
||||
(*pm).LoadPages(cfg.Pages)
|
||||
(*pm).ActivatePage(cfg.DefaultPage)
|
||||
(*pm).startPeriodicKeys()
|
||||
asm.NotifyManualPage(cfg.DefaultPage)
|
||||
return newDeck
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user