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:
totmin
2026-08-26 10:35:20 +07:00
parent bd8895f9ef
commit 8a0c167924
45 changed files with 2506 additions and 2598 deletions
+288
View File
@@ -0,0 +1,288 @@
package daemon
import (
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
)
func ExecuteAction(a *config.Action, deck *deck.Deck, pm *render.PageManager) error {
if a == nil {
return nil
}
slog.Debug("execute action", "type", a.Type)
switch a.Type {
case "command":
return execCommand(a)
case "builtin":
return execBuiltin(a, deck, pm)
case "script":
return execScript(a)
case "page":
if pm != nil {
return pm.ActivatePage(a.Page)
}
return fmt.Errorf("page manager not available")
case "keyboard":
return execKeyboard(a)
default:
return fmt.Errorf("unknown action type: %s", a.Type)
}
}
func execCommand(a *config.Action) error {
cmd := exec.Command("sh", "-c", a.Command)
if a.Background {
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return fmt.Errorf("start command: %w", err)
}
go func() {
if err := cmd.Wait(); err != nil {
slog.Warn("command finished", "cmd", a.Command, "error", err)
}
}()
return nil
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("run command: %w", err)
}
return nil
}
func execScript(a *config.Action) error {
cmd := exec.Command(a.Script)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("run script: %w", err)
}
return nil
}
func execBuiltin(a *config.Action, deck *deck.Deck, pm *render.PageManager) error {
parts := strings.SplitN(a.Builtin, ":", 2)
if len(parts) < 1 {
return fmt.Errorf("invalid builtin: %s", a.Builtin)
}
category := parts[0]
action := ""
if len(parts) > 1 {
action = parts[1]
}
slog.Debug("builtin", "category", category, "action", action)
switch category {
case "media":
return mediaAction(action)
case "volume":
return volumeAction(action)
case "brightness":
return brightnessAction(action)
case "page":
if pm != nil {
return pageBuiltinAction(action, pm)
}
return fmt.Errorf("page manager not available")
case "deck":
return deckBuiltinAction(action, deck)
default:
return fmt.Errorf("unknown builtin category: %s", category)
}
}
func mediaAction(action string) error {
var cmd string
switch action {
case "playpause":
cmd = "playerctl play-pause"
case "next":
cmd = "playerctl next"
case "prev":
cmd = "playerctl previous"
case "stop":
cmd = "playerctl stop"
default:
return fmt.Errorf("unknown media action: %s", action)
}
return exec.Command("sh", "-c", cmd).Run()
}
func volumeAction(action string) error {
var cmd string
switch action {
case "up":
cmd = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+"
case "down":
cmd = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"
case "mute":
cmd = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
default:
return fmt.Errorf("unknown volume action: %s", action)
}
return exec.Command("sh", "-c", cmd).Run()
}
func brightnessAction(action string) error {
var cmd string
switch action {
case "up":
cmd = "brightnessctl s +10%"
case "down":
cmd = "brightnessctl s 10%-"
default:
return fmt.Errorf("unknown brightness action: %s", action)
}
return exec.Command("sh", "-c", cmd).Run()
}
func pageBuiltinAction(action string, pm *render.PageManager) error {
switch action {
case "next":
pageNames := pm.GetPageNames()
current := pm.ActivePageName()
for i, name := range pageNames {
if name == current {
next := (i + 1) % len(pageNames)
return pm.ActivatePage(pageNames[next])
}
}
if len(pageNames) > 0 {
return pm.ActivatePage(pageNames[0])
}
case "prev":
pageNames := pm.GetPageNames()
current := pm.ActivePageName()
for i, name := range pageNames {
if name == current {
prev := (i - 1 + len(pageNames)) % len(pageNames)
return pm.ActivatePage(pageNames[prev])
}
}
if len(pageNames) > 0 {
return pm.ActivatePage(pageNames[0])
}
default:
return fmt.Errorf("unknown page action: %s", action)
}
return nil
}
func deckBuiltinAction(action string, deck *deck.Deck) error {
switch action {
case "brightness-up":
current := deck.Brightness()
next := current + 25
if next > 100 {
next = 25
}
return deck.SetBrightness(next)
case "brightness-down":
current := deck.Brightness()
next := current - 25
if next < 25 {
next = 100
}
return deck.SetBrightness(next)
default:
return fmt.Errorf("unknown deck action: %s", action)
}
}
func keyboardTool() string {
if os.Getenv("WAYLAND_DISPLAY") != "" {
if _, err := exec.LookPath("ydotool"); err == nil {
return "ydotool"
}
if _, err := exec.LookPath("wtype"); err == nil {
return "wtype"
}
return ""
}
if _, err := exec.LookPath("xdotool"); err == nil {
return "xdotool"
}
return ""
}
func execKeyboard(a *config.Action) error {
tool := keyboardTool()
if tool == "" {
wayland := os.Getenv("WAYLAND_DISPLAY") != ""
if wayland {
return fmt.Errorf("keyboard action requires wtype — install it (e.g. apk add wtype) and restart")
}
return fmt.Errorf("keyboard action requires xdotool — install it (e.g. apk add xdotool) and restart")
}
keys := strings.ToLower(strings.TrimSpace(a.Keys))
if keys == "" {
return fmt.Errorf("keyboard action: keys is empty")
}
parts := strings.Split(keys, "+")
if len(parts) == 0 {
return fmt.Errorf("keyboard action: invalid keys format %q", a.Keys)
}
switch tool {
case "ydotool":
return execYDOTool(keys)
case "wtype":
return execWType(parts)
case "xdotool":
return execXDoTool(keys)
default:
return fmt.Errorf("keyboard action: unsupported tool %q", tool)
}
}
func execWType(parts []string) error {
mainKey := parts[len(parts)-1]
mods := parts[:len(parts)-1]
args := make([]string, 0, 2+len(mods)*2)
for _, m := range mods {
args = append(args, "-M", m)
}
args = append(args, "-P", mainKey, "-p", mainKey)
for i := len(mods) - 1; i >= 0; i-- {
args = append(args, "-m", mods[i])
}
cmd := exec.Command("wtype", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func execXDoTool(keys string) error {
cmd := exec.Command("xdotool", "key", keys)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func execYDOTool(keys string) error {
// Use 'type' command instead of 'key' for better compatibility with Wine/games on Wayland
cmd := exec.Command("ydotool", "type", keys)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+476
View File
@@ -0,0 +1,476 @@
package daemon
import (
"context"
"encoding/json"
"log/slog"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"streamdeck-lets-go/internal/config"
)
type WindowDetector interface {
Start(ctx context.Context) (<-chan Window, error)
Close()
}
type Window struct {
WMClass string
Title string
}
func NewWindowDetector() WindowDetector {
desktop := os.Getenv("XDG_CURRENT_DESKTOP")
wayland := os.Getenv("WAYLAND_DISPLAY")
slog.Debug("detecting desktop environment", "XDG_CURRENT_DESKTOP", desktop, "WAYLAND_DISPLAY", wayland)
switch {
case desktop == "Hyprland":
slog.Info("auto-switch: using Hyprland detector")
d := &hyprlandDetector{}
d.PollInterval = 200 * time.Millisecond
return d
case desktop == "sway":
slog.Info("auto-switch: using Sway detector")
d := &swayDetector{}
d.PollInterval = 500 * time.Millisecond
return d
case desktop == "niri":
slog.Info("auto-switch: using Niri detector")
d := &niriDetector{}
d.PollInterval = 200 * time.Millisecond
return d
case desktop == "GNOME" || strings.Contains(desktop, "GNOME"):
slog.Info("auto-switch: GNOME detector (stub)")
d := &gnomeDetector{}
d.PollInterval = 500 * time.Millisecond
return d
case strings.Contains(desktop, "KDE") || strings.Contains(desktop, "plasma"):
slog.Info("auto-switch: KDE detector (stub)")
d := &kdeDetector{}
d.PollInterval = 500 * time.Millisecond
return d
case wayland != "":
slog.Warn("auto-switch: unknown Wayland compositor, using portal fallback")
d := &portalDetector{}
d.PollInterval = 500 * time.Millisecond
return d
default:
slog.Info("auto-switch: using X11 detector")
d := &x11Detector{}
d.PollInterval = 200 * time.Millisecond
return d
}
}
type basePoll struct {
PollInterval time.Duration
pollFn func(context.Context) Window
cancel context.CancelFunc
lastRaw string
}
func (b *basePoll) start(ctx context.Context, ch chan<- Window) {
ticker := time.NewTicker(b.PollInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
win := b.pollFn(ctx)
raw := win.WMClass + "|" + win.Title
if raw != b.lastRaw {
b.lastRaw = raw
select {
case ch <- win:
default:
}
}
case <-ctx.Done():
return
}
}
}
func (b *basePoll) stop() {
if b.cancel != nil {
b.cancel()
}
}
func runCmd(ctx context.Context, cmd string, args ...string) (string, error) {
c := exec.CommandContext(ctx, cmd, args...)
out, err := c.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func runCmdJSON(ctx context.Context, cmd string, args ...string) ([]byte, error) {
c := exec.CommandContext(ctx, cmd, args...)
return c.Output()
}
type hyprlandDetector struct {
basePoll
}
func (d *hyprlandDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
ch := make(chan Window, 4)
go d.basePoll.start(ctx, ch)
return ch, nil
}
func (d *hyprlandDetector) Close() { d.basePoll.stop() }
type hyprlandWindow struct {
Class string `json:"class"`
Title string `json:"title"`
}
func (d *hyprlandDetector) getWindow(ctx context.Context) Window {
data, err := runCmdJSON(ctx, "hyprctl", "activewindow", "-j")
if err != nil {
return Window{}
}
var win hyprlandWindow
if err := json.Unmarshal(data, &win); err != nil {
return Window{}
}
return Window{WMClass: win.Class, Title: win.Title}
}
type swayDetector struct {
basePoll
}
func (d *swayDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
ch := make(chan Window, 4)
go d.basePoll.start(ctx, ch)
return ch, nil
}
func (d *swayDetector) Close() { d.basePoll.stop() }
type swayNode struct {
Type string `json:"type"`
Focused bool `json:"focused"`
AppID *string `json:"app_id"`
Name string `json:"name"`
Nodes []swayNode `json:"nodes"`
Floating []swayNode `json:"floating_nodes"`
Props *swayWindowProps `json:"window_properties"`
}
type swayWindowProps struct {
Class string `json:"class"`
}
func (d *swayDetector) getWindow(ctx context.Context) Window {
data, err := runCmdJSON(ctx, "swaymsg", "-t", "get_tree")
if err != nil {
return Window{}
}
var root swayNode
if err := json.Unmarshal(data, &root); err != nil {
return Window{}
}
node := findFocusedSwayNode(&root)
if node == nil {
return Window{}
}
wmClass := ""
if node.AppID != nil {
wmClass = *node.AppID
} else if node.Props != nil {
wmClass = node.Props.Class
}
return Window{WMClass: wmClass, Title: node.Name}
}
func findFocusedSwayNode(node *swayNode) *swayNode {
if node.Type == "con" && node.Focused {
return node
}
for i := range node.Nodes {
if found := findFocusedSwayNode(&node.Nodes[i]); found != nil {
return found
}
}
for i := range node.Floating {
if found := findFocusedSwayNode(&node.Floating[i]); found != nil {
return found
}
}
return nil
}
type niriDetector struct {
basePoll
}
func (d *niriDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
ch := make(chan Window, 4)
go d.basePoll.start(ctx, ch)
return ch, nil
}
func (d *niriDetector) Close() { d.basePoll.stop() }
type niriWindow struct {
AppID *string `json:"app_id"`
Title string `json:"title"`
}
func (d *niriDetector) getWindow(ctx context.Context) Window {
data, err := runCmdJSON(ctx, "niri", "msg", "--json", "focused-window")
if err != nil {
return Window{}
}
var win niriWindow
if err := json.Unmarshal(data, &win); err != nil {
return Window{}
}
if win.AppID == nil {
return Window{}
}
return Window{WMClass: *win.AppID, Title: win.Title}
}
type gnomeDetector struct{ basePoll }
func (d *gnomeDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
ch := make(chan Window, 4)
go d.basePoll.start(ctx, ch)
return ch, nil
}
func (d *gnomeDetector) Close() { d.basePoll.stop() }
func (d *gnomeDetector) getWindow(ctx context.Context) Window {
rawClass, err := runCmd(ctx, "gdbus", "call", "--session",
"--dest", "org.gnome.Shell",
"--object-path", "/org/gnome/Shell",
"--method", "org.gnome.Shell.Eval",
"global.display.focus_window?.get_wm_class() ?? ''")
if err != nil {
return Window{}
}
rawTitle, err := runCmd(ctx, "gdbus", "call", "--session",
"--dest", "org.gnome.Shell",
"--object-path", "/org/gnome/Shell",
"--method", "org.gnome.Shell.Eval",
"global.display.focus_window?.title ?? ''")
if err != nil {
_ = rawClass
return Window{}
}
wmClass := parseGnomeEval(rawClass)
title := parseGnomeEval(rawTitle)
return Window{WMClass: wmClass, Title: title}
}
func parseGnomeEval(out string) string {
out = strings.TrimSpace(out)
out = strings.TrimPrefix(out, "(true, ")
out = strings.TrimSuffix(out, ")")
out = strings.Trim(out, `"'`)
return out
}
type kdeDetector struct{ basePoll }
func (d *kdeDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
ch := make(chan Window, 4)
go d.basePoll.start(ctx, ch)
return ch, nil
}
func (d *kdeDetector) Close() { d.basePoll.stop() }
func (d *kdeDetector) getWindow(ctx context.Context) Window {
wmClass, _ := runCmd(ctx, "kdotool", "getactivewindow", "getclassname")
title, _ := runCmd(ctx, "kdotool", "getactivewindow", "getwindowname")
return Window{WMClass: wmClass, Title: title}
}
type x11Detector struct{ basePoll }
func (d *x11Detector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
ch := make(chan Window, 4)
go d.basePoll.start(ctx, ch)
return ch, nil
}
func (d *x11Detector) Close() { d.basePoll.stop() }
func (d *x11Detector) getWindow(ctx context.Context) Window {
wmClass, _ := runCmd(ctx, "xdotool", "getactivewindow", "getclassname")
title, _ := runCmd(ctx, "xdotool", "getactivewindow", "getwindowname")
return Window{WMClass: wmClass, Title: title}
}
type portalDetector struct{ basePoll }
func (d *portalDetector) Start(ctx context.Context) (<-chan Window, error) {
d.pollFn = d.getWindow
ctx, d.cancel = context.WithCancel(ctx)
ch := make(chan Window, 4)
go d.basePoll.start(ctx, ch)
return ch, nil
}
func (d *portalDetector) Close() { d.basePoll.stop() }
func (d *portalDetector) getWindow(ctx context.Context) Window {
return Window{}
}
type AutoSwitchManager struct {
mu sync.RWMutex
rules []compiledRule
lastManualPage string
autoPage string
autoStay bool
paused atomic.Bool // disables auto-switch when device is disconnected
}
type compiledRule struct {
wmClass *regexp.Regexp
title *regexp.Regexp
rule config.SwitchRule
}
func NewAutoSwitchManager(rules []config.SwitchRule, defaultPage string) *AutoSwitchManager {
m := &AutoSwitchManager{
lastManualPage: defaultPage,
}
m.Reload(rules)
return m
}
func (m *AutoSwitchManager) Reload(rules []config.SwitchRule) {
m.mu.Lock()
defer m.mu.Unlock()
m.rules = make([]compiledRule, 0, len(rules))
for _, r := range rules {
cr := compiledRule{rule: r}
if r.WMClass != "" {
re, err := regexp.Compile(r.WMClass)
if err != nil {
slog.Warn("auto-switch: invalid wm_class regex", "pattern", r.WMClass, "error", err)
continue
}
cr.wmClass = re
}
if r.Title != "" {
re, err := regexp.Compile(r.Title)
if err != nil {
slog.Warn("auto-switch: invalid title regex", "pattern", r.Title, "error", err)
continue
}
cr.title = re
}
m.rules = append(m.rules, cr)
}
slog.Info("auto-switch: rules loaded", "count", len(m.rules))
}
func (m *AutoSwitchManager) NotifyManualPage(name string) {
m.mu.Lock()
defer m.mu.Unlock()
m.lastManualPage = name
m.autoPage = ""
slog.Debug("auto-switch: manual page set", "page", name)
}
// Pause disables auto-switch evaluation — use when the device is disconnected
// to prevent unnecessary page switches on a closed deck.
func (m *AutoSwitchManager) Pause() {
m.paused.Store(true)
slog.Debug("auto-switch: paused")
}
// Resume re-enables auto-switch evaluation after reconnect.
// Stale window events should be drained from the channel before resuming.
func (m *AutoSwitchManager) Resume() {
m.paused.Store(false)
slog.Debug("auto-switch: resumed")
}
// IsPaused returns true when auto-switch evaluation is paused.
func (m *AutoSwitchManager) IsPaused() bool {
return m.paused.Load()
}
func (m *AutoSwitchManager) Evaluate(win Window, currentPage string) (page string, shouldSwitch bool) {
m.mu.Lock()
defer m.mu.Unlock()
if win.WMClass == "" && win.Title == "" {
if m.autoPage != "" && m.autoPage == currentPage && !m.autoStay {
m.autoPage = ""
slog.Debug("auto-switch: no focused window, reverting to manual page", "page", m.lastManualPage)
return m.lastManualPage, true
}
return "", false
}
for _, r := range m.rules {
if r.wmClass == nil && r.title == nil {
continue
}
if r.wmClass != nil && !r.wmClass.MatchString(win.WMClass) {
continue
}
if r.title != nil && !r.title.MatchString(win.Title) {
continue
}
m.autoPage = r.rule.Page
m.autoStay = r.rule.Stay
slog.Debug("auto-switch: rule matched",
"wm_class", win.WMClass, "page", r.rule.Page, "stay", r.rule.Stay)
return r.rule.Page, true
}
if m.autoPage != "" && m.autoPage == currentPage && !m.autoStay {
m.autoPage = ""
slog.Debug("auto-switch: reverting to manual page", "page", m.lastManualPage)
return m.lastManualPage, true
}
return "", false
}
+223
View File
@@ -0,0 +1,223 @@
package daemon
import (
"encoding/json"
"testing"
"streamdeck-lets-go/internal/config"
)
func TestFindFocusedSwayNode(t *testing.T) {
input := `{
"id": 0,
"type": "root",
"nodes": [
{
"type": "output",
"name": "eDP-1",
"nodes": [
{
"type": "workspace",
"name": "1",
"nodes": [
{
"type": "con",
"focused": false,
"app_id": "firefox",
"name": "Firefox",
"nodes": []
}
]
},
{
"type": "workspace",
"name": "2",
"nodes": [
{
"type": "con",
"focused": true,
"app_id": "Alacritty",
"name": "Alacritty",
"nodes": []
}
]
}
],
"floating_nodes": []
}
]
}`
var root swayNode
if err := json.Unmarshal([]byte(input), &root); err != nil {
t.Fatal(err)
}
node := findFocusedSwayNode(&root)
if node == nil {
t.Fatal("expected focused node, got nil")
}
if node.AppID == nil || *node.AppID != "Alacritty" {
t.Fatalf("expected Alacritty, got %v", node.AppID)
}
if node.Name != "Alacritty" {
t.Fatalf("expected name Alacritty, got %s", node.Name)
}
}
func TestFindFocusedSwayNode_XWayland(t *testing.T) {
input := `{
"id": 0,
"type": "root",
"nodes": [
{
"type": "output",
"nodes": [
{
"type": "workspace",
"nodes": [
{
"type": "con",
"focused": true,
"app_id": null,
"name": "Steam",
"window_properties": {"class": "steam"},
"nodes": []
}
]
}
]
}
]
}`
var root swayNode
json.Unmarshal([]byte(input), &root)
node := findFocusedSwayNode(&root)
if node == nil {
t.Fatal("expected focused node, got nil")
}
if node.Props == nil || node.Props.Class != "steam" {
t.Fatal("expected window_properties.class = steam")
}
}
func TestFindFocusedSwayNode_NoFocus(t *testing.T) {
input := `{
"id": 0,
"type": "root",
"nodes": [
{
"type": "output",
"nodes": [
{
"type": "workspace",
"nodes": [
{
"type": "con",
"focused": false,
"app_id": "kitty",
"name": "kitty",
"nodes": []
}
]
}
]
}
]
}`
var root swayNode
json.Unmarshal([]byte(input), &root)
node := findFocusedSwayNode(&root)
if node != nil {
t.Fatal("expected nil when no focused node")
}
}
func TestAutoSwitchManager_Evaluate(t *testing.T) {
rules := []config.SwitchRule{
{WMClass: "firefox", Page: "browser"},
{WMClass: "Alacritty|kitty", Title: ".*vim.*", Page: "coding", Stay: true},
}
m := NewAutoSwitchManager(rules, "default")
page, ok := m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "default")
if !ok || page != "browser" {
t.Fatalf("expected browser, got %s/%v", page, ok)
}
page, ok = m.Evaluate(Window{WMClass: "Alacritty", Title: "nvim main.go"}, "default")
if !ok || page != "coding" {
t.Fatalf("expected coding, got %s/%v", page, ok)
}
page, ok = m.Evaluate(Window{WMClass: "firefox", Title: "YouTube"}, "browser")
if !ok || page != "browser" {
t.Fatalf("expected browser again, got %s/%v", page, ok)
}
}
func TestAutoSwitchManager_Stay(t *testing.T) {
rules := []config.SwitchRule{
{WMClass: "firefox", Page: "browser", Stay: false},
}
m := NewAutoSwitchManager(rules, "home")
m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home")
page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser")
if !ok || page != "home" {
t.Fatalf("expected revert to home, got %s/%v", page, ok)
}
}
func TestAutoSwitchManager_StayTrue(t *testing.T) {
rules := []config.SwitchRule{
{WMClass: "firefox", Page: "browser", Stay: true},
}
m := NewAutoSwitchManager(rules, "home")
m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home")
page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser")
if ok {
t.Fatalf("expected no switch (stay=true), got %s", page)
}
}
func TestAutoSwitchManager_ManualOverride(t *testing.T) {
rules := []config.SwitchRule{
{WMClass: "firefox", Page: "browser", Stay: false},
}
m := NewAutoSwitchManager(rules, "home")
// 1. Firefox opens → auto-switch to "browser"
m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "home")
// 2. User manually switches to "settings"
m.NotifyManualPage("settings")
// 3. Window changes to thunar on "settings" → no match → stay on settings
page, ok := m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "settings")
if ok {
t.Fatalf("expected no switch (already on manual page), got %s", page)
}
// 4. Firefox comes back → auto-switch to "browser" again
page, ok = m.Evaluate(Window{WMClass: "firefox", Title: "Mozilla Firefox"}, "settings")
if !ok || page != "browser" {
t.Fatalf("expected browser, got %s/%v", page, ok)
}
// 5. Thunar again, current page is "browser" (auto) → no match → revert to "settings"
page, ok = m.Evaluate(Window{WMClass: "thunar", Title: "Files"}, "browser")
if !ok || page != "settings" {
t.Fatalf("expected revert to settings, got %s/%v", page, ok)
}
}
+398
View File
@@ -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
}
+173
View File
@@ -0,0 +1,173 @@
package daemon
import (
"sync"
"time"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
)
type ActionCallback func(idx int, a *config.Action)
type gestureKeyState struct {
mu sync.Mutex
pressedAt time.Time
lastTapAt time.Time
holdTimer *time.Timer
holdActive bool
pendingTap *time.Timer
}
type GestureEngine struct {
mu sync.Mutex
states map[int]*gestureKeyState
longMs time.Duration
doubleMs time.Duration
onAction ActionCallback
}
func NewGestureEngine(longMs, doubleMs int, cb ActionCallback) *GestureEngine {
if longMs <= 0 {
longMs = config.DefaultLongPressMs
}
if doubleMs <= 0 {
doubleMs = config.DefaultDoubleTapMs
}
return &GestureEngine{
states: make(map[int]*gestureKeyState),
longMs: time.Duration(longMs) * time.Millisecond,
doubleMs: time.Duration(doubleMs) * time.Millisecond,
onAction: cb,
}
}
func (ge *GestureEngine) ReloadTiming(longMs, doubleMs int) {
ge.mu.Lock()
defer ge.mu.Unlock()
if longMs > 0 {
ge.longMs = time.Duration(longMs) * time.Millisecond
}
if doubleMs > 0 {
ge.doubleMs = time.Duration(doubleMs) * time.Millisecond
}
}
func (ge *GestureEngine) Reset() {
ge.mu.Lock()
defer ge.mu.Unlock()
for _, s := range ge.states {
s.mu.Lock()
s.cancelTimers()
s.holdActive = false
s.lastTapAt = time.Time{}
s.mu.Unlock()
}
}
func (s *gestureKeyState) cancelTimers() {
if s.holdTimer != nil {
s.holdTimer.Stop()
s.holdTimer = nil
}
if s.pendingTap != nil {
s.pendingTap.Stop()
s.pendingTap = nil
}
}
func (ge *GestureEngine) getState(index int) *gestureKeyState {
ge.mu.Lock()
defer ge.mu.Unlock()
s, ok := ge.states[index]
if !ok {
s = &gestureKeyState{}
ge.states[index] = s
}
return s
}
func findAction(actions []config.KeyAction, trigger string) *config.Action {
for _, a := range actions {
if a.Trigger == trigger {
return &a.Action
}
}
return nil
}
func (ge *GestureEngine) HandleEvent(evt deck.Event, actions []config.KeyAction) {
state := ge.getState(evt.Index)
if len(actions) == 0 {
return
}
switch evt.Kind {
case deck.EventKeyPressed:
ge.handleKeyPress(state, evt, actions)
case deck.EventKeyReleased:
ge.handleKeyRelease(state, evt, actions)
}
}
func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) {
state.mu.Lock()
state.cancelTimers()
state.holdActive = false
state.pressedAt = evt.At
state.holdTimer = time.AfterFunc(ge.longMs, func() {
state.mu.Lock()
state.holdActive = true
state.holdTimer = nil
state.mu.Unlock()
if a := findAction(actions, "hold_start"); a != nil {
ge.onAction(evt.Index, a)
} else if a := findAction(actions, "long_press"); a != nil {
ge.onAction(evt.Index, a)
}
})
state.mu.Unlock()
}
func (ge *GestureEngine) handleKeyRelease(state *gestureKeyState, evt deck.Event, actions []config.KeyAction) {
state.mu.Lock()
state.cancelTimers()
if state.holdActive {
state.holdActive = false
state.mu.Unlock()
if a := findAction(actions, "hold_end"); a != nil {
ge.onAction(evt.Index, a)
}
return
}
if !state.lastTapAt.IsZero() && evt.At.Sub(state.lastTapAt) <= ge.doubleMs {
state.lastTapAt = time.Time{}
state.mu.Unlock()
if a := findAction(actions, "double_tap"); a != nil {
ge.onAction(evt.Index, a)
}
return
}
lastTap := evt.At
state.lastTapAt = lastTap
state.pendingTap = time.AfterFunc(ge.doubleMs, func() {
state.mu.Lock()
if state.lastTapAt.Equal(lastTap) {
state.lastTapAt = time.Time{}
state.pendingTap = nil
state.mu.Unlock()
if a := findAction(actions, "tap"); a != nil {
ge.onAction(evt.Index, a)
}
return
}
state.mu.Unlock()
})
state.mu.Unlock()
}
+94
View File
@@ -0,0 +1,94 @@
package daemon
import (
"log/slog"
"time"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
)
type Screensaver struct {
enabled bool
idleAfter time.Duration
lastInput time.Time
active bool
savedBrightness int
}
func NewScreensaver(cfg *config.ScreensaverCfg) *Screensaver {
ss := &Screensaver{
enabled: cfg.Enabled,
idleAfter: time.Duration(cfg.IdleSeconds) * time.Second,
lastInput: time.Now(),
}
if ss.idleAfter <= 0 {
ss.idleAfter = 30 * time.Second
}
return ss
}
func (ss *Screensaver) NotifyInput() {
ss.lastInput = time.Now()
if ss.active {
ss.active = false
slog.Debug("screensaver deactivated")
}
}
func (ss *Screensaver) Check() bool {
if !ss.enabled {
return false
}
if time.Since(ss.lastInput) > ss.idleAfter {
if !ss.active {
ss.active = true
slog.Debug("screensaver activated")
return true
}
return false
}
return false
}
func (ss *Screensaver) Activate(deck *deck.Deck, cfg *config.ScreensaverCfg) {
ss.savedBrightness = deck.Brightness()
brightness := cfg.Brightness
if brightness <= 0 {
brightness = 10
}
if err := deck.SetBrightness(brightness); err != nil {
slog.Warn("screensaver: set brightness", "error", err)
}
if cfg.Image != "" {
img, err := render.LoadImage(cfg.Image, 0, 0)
if err != nil {
slog.Warn("screensaver: load image", "path", cfg.Image, "error", err)
return
}
if err := deck.FillPanel(img); err != nil {
slog.Warn("screensaver: fill panel", "error", err)
}
}
slog.Info("screensaver activated")
}
func (ss *Screensaver) Deactivate(deck *deck.Deck) {
brightness := ss.savedBrightness
if brightness <= 0 {
brightness = 75
}
if err := deck.SetBrightness(brightness); err != nil {
slog.Warn("screensaver: restore brightness", "error", err)
}
ss.savedBrightness = 0
slog.Info("screensaver deactivated")
}
func (ss *Screensaver) IsActive() bool {
return ss.active
}