1
0
streamdeck-lets-go/daemon.go
Maksim Totmin f65ff75a43 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
2026-06-16 22:35:14 +07:00

257 lines
5.7 KiB
Go

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):
}
}
}