refactor: restructure to standard Go project layout (cmd/ + internal/)
- Move entry point to cmd/streamdeck-lets-go/ - Split package main into internal packages: - internal/deck: hardware control (Deck, events, brightness) - internal/render: key rendering, icons, fonts, PageManager - internal/web: HTTP API + embedded SPA - internal/daemon: event loop, autoswitch, screensaver, actions - Add ConfigDir() to internal/config - Export cross-package API (Deck, PageManager, WebServer, etc.) - Move dist/arch/ → packaging/ with updated PKGBUILD/.SRCINFO - Add Makefile (build, test, vet, fmt, install) - Update README with new build commands and project structure - Delete unused media.go stub
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"streamdeck-lets-go/internal/config"
|
||||
"streamdeck-lets-go/internal/deck"
|
||||
"streamdeck-lets-go/internal/render"
|
||||
"streamdeck-lets-go/internal/web"
|
||||
)
|
||||
|
||||
type RunOptions struct {
|
||||
ConfigPath string
|
||||
HTTPAddr string
|
||||
HTTPEnabled bool
|
||||
NoDeck bool
|
||||
StartPage string
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||
web := 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 decks []*deck.Deck
|
||||
var err error
|
||||
|
||||
for {
|
||||
decks, err = deck.OpenAllDecks()
|
||||
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):
|
||||
}
|
||||
}
|
||||
|
||||
pageMgrs := make([]*render.PageManager, len(decks))
|
||||
for i, d := range decks {
|
||||
pageMgrs[i] = render.NewPageManager(d)
|
||||
pageMgrs[i].DefaultFont = cfg.Font
|
||||
pageMgrs[i].ShowLabelBackground = cfg.ShowLabelBackground
|
||||
pageMgrs[i].LoadPages(cfg.Pages)
|
||||
}
|
||||
primaryPM := pageMgrs[0]
|
||||
primaryDeck := decks[0]
|
||||
|
||||
startPage := opts.StartPage
|
||||
if startPage == "" {
|
||||
startPage = cfg.DefaultPage
|
||||
}
|
||||
if !pageExists(cfg.Pages, startPage) {
|
||||
slog.Warn("start page not found in pages, falling back to default",
|
||||
"requested", startPage, "default", cfg.DefaultPage)
|
||||
startPage = cfg.DefaultPage
|
||||
}
|
||||
|
||||
defer func() {
|
||||
for _, d := range decks {
|
||||
d.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for _, d := range decks {
|
||||
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
|
||||
}
|
||||
|
||||
web.SetDecks(decks)
|
||||
web.SetPageManager(primaryPM)
|
||||
web.SetExtraPageManagers(pageMgrs[1:])
|
||||
|
||||
for _, pm := range pageMgrs {
|
||||
if err := pm.ActivatePage(startPage); err != nil {
|
||||
slog.Warn("activate start 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, startPage)
|
||||
|
||||
ssCtrl := NewScreensaver(&cfg.Screensaver)
|
||||
|
||||
ge := NewGestureEngine(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs, func(idx int, a *config.Action) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
oldPage := primaryPM.ActivePageName()
|
||||
if err := ExecuteAction(a, primaryDeck, primaryPM); err != nil {
|
||||
slog.Error("execute action", "error", err)
|
||||
}
|
||||
newPage := primaryPM.ActivePageName()
|
||||
if newPage != oldPage {
|
||||
if a.Type == "page" {
|
||||
asm.NotifyManualPage(a.Page)
|
||||
}
|
||||
for _, pm := range pageMgrs {
|
||||
pm.StopPeriodicKeys()
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
web.BroadcastPageChange(newPage)
|
||||
} else {
|
||||
primaryPM.RefreshDisplayKey(idx)
|
||||
}
|
||||
})
|
||||
|
||||
reconnectTicker := time.NewTicker(2 * 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", "decks", len(decks))
|
||||
defer slog.Info("daemon stopped")
|
||||
|
||||
for {
|
||||
select {
|
||||
case evt, ok := <-primaryDeck.Events():
|
||||
if !ok {
|
||||
slog.Warn("deck event channel closed, attempting reconnect...")
|
||||
|
||||
// Stop all periodic scripts to avoid wasting cycles on a closed device.
|
||||
primaryPM.StopPeriodicKeys()
|
||||
asm.Pause()
|
||||
|
||||
primaryDeck.Close()
|
||||
newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage)
|
||||
if newDeck == nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Update all references to the new device and page manager.
|
||||
pageMgrs[0] = primaryPM
|
||||
decks[0] = newDeck
|
||||
primaryDeck = newDeck
|
||||
web.SetDecks(decks)
|
||||
web.SetPageManager(primaryPM)
|
||||
|
||||
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
|
||||
asm.NotifyManualPage(startPage)
|
||||
asm.Resume()
|
||||
continue
|
||||
}
|
||||
|
||||
wasSsActive := ssCtrl.IsActive()
|
||||
ssCtrl.NotifyInput()
|
||||
|
||||
if evt.Kind == deck.EventKeyPressed {
|
||||
if wasSsActive {
|
||||
ssCtrl.Deactivate(primaryDeck)
|
||||
|
||||
savedOutputs := primaryPM.GetDisplayOutputs()
|
||||
|
||||
if page := primaryPM.ActivePage(); page != nil {
|
||||
for _, pm := range pageMgrs {
|
||||
if err := pm.ActivatePage(primaryPM.ActivePageName()); err != nil {
|
||||
slog.Warn("screensaver: re-render page", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for idx, dout := range savedOutputs {
|
||||
if dout != nil {
|
||||
primaryPM.ReRenderDisplayKey(idx, dout.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page := primaryPM.ActivePage()
|
||||
if page == nil {
|
||||
continue
|
||||
}
|
||||
for _, k := range primaryPM.ActiveKeys() {
|
||||
if k.Index == evt.Index {
|
||||
if len(k.Actions) > 0 {
|
||||
ge.HandleEvent(evt, k.Actions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case win := <-windowCh:
|
||||
if asm.IsPaused() {
|
||||
// Drain buffered window events accumulated while disconnected.
|
||||
for len(windowCh) > 0 {
|
||||
<-windowCh
|
||||
}
|
||||
continue
|
||||
}
|
||||
if page, ok := asm.Evaluate(win, primaryPM.ActivePageName()); ok {
|
||||
for _, pm := range pageMgrs {
|
||||
if err := pm.ActivatePage(page); err != nil {
|
||||
slog.Warn("auto-switch: activate page", "error", err)
|
||||
}
|
||||
pm.StopPeriodicKeys()
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
web.BroadcastPageChange(page)
|
||||
}
|
||||
|
||||
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)
|
||||
for _, d := range decks {
|
||||
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
|
||||
}
|
||||
var activePages []string
|
||||
for _, pm := range pageMgrs {
|
||||
activePages = append(activePages, pm.ActivePageName())
|
||||
pm.StopPeriodicKeys()
|
||||
pm.DefaultFont = cfg.Font
|
||||
pm.ShowLabelBackground = cfg.ShowLabelBackground
|
||||
pm.LoadPages(cfg.Pages)
|
||||
}
|
||||
ge.ReloadTiming(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs)
|
||||
asm.Reload(cfg.AutoSwitch)
|
||||
if len(cfg.AutoSwitch) > 0 && detector == nil {
|
||||
detector = NewWindowDetector()
|
||||
windowCh, _ = detector.Start(ctx)
|
||||
}
|
||||
for i, pm := range pageMgrs {
|
||||
page := cfg.DefaultPage
|
||||
if i < len(activePages) && activePages[i] != "" {
|
||||
for _, p := range cfg.Pages {
|
||||
if p.Name == activePages[i] {
|
||||
page = activePages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := pm.ActivatePage(page); err != nil {
|
||||
slog.Warn("reload: activate page", "error", err)
|
||||
}
|
||||
pm.StartPeriodicKeys()
|
||||
}
|
||||
}
|
||||
if lastConfigMod.IsZero() {
|
||||
lastConfigMod = mt
|
||||
}
|
||||
|
||||
case <-reconnectTicker.C:
|
||||
if err := primaryDeck.SetBrightness(primaryDeck.Brightness()); err != nil {
|
||||
slog.Warn("deck connection lost, reconnecting...", "error", err)
|
||||
|
||||
// Stop all periodic scripts to avoid wasting cycles on a closed device.
|
||||
primaryPM.StopPeriodicKeys()
|
||||
asm.Pause()
|
||||
|
||||
primaryDeck.Close()
|
||||
newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage)
|
||||
if newDeck == nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Update all references to the new device and page manager.
|
||||
pageMgrs[0] = primaryPM
|
||||
decks[0] = newDeck
|
||||
primaryDeck = newDeck
|
||||
web.SetDecks(decks)
|
||||
web.SetPageManager(primaryPM)
|
||||
|
||||
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
|
||||
asm.NotifyManualPage(startPage)
|
||||
asm.Resume()
|
||||
}
|
||||
|
||||
case <-ssTicker.C:
|
||||
if ssCtrl.Check() {
|
||||
for _, d := range decks {
|
||||
ssCtrl.Activate(d, &cfg.Screensaver)
|
||||
}
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkKeyboardTool() {
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
// On Wayland, prefer ydotool over wtype
|
||||
if _, err := exec.LookPath("ydotool"); err == nil {
|
||||
// Start ydotoold daemon if not already running
|
||||
cmd := exec.Command("ydotoold")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
// Run in background, don't wait
|
||||
_ = cmd.Start()
|
||||
slog.Info("ydotoold daemon started for keyboard input")
|
||||
} else if _, err := exec.LookPath("wtype"); err == nil {
|
||||
slog.Warn("ydotool not found, falling back to wtype — consider installing ydotool for better Wayland support (e.g. apk add ydotool)")
|
||||
} else {
|
||||
slog.Warn("keyboard actions require ydotool or wtype on Wayland — install one (e.g. apk add ydotool) 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, serial string) int {
|
||||
for _, d := range cfg.Devices {
|
||||
if d.Serial == serial || d.Serial == "" {
|
||||
if d.Brightness > 0 {
|
||||
return d.Brightness
|
||||
}
|
||||
return 75
|
||||
}
|
||||
}
|
||||
return 75
|
||||
}
|
||||
|
||||
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **render.PageManager, startPage string) *deck.Deck {
|
||||
for {
|
||||
newDeck, err := deck.OpenDeck("")
|
||||
if err == nil {
|
||||
*pm = render.NewPageManager(newDeck)
|
||||
(*pm).DefaultFont = cfg.Font
|
||||
(*pm).ShowLabelBackground = cfg.ShowLabelBackground
|
||||
(*pm).LoadPages(cfg.Pages)
|
||||
(*pm).ActivatePage(startPage)
|
||||
(*pm).StartPeriodicKeys()
|
||||
return newDeck
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(1 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pageExists reports whether pages contains a page with the given name.
|
||||
func pageExists(pages []config.PageConfig, name string) bool {
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user