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 decks []*Deck var err error for { decks, err = 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([]*PageManager, len(decks)) for i, d := range decks { pageMgrs[i] = NewPageManager(d) pageMgrs[i].defaultFont = cfg.Font pageMgrs[i].showLabelBackground = cfg.ShowLabelBackground pageMgrs[i].LoadPages(cfg.Pages) } primaryPM := pageMgrs[0] primaryDeck := decks[0] 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(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) 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(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", "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...") primaryDeck.Close() newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm) if newDeck == nil { return ctx.Err() } decks[0] = newDeck primaryDeck = newDeck web.SetDecks(decks) newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial())) continue } wasSsActive := ssCtrl.IsActive() ssCtrl.NotifyInput() if evt.Kind == 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 page.Keys { if k.Index == evt.Index { if len(k.Actions) > 0 { ge.HandleEvent(evt, k.Actions) } break } } case win := <-windowCh: 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) primaryDeck.Close() newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm) if newDeck == nil { return ctx.Err() } decks[0] = newDeck primaryDeck = newDeck web.SetDecks(decks) newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial())) } 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 **PageManager, asm *AutoSwitchManager) *Deck { for { newDeck, err := OpenDeck("") if err == nil { *pm = NewPageManager(newDeck) (*pm).defaultFont = cfg.Font (*pm).showLabelBackground = cfg.ShowLabelBackground (*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): } } }