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
+34 -19
View File
@@ -11,16 +11,16 @@ import (
)
type Config struct {
Version int `json:"version"`
LogLevel string `json:"log_level"`
DefaultPage string `json:"default_page"`
Font string `json:"font,omitempty"`
ShowLabelBackground bool `json:"show_label_background"`
Devices []DeviceConfig `json:"devices"`
Pages []PageConfig `json:"pages"`
AutoSwitch []SwitchRule `json:"auto_switch"`
Screensaver ScreensaverCfg `json:"screensaver"`
Timing TimingConfig `json:"timing,omitempty"`
Version int `json:"version"`
LogLevel string `json:"log_level"`
DefaultPage string `json:"default_page"`
Font string `json:"font,omitempty"`
ShowLabelBackground bool `json:"show_label_background"`
Devices []DeviceConfig `json:"devices"`
Pages []PageConfig `json:"pages"`
AutoSwitch []SwitchRule `json:"auto_switch"`
Screensaver ScreensaverCfg `json:"screensaver"`
Timing TimingConfig `json:"timing,omitempty"`
}
type TimingConfig struct {
@@ -57,15 +57,15 @@ type DynamicKeyGen struct {
}
type KeyConfig struct {
Index int `json:"index"`
Icon string `json:"icon,omitempty"`
Background string `json:"background,omitempty"`
Label string `json:"label,omitempty"`
Font string `json:"font,omitempty"`
FontSize *float64 `json:"font_size,omitempty"`
IconScale *float64 `json:"icon_scale,omitempty"`
Actions []KeyAction `json:"actions,omitempty"`
Display *DisplayCfg `json:"display,omitempty"`
Index int `json:"index"`
Icon string `json:"icon,omitempty"`
Background string `json:"background,omitempty"`
Label string `json:"label,omitempty"`
Font string `json:"font,omitempty"`
FontSize *float64 `json:"font_size,omitempty"`
IconScale *float64 `json:"icon_scale,omitempty"`
Actions []KeyAction `json:"actions,omitempty"`
Display *DisplayCfg `json:"display,omitempty"`
}
type KeyAction struct {
@@ -151,6 +151,21 @@ func ConfigPath(path string) string {
return filepath.Join(home, ".config", "streamdeck-lets-go", "config.json")
}
var cachedConfigDir string
func ConfigDir() string {
if cachedConfigDir != "" {
return cachedConfigDir
}
home, err := os.UserHomeDir()
if err != nil {
cachedConfigDir = "."
return cachedConfigDir
}
cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go")
return cachedConfigDir
}
func LoadConfig(path string) (*Config, error) {
path = ConfigPath(path)
data, err := os.ReadFile(path)
+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
}
+381
View File
@@ -0,0 +1,381 @@
package deck
import (
"fmt"
"image"
"image/color"
"image/draw"
"log/slog"
"sync"
"time"
"unicode/utf8"
"github.com/bearsh/hid"
"github.com/dh1tw/streamdeck"
"github.com/disintegration/gift"
)
type DeviceInfo struct {
Serial string
Model string
PID uint16
}
type DeckConfig struct {
PID uint16
Name string
KeysX int
KeysY int
KeySize int
ImageFmt string
Rotate bool
Convert bool
HasDials bool
HasTouch bool
}
var KnownDecks = []DeckConfig{
{PID: 0x60, Name: "Mini", KeysX: 3, KeysY: 2, KeySize: 80, ImageFmt: "bmp", Convert: true},
{PID: 0x6d, Name: "Original", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true},
{PID: 0x63, Name: "OriginalV2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "bmp"},
{PID: 0x80, Name: "MK2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true},
{PID: 0x6c, Name: "XL", KeysX: 8, KeysY: 4, KeySize: 96, ImageFmt: "jpg"},
}
func findConfig(pid uint16) (DeckConfig, bool) {
for _, d := range KnownDecks {
if d.PID == pid {
return d, true
}
}
return DeckConfig{}, false
}
func EnumerateDevices() ([]DeviceInfo, error) {
devices := hid.Enumerate(streamdeck.VendorID, 0)
if len(devices) == 0 {
return nil, nil
}
var infos []DeviceInfo
for _, d := range devices {
info := DeviceInfo{Serial: d.Serial, PID: d.ProductID}
if cfg, ok := findConfig(d.ProductID); ok {
info.Model = cfg.Name
} else {
info.Model = fmt.Sprintf("Unknown (0x%04x)", d.ProductID)
}
infos = append(infos, info)
}
return infos, nil
}
func toStreamDeckConfig(dc DeckConfig) *streamdeck.Config {
return &streamdeck.Config{
ProductID: dc.PID,
NumButtonColumns: dc.KeysX,
NumButtonRows: dc.KeysY,
Spacer: 19,
ButtonSize: dc.KeySize,
ImageFormat: dc.ImageFmt,
ImageRotate: dc.Rotate,
ConvertKey: dc.Convert,
}
}
type Event struct {
Kind int
Index int
At time.Time
}
const (
EventKeyPressed = 1
EventKeyReleased = 2
)
type Deck struct {
sd *streamdeck.StreamDeck
cfg DeckConfig
serial string
mu sync.Mutex
events chan Event
closed bool
brightness int
}
func OpenDeck(serial string) (*Deck, error) {
desiredSerial := serial
if serial == "first" || serial == "" {
desiredSerial = ""
}
sd, err := streamdeck.NewStreamDeck(desiredSerial)
if err != nil {
return nil, fmt.Errorf("opening streamdeck: %w", err)
}
cfg, ok := findConfig(sd.Config.ProductID)
if !ok {
sd.Close()
return nil, fmt.Errorf("unsupported device PID: 0x%04x", sd.Config.ProductID)
}
d := &Deck{
sd: sd,
cfg: cfg,
serial: sd.Serial(),
events: make(chan Event, 64),
brightness: 75,
}
sd.SetBtnEventCb(func(s streamdeck.State, e streamdeck.Event) {
kind := 0
switch e.Kind {
case streamdeck.EventKeyPressed:
kind = EventKeyPressed
case streamdeck.EventKeyReleased:
kind = EventKeyReleased
default:
return
}
select {
case d.events <- Event{Kind: kind, Index: e.Which, At: time.Now()}:
default:
slog.Warn("event channel full, dropping event", "kind", e.Kind, "index", e.Which)
}
})
slog.Info("deck opened", "serial", d.serial, "model", cfg.Name, "keys", cfg.KeysX*cfg.KeysY)
return d, nil
}
func (d *Deck) Close() {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return
}
d.closed = true
close(d.events)
d.sd.Close()
}
func (d *Deck) Serial() string { return d.serial }
func (d *Deck) Events() <-chan Event { return d.events }
func (d *Deck) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY }
func (d *Deck) KeySize() int { return d.cfg.KeySize }
func (d *Deck) Config() DeckConfig { return d.cfg }
func (d *Deck) Model() string { return d.cfg.Name }
type DeckInfo struct {
Serial string `json:"serial"`
Model string `json:"model"`
KeysX int `json:"keys_x"`
KeysY int `json:"keys_y"`
NumKeys int `json:"num_keys"`
KeySize int `json:"key_size"`
}
func (d *Deck) DeckInfo() DeckInfo {
return DeckInfo{
Serial: d.serial,
Model: d.cfg.Name,
KeysX: d.cfg.KeysX,
KeysY: d.cfg.KeysY,
NumKeys: d.cfg.KeysX * d.cfg.KeysY,
KeySize: d.cfg.KeySize,
}
}
func OpenAllDecks() ([]*Deck, error) {
devices := hid.Enumerate(streamdeck.VendorID, 0)
if len(devices) == 0 {
return nil, fmt.Errorf("no stream deck devices found")
}
var decks []*Deck
for _, d := range devices {
if _, ok := findConfig(d.ProductID); !ok {
slog.Warn("skipping unsupported device", "pid", fmt.Sprintf("0x%04x", d.ProductID))
continue
}
deck, err := OpenDeck(d.Serial)
if err != nil {
slog.Warn("failed to open deck", "serial", d.Serial, "error", err)
continue
}
decks = append(decks, deck)
}
if len(decks) == 0 {
return nil, fmt.Errorf("no supported stream deck devices could be opened")
}
return decks, nil
}
func (d *Deck) SetBrightness(val int) error {
if val < 0 {
val = 0
}
if val > 100 {
val = 100
}
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return fmt.Errorf("deck closed")
}
d.brightness = val
return d.sd.SetBrightness(uint16(val))
}
func (d *Deck) Brightness() int {
d.mu.Lock()
defer d.mu.Unlock()
return d.brightness
}
func (d *Deck) FillColor(keyIndex int, r, g, b uint8) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return fmt.Errorf("deck closed")
}
return d.sd.FillColor(keyIndex, int(r), int(g), int(b))
}
func (d *Deck) FillImage(keyIndex int, img image.Image) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return fmt.Errorf("deck closed")
}
return d.sd.FillImage(keyIndex, img)
}
func (d *Deck) FillPanel(img image.Image) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return fmt.Errorf("deck closed")
}
return d.sd.FillPanel(img)
}
func (d *Deck) ClearAll() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return fmt.Errorf("deck closed")
}
return d.sd.ClearAllBtns()
}
func (d *Deck) ClearKey(idx int) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return fmt.Errorf("deck closed")
}
return d.sd.ClearBtn(idx)
}
func (d *Deck) WriteText(keyIndex int, text string, bg color.Color, fontName string, fontSize float64) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return fmt.Errorf("deck closed")
}
if fontSize <= 0 {
fontSize = 18
}
font := streamdeck.MonoMedium
if fontName == "regular" {
font = streamdeck.MonoRegular
}
ks := d.cfg.KeySize
charW := fontSize * 0.55
textW := int(float64(len(text)) * charW)
posX := (ks - textW) / 2
if posX < 2 {
posX = 2
}
posY := ks/2 - 24 + int(fontSize/3)
if posY < 2 {
posY = 2
}
return d.sd.WriteText(keyIndex, streamdeck.TextButton{
Lines: []streamdeck.TextLine{
{
Text: text,
PosX: posX,
PosY: posY,
Font: font,
FontSize: fontSize,
FontColor: color.White,
},
},
BgColor: bg,
})
}
func (d *Deck) WriteTextOnImage(keyIndex int, img image.Image, text, fontName string, fontSize float64, showLabelBackground bool) error {
ks := d.cfg.KeySize
g := gift.New(
gift.Resize(ks, ks, gift.LanczosResampling),
)
rgba := image.NewRGBA(image.Rect(0, 0, ks, ks))
g.Draw(rgba, img)
lines := []streamdeck.TextLine{}
if text != "" {
if fontSize <= 0 {
fontSize = 16
}
font := streamdeck.MonoRegular
if fontName == "medium" {
font = streamdeck.MonoMedium
}
if showLabelBackground {
barHeight := 20
if ks < 72 {
barHeight = 18
}
barRect := image.Rect(0, ks-barHeight, ks, ks)
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
}
offsetY := 24
baselineY := ks - 6
posY := baselineY - offsetY
if posY < 2 {
posY = 2
}
charW := fontSize * 0.55
textW := int(float64(utf8.RuneCountInString(text)) * charW)
posX := (ks - textW) / 2
if posX < 2 {
posX = 2
}
lines = append(lines, streamdeck.TextLine{
Text: text,
PosX: posX,
PosY: posY,
Font: font,
FontSize: fontSize,
FontColor: color.White,
})
}
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return fmt.Errorf("deck closed")
}
return d.sd.WriteTextOnImage(keyIndex, rgba, lines)
}
Binary file not shown.
Binary file not shown.
+212
View File
@@ -0,0 +1,212 @@
package render
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/draw"
"image/png"
"log/slog"
"sync"
_ "image/jpeg"
"github.com/disintegration/gift"
)
type cbdtIndex struct {
once sync.Once
imgs []image.Image
scale int
}
var emojiCBDT cbdtIndex
func loadCBDT(data []byte) ([]byte, []byte, error) {
if len(data) < 12 {
return nil, nil, fmt.Errorf("font too small")
}
numTables := int(binary.BigEndian.Uint16(data[4:6]))
off := 12
var cmapData, cbdtData []byte
for i := 0; i < numTables; i++ {
if off+16 > len(data) {
break
}
tag := string(data[off : off+4])
tblOff := int(binary.BigEndian.Uint32(data[off+8 : off+12]))
tblLen := int(binary.BigEndian.Uint32(data[off+12 : off+16]))
switch tag {
case "cmap":
if tblOff+tblLen <= len(data) {
cmapData = data[tblOff : tblOff+tblLen]
}
case "CBDT":
if tblOff+tblLen <= len(data) {
cbdtData = data[tblOff : tblOff+tblLen]
}
}
off += 16
}
if cmapData == nil {
return nil, nil, fmt.Errorf("cmap table not found")
}
if cbdtData == nil {
return nil, nil, fmt.Errorf("CBDT table not found")
}
return cmapData, cbdtData, nil
}
func cmapGlyphIndex(cmap []byte, r rune) (int, bool) {
if len(cmap) < 4 {
return 0, false
}
numTables := int(binary.BigEndian.Uint16(cmap[2:4]))
for i := 0; i < numTables; i++ {
boff := 4 + i*8
if boff+8 > len(cmap) {
break
}
platform := binary.BigEndian.Uint16(cmap[boff : boff+2])
encoding := binary.BigEndian.Uint16(cmap[boff+2 : boff+4])
if platform != 3 || encoding != 10 {
continue
}
subOff := int(binary.BigEndian.Uint32(cmap[boff+4 : boff+8]))
if subOff+2 > len(cmap) {
continue
}
fmtRaw := binary.BigEndian.Uint16(cmap[subOff:])
if fmtRaw != 12 {
continue
}
return cmapFormat12(cmap[subOff:], r)
}
return 0, false
}
func cmapFormat12(data []byte, r rune) (int, bool) {
if len(data) < 16 {
return 0, false
}
numGroups := int(binary.BigEndian.Uint32(data[12:16]))
cp := uint32(r)
lo, hi := 0, numGroups-1
for lo <= hi {
mid := (lo + hi) / 2
goff := 16 + mid*12
if goff+12 > len(data) {
break
}
start := binary.BigEndian.Uint32(data[goff:])
end := binary.BigEndian.Uint32(data[goff+4:])
startGlyph := binary.BigEndian.Uint32(data[goff+8:])
if cp < start {
hi = mid - 1
} else if cp > end {
lo = mid + 1
} else {
return int(startGlyph + (cp - start)), true
}
}
return 0, false
}
func scanCBDTImages(cbdt []byte, firstGlyph int) ([]image.Image, error) {
if len(cbdt) < 4 {
return nil, fmt.Errorf("CBDT too small")
}
pos := 4
var imgs []image.Image
for pos < len(cbdt) {
if pos+9 > len(cbdt) {
break
}
dataSize := int(binary.BigEndian.Uint16(cbdt[pos+7 : pos+9]))
pngOff := pos + 9
if pngOff+dataSize > len(cbdt) {
break
}
img, err := png.Decode(bytes.NewReader(cbdt[pngOff : pngOff+dataSize]))
if err != nil {
pos += 9 + dataSize
continue
}
imgs = append(imgs, img)
pos += 9 + dataSize
}
if len(imgs) == 0 {
return nil, fmt.Errorf("no valid PNG images found in CBDT")
}
slog.Debug("CBDT scan", "images", len(imgs), "firstGlyph", firstGlyph)
return imgs, nil
}
func renderCBDTGlyph(r rune, targetSize int, scale float64) (image.Image, bool) {
fontData := loadColorEmojiFont()
if fontData == nil {
return nil, false
}
cmap, cbdt, err := loadCBDT(fontData)
if err != nil {
slog.Debug("CBDT load", "error", err)
return nil, false
}
gid, ok := cmapGlyphIndex(cmap, r)
if !ok {
return nil, false
}
emojiCBDT.once.Do(func() {
imgs, err := scanCBDTImages(cbdt, 4)
if err != nil {
slog.Warn("CBDT scan", "error", err)
return
}
emojiCBDT.imgs = imgs
})
if emojiCBDT.imgs == nil {
return nil, false
}
idx := gid - 5
if idx < 0 || idx >= len(emojiCBDT.imgs) {
return nil, false
}
raw := emojiCBDT.imgs[idx]
displaySize := int(float64(targetSize) * scale)
if displaySize > targetSize {
displaySize = targetSize
}
if displaySize < 1 {
displaySize = 1
}
scaled := raw
if raw.Bounds().Dx() != displaySize || raw.Bounds().Dy() != displaySize {
g := gift.New(gift.Resize(displaySize, displaySize, gift.LanczosResampling))
rgba := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize))
g.Draw(rgba, raw)
scaled = rgba
}
out := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
offX := (targetSize - scaled.Bounds().Dx()) / 2
offY := (targetSize - scaled.Bounds().Dy()) / 2
rect := image.Rect(offX, offY, offX+scaled.Bounds().Dx(), offY+scaled.Bounds().Dy())
draw.Draw(out, rect, scaled, image.Point{}, draw.Over)
return out, true
}
func scaleToTarget(img image.Image, targetSize int) image.Image {
b := img.Bounds()
if b.Dx() == targetSize && b.Dy() == targetSize {
return img
}
g := gift.New(gift.Resize(targetSize, targetSize, gift.LanczosResampling))
rgba := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
g.Draw(rgba, img)
return rgba
}
+209
View File
@@ -0,0 +1,209 @@
package render
import "sync"
var emojiShortcodes = map[string]rune{
// media
"play_pause": 0x25B6,
"stop": 0x23F9,
"record": 0x23FA,
"eject": 0x23CF,
"track_previous": 0x23EE,
"track_next": 0x23ED,
"fast_forward": 0x23E9,
"rewind": 0x23EA,
"shuffle": 0x1F500,
"repeat": 0x1F501,
"repeat_one": 0x1F502,
// volume
"speaker": 0x1F50A,
"mute": 0x1F507,
"sound": 0x1F509,
// navigation
"arrow_up": 0x2B06,
"arrow_down": 0x2B07,
"arrow_left": 0x2B05,
"arrow_right": 0x27A1,
"arrows_clockwise": 0x1F503,
"arrows_counterclockwise": 0x1F504,
// status
"check": 0x2705,
"heavy_check_mark": 0x2714,
"x": 0x274C,
"heavy_multiplication_x": 0x2716,
"warning": 0x26A0,
"information_source": 0x2139,
"question": 0x2753,
"exclamation": 0x2757,
"white_check_mark": 0x2705,
"heavy_plus_sign": 0x2795,
"heavy_minus_sign": 0x2796,
"heavy_division_sign": 0x2797,
// actions
"gear": 0x2699,
"hammer": 0x1F528,
"wrench": 0x1F527,
"key": 0x1F511,
"lock": 0x1F512,
"unlocked": 0x1F513,
"magnifying_glass": 0x1F50D,
"home": 0x1F3E0,
"bookmark": 0x1F516,
"bell": 0x1F514,
"clock": 0x1F550,
"alarm_clock": 0x23F0,
"hourglass": 0x231B,
"calendar": 0x1F4C5,
"envelope": 0x2709,
"camera": 0x1F4F7,
"video_camera": 0x1F4F9,
"microphone": 0x1F3A4,
"telephone": 0x260E,
"phone": 0x1F4DE,
"computer": 0x1F4BB,
"laptop": 0x1F4BB,
"folder": 0x1F4C1,
"open_file_folder": 0x1F4C2,
"clipboard": 0x1F4CB,
"memo": 0x1F4DD,
"pencil": 0x270F,
"scissors": 0x2702,
"link": 0x1F517,
"paperclip": 0x1F4CE,
"pushpin": 0x1F4CC,
"trash": 0x1F5D1,
"star": 0x2B50,
"trophy": 0x1F3C6,
"medal": 0x1F3C5,
"target": 0x1F3AF,
"dart": 0x1F3AF,
// objects
"lightbulb": 0x1F4A1,
"bulb": 0x1F4A1,
"battery": 0x1F50B,
"electric_plug": 0x1F50C,
"rocket": 0x1F680,
"airplane": 0x2708,
"car": 0x1F697,
"bicycle": 0x1F6B2,
"headphones": 0x1F3A7,
"gamepad": 0x1F3AE,
"joystick": 0x1F579,
"musical_note": 0x1F3B5,
"notes": 0x1F3B6,
"printer": 0x1F5A8,
"keyboard": 0x2328,
// weather
"sun": 0x2600,
"sunny": 0x2600,
"moon": 0x1F319,
"cloud": 0x2601,
"rainbow": 0x1F308,
"fire": 0x1F525,
"flame": 0x1F525,
"zap": 0x26A1,
"lightning": 0x26A1,
"snowflake": 0x2744,
"umbrella": 0x2602,
// hearts
"heart": 0x2764,
"yellow_heart": 0x1F49B,
"green_heart": 0x1F49A,
"blue_heart": 0x1F499,
"purple_heart": 0x1F49C,
"black_heart": 0x1F5A4,
"broken_heart": 0x1F494,
"two_hearts": 0x1F495,
"sparkling_heart": 0x1F496,
"heartpulse": 0x1F497,
"heart_beat": 0x1F493,
"revolving_hearts": 0x1F49E,
"cupid": 0x1F498,
"gift_heart": 0x1F49D,
// faces
"smile": 0x1F600,
"smiley": 0x1F603,
"grinning": 0x1F604,
"blush": 0x1F60A,
"wink": 0x1F609,
"heart_eyes": 0x1F60D,
"kissing_heart": 0x1F618,
"kissing": 0x1F617,
"smirk": 0x1F60F,
"stuck_out_tongue": 0x1F61B,
"stuck_out_tongue_winking_eye": 0x1F61C,
"sunglasses": 0x1F60E,
"innocent": 0x1F607,
"neutral_face": 0x1F610,
"expressionless": 0x1F611,
"thinking": 0x1F914,
"confused": 0x1F615,
"worried": 0x1F61F,
"frown": 0x1F641,
"persevere": 0x1F623,
"tired": 0x1F62B,
"weary": 0x1F629,
"cry": 0x1F622,
"sob": 0x1F62D,
"sweat_smile": 0x1F605,
"joy": 0x1F602,
"relaxed": 0x263A,
"angry": 0x1F620,
"rage": 0x1F621,
"skull": 0x1F480,
"ghost": 0x1F47B,
"robot": 0x1F916,
"sleeping": 0x1F634,
"sleep": 0x1F634,
"zzz": 0x1F4A4,
"dizzy": 0x1F4AB,
"boom": 0x1F4A5,
"collision": 0x1F4A5,
"sweat_drops": 0x1F4A6,
"dash": 0x1F4A8,
"alien": 0x1F47D,
"poop": 0x1F4A9,
// hands & gestures
"thumbsup": 0x1F44D,
"thumbsdown": 0x1F44E,
"ok_hand": 0x1F44C,
"wave": 0x1F44B,
"clap": 0x1F44F,
"open_hands": 0x1F450,
"raised_hands": 0x1F64C,
"pray": 0x1F64F,
"muscle": 0x1F4AA,
"point_up": 0x261D,
"point_down": 0x1F447,
"point_left": 0x1F448,
"point_right": 0x1F449,
"fist": 0x270A,
"raised_hand": 0x270B,
"v": 0x270C,
"victory": 0x270C,
"crossed_fingers": 0x1F91E,
"writing_hand": 0x270D,
"call_me": 0x1F919,
"hand": 0x270B,
}
var (
emojiColorFontOnce sync.Once
emojiColorFontData []byte
)
func loadColorEmojiFont() []byte {
emojiColorFontOnce.Do(func() {
emojiColorFontData = fcRead("emoji")
})
return emojiColorFontData
}
+140
View File
@@ -0,0 +1,140 @@
package render
import (
"fmt"
"image"
"image/color"
"strings"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
func parseFAIcon(ref string) (faStyle, string, error) {
if !strings.HasPrefix(ref, "fa") {
return 0, "", fmt.Errorf("not a font awesome ref")
}
var style faStyle
var name string
switch {
case strings.HasPrefix(ref, "fab:"):
style = faBrands
name = strings.TrimPrefix(ref, "fab:")
case strings.HasPrefix(ref, "far:"):
style = faRegular
name = strings.TrimPrefix(ref, "far:")
case strings.HasPrefix(ref, "fa:"):
style = faSolid
name = strings.TrimPrefix(ref, "fa:")
default:
return 0, "", fmt.Errorf("invalid font awesome ref: %s", ref)
}
if name == "" {
return 0, "", fmt.Errorf("empty icon name")
}
return style, name, nil
}
func faCodepoint(style faStyle, name string) (rune, error) {
m, ok := faCodepoints[style]
if !ok {
return 0, fmt.Errorf("unknown style")
}
if cp, ok := m[name]; ok {
return cp, nil
}
if cp, ok := m[normalizeFAName(name)]; ok {
return cp, nil
}
return 0, fmt.Errorf("icon %q not found", name)
}
func normalizeFAName(name string) string {
if len(name) == 0 {
return name
}
parts := strings.Split(name, "-")
for i, p := range parts {
if len(p) > 0 {
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
}
camel := strings.Join(parts, "")
return strings.ToLower(camel[:1]) + camel[1:]
}
func faFontBytes(style faStyle) ([]byte, error) {
return faFonts.ReadFile(style.otfPath())
}
func loadFAFace(style faStyle, pointSize float64) (font.Face, error) {
data, err := faFontBytes(style)
if err != nil {
return nil, fmt.Errorf("read font: %w", err)
}
fnt, err := opentype.Parse(data)
if err != nil {
return nil, fmt.Errorf("parse font: %w", err)
}
face, err := opentype.NewFace(fnt, &opentype.FaceOptions{
Size: pointSize,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
return nil, fmt.Errorf("new face: %w", err)
}
return face, nil
}
func renderFAGlyph(style faStyle, name string, size int, scale float64) (image.Image, error) {
cp, err := faCodepoint(style, name)
if err != nil {
return nil, err
}
if scale <= 0 {
scale = 0.55
}
fontSize := float64(size) * scale
face, err := loadFAFace(style, fontSize)
if err != nil {
return nil, err
}
defer face.Close()
rgba := image.NewRGBA(image.Rect(0, 0, size, size))
adv := font.MeasureString(face, string(cp)).Ceil()
offX := (size - adv) / 2
if offX < 0 {
offX = 0
}
metrics := face.Metrics()
baselineY := (size + metrics.Ascent.Ceil() - metrics.Descent.Ceil()) / 2
d := font.Drawer{
Dst: rgba,
Src: image.NewUniform(color.White),
Face: face,
Dot: fixed.P(offX, baselineY),
}
d.DrawString(string(cp))
return rgba, nil
}
func isFAIconRef(path string) bool {
return strings.HasPrefix(path, "fa:") || strings.HasPrefix(path, "far:") || strings.HasPrefix(path, "fab:")
}
+71
View File
@@ -0,0 +1,71 @@
package render
import (
"embed"
"unicode"
"unicode/utf8"
)
//go:embed assets/Font_Awesome_7_BrandsRegular400.otf
//go:embed assets/Font_Awesome_7_FreeRegular400.otf
//go:embed assets/Font_Awesome_7_FreeSolid900.otf
var faFonts embed.FS
type faStyle int
const (
faSolid faStyle = iota
faRegular
faBrands
)
func (s faStyle) otfPath() string {
switch s {
case faSolid:
return "assets/Font_Awesome_7_FreeSolid900.otf"
case faRegular:
return "assets/Font_Awesome_7_FreeRegular400.otf"
case faBrands:
return "assets/Font_Awesome_7_BrandsRegular400.otf"
default:
return "assets/Font_Awesome_7_FreeSolid900.otf"
}
}
var faCodepoints map[faStyle]map[string]rune
func init() {
faCodepoints = make(map[faStyle]map[string]rune)
faCodepoints[faSolid] = buildFAMap(fa7Icons)
faCodepoints[faRegular] = buildFAMap(fa7Icons)
faCodepoints[faBrands] = buildFAMap(fa7BrandsIcons)
}
func buildFAMap(src map[string]string) map[string]rune {
m := make(map[string]rune, len(src))
for name, cp := range src {
r, _ := utf8.DecodeRuneInString(cp)
camel := camelToKebab(name)
m[name] = r
m[camel] = r
if lower := toLower(name); lower != name {
m[lower] = r
}
}
return m
}
func camelToKebab(s string) string {
var out []byte
for i, r := range s {
if unicode.IsUpper(r) && i > 0 {
out = append(out, '-')
}
out = append(out, byte(unicode.ToLower(r)))
}
return string(out)
}
func toLower(s string) string {
return string(unicode.ToLower(rune(s[0]))) + s[1:]
}
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
package render
import (
"bytes"
"fmt"
"image"
"image/draw"
_ "image/png"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
)
func iconThemeDirs() []string {
dirs := []string{}
if home := os.Getenv("HOME"); home != "" {
dirs = append(dirs, filepath.Join(home, ".local/share/icons"))
}
xdgDirs := os.Getenv("XDG_DATA_DIRS")
if xdgDirs == "" {
xdgDirs = "/usr/local/share:/usr/share"
}
for _, d := range filepath.SplitList(xdgDirs) {
dirs = append(dirs, filepath.Join(d, "icons"))
}
return uniquePaths(dirs)
}
func uniquePaths(paths []string) []string {
seen := make(map[string]bool)
res := make([]string, 0, len(paths))
for _, p := range paths {
if !seen[p] {
seen[p] = true
res = append(res, p)
}
}
return res
}
func preferredThemes() []string {
theme := detectGtkTheme()
if theme != "" {
return []string{theme, "hicolor", "Adwaita", "Papirus", "Humanity", "breeze", "gnome"}
}
return []string{"hicolor", "Adwaita", "Papirus", "Humanity", "breeze", "gnome"}
}
func detectGtkTheme() string {
data, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".config/gtk-4.0/settings.ini"))
if err != nil {
data, err = os.ReadFile(filepath.Join(os.Getenv("HOME"), ".config/gtk-3.0/settings.ini"))
if err != nil {
return ""
}
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "gtk-icon-theme-name=") {
return strings.TrimSpace(line[len("gtk-icon-theme-name="):])
}
}
return ""
}
func sizeDirs(target int) []string {
sizes := []int{}
for _, base := range []int{16, 22, 24, 32, 48, 64, 72, 96, 128, 192, 256} {
sizes = append(sizes, base)
}
sort.Slice(sizes, func(i, j int) bool {
di := abs(sizes[i] - target)
dj := abs(sizes[j] - target)
if di != dj {
return di < dj
}
return sizes[i] > sizes[j]
})
seen := make(map[int]bool)
res := make([]string, 0, len(sizes))
for _, s := range sizes {
if seen[s] {
continue
}
seen[s] = true
res = append(res, fmt.Sprintf("%dx%d", s, s))
}
res = append(res, "scalable")
return res
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func iconCategories() []string {
return []string{"actions", "apps", "categories", "devices", "emblems", "mimetypes", "places", "status"}
}
func findSystemIcon(name string, targetSize int) (string, error) {
themes := preferredThemes()
dirs := iconThemeDirs()
sDirs := sizeDirs(targetSize)
cats := iconCategories()
for _, base := range dirs {
for _, theme := range themes {
themeDir := filepath.Join(base, theme)
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
continue
}
for _, sd := range sDirs {
for _, cat := range cats {
for _, ext := range []string{"png", "xpm"} {
p := filepath.Join(themeDir, sd, cat, name+"."+ext)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return p, nil
}
}
}
}
for _, sd := range sDirs {
for _, cat := range cats {
p := filepath.Join(themeDir, sd, cat, name+".svg")
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return p, nil
}
}
}
}
}
return "", fmt.Errorf("system icon %q not found", name)
}
func svgToPNG(svgPath string, targetSize int, scale float64) (image.Image, error) {
if scale <= 0 {
scale = 0.55
}
renderSize := int(float64(targetSize) * scale)
if renderSize > targetSize {
renderSize = targetSize
}
if renderSize < 1 {
renderSize = 1
}
cmd := exec.Command("rsvg-convert",
"-w", strconv.Itoa(renderSize),
"-h", strconv.Itoa(renderSize),
"-f", "png",
svgPath,
)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("rsvg-convert: %w", err)
}
img, _, err := image.Decode(bytes.NewReader(out))
if err != nil {
return nil, fmt.Errorf("decode converted svg: %w", err)
}
if renderSize < targetSize {
canvas := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
offX := (targetSize - renderSize) / 2
offY := (targetSize - renderSize) / 2
draw.Draw(canvas, image.Rect(offX, offY, offX+renderSize, offY+renderSize), img, image.Point{}, draw.Over)
img = canvas
}
return img, nil
}
var iconSizeCache sync.Map
func loadSystemIcon(name string, targetSize int) (string, error) {
type cacheKey struct {
name string
size int
}
key := cacheKey{name, targetSize}
if cached, ok := iconSizeCache.Load(key); ok {
return cached.(string), nil
}
path, err := findSystemIcon(name, targetSize)
if err != nil {
return "", err
}
iconSizeCache.Store(key, path)
return path, nil
}
func isSystemIconRef(path string) bool {
return strings.HasPrefix(path, "@")
}
func systemIconName(path string) string {
return strings.TrimPrefix(path, "@")
}
func parseSizeDir(dirName string) (int, bool) {
parts := strings.SplitN(dirName, "x", 2)
if len(parts) != 2 {
return 0, false
}
s, err := strconv.Atoi(parts[0])
if err != nil {
return 0, false
}
return s, true
}
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
package render
import (
"image"
"image/color"
"image/draw"
"github.com/disintegration/gift"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
"streamdeck-lets-go/internal/config"
)
func RenderKeyToImage(k *config.KeyConfig, keySize int, showLabelBackground bool) image.Image {
if k == nil {
return blankImage(keySize, color.RGBA{0, 0, 0, 255})
}
if k.Icon == "" && k.Label == "" {
if k.Background != "" {
if bg, err := parseHexColor(k.Background); err == nil {
return blankImage(keySize, bg)
}
}
return blankImage(keySize, color.RGBA{64, 64, 64, 255})
}
faScale := 0.55
if k.IconScale != nil {
faScale = *k.IconScale
}
if k.Icon != "" {
img, err := LoadImage(k.Icon, keySize, faScale)
if err != nil {
img = blankImage(keySize, color.RGBA{64, 64, 64, 255})
}
if k.Background != "" {
if bg, err := parseHexColor(k.Background); err == nil {
img = applyBackground(img, bg)
}
}
if k.Label != "" {
fontSize := 10.0
if k.FontSize != nil {
fontSize = *k.FontSize
}
return composeImageWithLabel(img, k.Label, keySize, fontSize, showLabelBackground)
}
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
g.Draw(rgba, img)
return rgba
}
if k.Background != "" {
if bg, err := parseHexColor(k.Background); err == nil {
if k.Label != "" {
fontSize := 12.0
if k.FontSize != nil {
fontSize = *k.FontSize
}
return renderUnicodeText(k.Label, fontSize, keySize, bg, color.White)
}
return blankImage(keySize, bg)
}
}
fontSize := 12.0
if k.FontSize != nil {
fontSize = *k.FontSize
}
return renderTextImage(k.Label, keySize, fontSize)
}
func blankImage(size int, c color.Color) image.Image {
img := image.NewRGBA(image.Rect(0, 0, size, size))
draw.Draw(img, img.Bounds(), &image.Uniform{c}, image.Point{}, draw.Src)
return img
}
func composeImageWithLabel(src image.Image, text string, keySize int, fontSize float64, showLabelBackground bool) image.Image {
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
g.Draw(rgba, src)
barHeight := 20
if keySize < 72 {
barHeight = 18
}
barRect := image.Rect(0, keySize-barHeight, keySize, keySize)
if showLabelBackground {
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
}
if text != "" {
face, err := parseDisplayFace(fontSize)
if err != nil {
face = basicfont.Face7x13
} else {
defer face.Close()
}
textW := font.MeasureString(face, text).Ceil()
posX := (keySize - textW) / 2
if posX < 2 {
posX = 2
}
posY := keySize - barHeight/2 + face.Metrics().Height.Ceil()/2
if posY >= keySize {
posY = keySize - 2
}
d := &font.Drawer{
Dst: rgba,
Src: image.NewUniform(color.White),
Face: face,
Dot: fixed.P(posX, posY),
}
d.DrawString(text)
}
return rgba
}
func renderTextImage(text string, keySize int, fontSize float64) image.Image {
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 0}).(*image.RGBA)
if text != "" {
face, err := parseDisplayFace(fontSize)
if err != nil {
face = basicfont.Face7x13
} else {
defer face.Close()
}
textW := font.MeasureString(face, text).Ceil()
posX := (keySize - textW) / 2
if posX < 2 {
posX = 2
}
posY := keySize/2 + face.Metrics().Height.Ceil()/2
d := &font.Drawer{
Dst: rgba,
Src: image.NewUniform(color.White),
Face: face,
Dot: fixed.P(posX, posY),
}
d.DrawString(text)
}
return rgba
}
File diff suppressed because one or more lines are too long
+745
View File
@@ -0,0 +1,745 @@
document.addEventListener('alpine:init', () => {
Alpine.data('deckApp', () => ({
// ── State ──
config: null,
pages: [],
activePage: '',
view: 'grid',
showAdvanced: false,
toast: null,
displayOutputs: {},
isCapturingKey: false,
decks: [],
activeDeckSerial: '',
// ── Edit state ──
editing: null,
editForm: {
bg_color: '',
actions: {
tap: {},
long_press: {},
double_tap: {},
hold: { start: {}, end: {} },
},
},
// ── Sub-views ──
subView: null,
// ── Settings ──
settingsForm: {},
autoSwitchRules: [],
// ── Constants ──
actionIconMap: {
command: 'terminal',
builtin: 'cog',
script: 'file-code',
page: 'layer-group',
keyboard: 'keyboard',
},
actionTitleMap: {
command: 'Command',
builtin: 'Built-in',
script: 'Script',
page: 'Switch page',
keyboard: 'Keyboard shortcut',
},
// ── Init ──
async init() {
await this.loadConfig()
await this.loadDecks()
setInterval(() => this.pollDisplayOutputs(), 3000)
setInterval(() => this.loadEffectiveKeys(), 10000)
this.connectSSE()
},
connectSSE() {
if (this._sse) this._sse.close()
const es = new EventSource('/api/events')
this._sse = es
es.addEventListener('page_changed', (e) => {
try {
const data = JSON.parse(e.data)
if (data.page && this.pages.find(p => p.name === data.page)) {
this.activePage = data.page
localStorage.setItem('sd_active_page', data.page)
this.displayOutputs = {}
}
} catch (_) {}
})
es.onerror = () => {
es.close()
this._sse = null
setTimeout(() => this.connectSSE(), 3000)
}
},
async loadDecks() {
try {
const res = await fetch('/api/decks')
if (res.ok) {
this.decks = await res.json()
if (this.decks.length > 0 && !this.activeDeckSerial) {
this.activeDeckSerial = this.decks[0].serial
}
}
} catch (_) {}
},
async pollDisplayOutputs() {
if (this.view !== 'grid') return
try {
const res = await fetch(`/api/display-outputs`)
this.displayOutputs = await res.json()
} catch (_) {}
},
async loadConfig() {
try {
const res = await fetch('/api/config')
this.config = await res.json()
this.pages = this.config.pages
await this.loadEffectiveKeys()
if (this.pages.length > 0) {
const saved = localStorage.getItem('sd_active_page')
if (saved && this.pages.find(p => p.name === saved)) {
this.activePage = saved
} else {
this.activePage = this.config.default_page || this.pages[0].name
}
}
this.syncSettings()
} catch (e) {
this.showToast('Failed to load config', 'error')
}
},
async loadEffectiveKeys() {
try {
const res = await fetch('/api/pages')
const pagesData = await res.json()
for (const pd of pagesData) {
const page = this.pages.find(p => p.name === pd.name)
if (page && pd.effective_keys && pd.effective_keys.length > 0) {
page.effective_keys = pd.effective_keys
}
}
} catch (_) {}
},
syncSettings() {
this.settingsForm = {
brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75,
font: this.config.font || 'medium',
show_label_background: this.config.show_label_background ?? true,
screensaver_enabled: this.config.screensaver?.enabled || false,
screensaver_idle: this.config.screensaver?.idle_seconds || 30,
screensaver_brightness: this.config.screensaver?.brightness || 10,
long_press_ms: this.config.timing?.long_press_ms || 500,
double_tap_ms: this.config.timing?.double_tap_ms || 300,
}
this.autoSwitchRules = [...(this.config.auto_switch || [])]
},
// ── Page helpers ──
get currentPage() {
return this.pages.find(p => p.name === this.activePage)
},
get pageNames() {
return this.pages.map(p => p.name)
},
get activeDeck() {
return this.decks.find(d => d.serial === this.activeDeckSerial)
|| { keys_x: 5, keys_y: 3, num_keys: 15 }
},
getDeviceConfig(serial) {
return this.config.devices?.find(d => d.serial === serial)
},
// ── Grid ──
get gridKeys() {
const page = this.currentPage
if (!page) return []
const sourceKeys = (page.effective_keys && page.effective_keys.length > 0)
? page.effective_keys
: (page.keys || [])
const keys = []
const n = this.activeDeck.num_keys
for (let i = 0; i < n; i++) {
const kc = sourceKeys.find(k => k.index === i)
const dout = this.displayOutputs[i]
keys.push({
index: i,
...kc,
configured: !!kc,
hasDisplay: !!(kc?.display),
hasAction: !!(kc?.actions?.length),
actionTypes: this.getActionTypes(kc?.actions || []),
displayBg: (kc?.display && dout?.background) || '',
displayText: (kc?.display && dout?.text) || '',
previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout),
isDynamic: !!(page.dynamic_keys && page.effective_keys?.find(k => k.index === i)),
})
}
return keys
},
getActionTypes(actions) {
const seen = {}
for (const a of actions) {
if (a.type && a.type !== 'none') {
seen[a.type] = true
}
}
return Object.keys(seen)
},
keyPreviewUrl(kc, dout) {
const k = kc || {}
let url = '/api/render?key_size=72'
if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}`
const label = (k.display && (dout?.text || this.displayOutputs[k.index]?.text)) || k.label
if (label) url += `&label=${encodeURIComponent(label)}`
if (k.icon_scale != null) url += `&icon_scale=${k.icon_scale}`
if (k.font_size != null) url += `&font_size=${k.font_size}`
const bg = dout?.background || k.background
if (bg) url += `&background=${encodeURIComponent(bg)}`
return url
},
isFAIcon(icon) {
if (!icon) return false
return icon.startsWith('fa:') || icon.startsWith('far:') || icon.startsWith('fab:')
},
faClass(icon) {
if (!icon) return ''
if (icon.startsWith('fa:')) return 'fa-solid fa-' + icon.slice(3)
if (icon.startsWith('far:')) return 'fa-regular fa-' + icon.slice(4)
if (icon.startsWith('fab:')) return 'fa-brands fa-' + icon.slice(4)
return ''
},
// ── Multi-action helpers ──
getCurrentAction() {
const t = this.editForm.activeTrigger || 'tap'
if (t === 'hold') {
return this.editForm.actions?.hold?.[this.editForm.holdPhase || 'start']
}
return this.editForm.actions?.[t]
},
hasCurrentAction(trigger) {
const a = this.editForm.actions?.[trigger]
return a && a.type && a.type !== 'none'
},
hasHoldAction(phase) {
const a = this.editForm.actions?.hold?.[phase]
return a && a.type && a.type !== 'none'
},
triggerLabel(trigger) {
return { tap: 'Tap', long_press: 'Long Press', double_tap: 'Double Tap', hold: 'Hold' }[trigger] || trigger
},
// ── Keyboard helpers (for current action) ──
get keyChips() {
const a = this.getCurrentAction()
if (!a || !a.keys) return []
const map = { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', super: 'Super', meta: 'Super' }
return a.keys.split('+').map(k => map[k] || k.charAt(0).toUpperCase() + k.slice(1))
},
toggleMod(mod) {
const a = this.getCurrentAction()
if (!a) return
if (mod === 'ctrl') a.modCtrl = !a.modCtrl
else if (mod === 'alt') a.modAlt = !a.modAlt
else if (mod === 'shift') a.modShift = !a.modShift
else if (mod === 'super') a.modSuper = !a.modSuper
this.rebuildKeys()
},
rebuildKeys() {
const a = this.getCurrentAction()
if (!a) return
const mods = []
if (a.modCtrl) mods.push('ctrl')
if (a.modAlt) mods.push('alt')
if (a.modShift) mods.push('shift')
if (a.modSuper) mods.push('super')
if (a.mainKey) mods.push(a.mainKey)
a.keys = mods.join('+')
},
startKeyCapture() {
this.isCapturingKey = true
this.$nextTick(() => {
this.$el.querySelectorAll('.key-capture').forEach(el => {
if (el.offsetParent !== null) el.focus()
})
})
},
onCaptureKey(e) {
if (!this.isCapturingKey) return
e.preventDefault()
e.stopPropagation()
const map = {
'Control': '', 'Alt': '', 'Shift': '', 'Meta': '',
' ': 'space', 'Enter': 'return', 'Tab': 'tab',
'Escape': 'escape', 'Delete': 'delete', 'Backspace': 'backspace',
'ArrowUp': 'up', 'ArrowDown': 'down', 'ArrowLeft': 'left', 'ArrowRight': 'right',
}
let key = map[e.key]
if (key === undefined) {
key = e.key.length === 1 ? e.key.toLowerCase() : e.key
}
if (!key) return
const a = this.getCurrentAction()
if (a) a.mainKey = key.toLowerCase()
this.isCapturingKey = false
this.rebuildKeys()
},
displayKey(key) {
const map = { return: '\u21B5', escape: 'Esc', tab: 'Tab', space: '\u2423',
delete: 'Del', backspace: '\u232B', up: '\u2191', down: '\u2193', left: '\u2190', right: '\u2192' }
return map[key] || key.toUpperCase()
},
// ── Edit Key ──
defaultAction(trigger) {
return { trigger, type: 'none', command: '', builtin: '', script: '', page: '', keys: '', background: true, modCtrl: false, modAlt: false, modShift: false, modSuper: false, mainKey: '' }
},
editKey(idx) {
this.isCapturingKey = false
const page = this.currentPage
if (!page) return
const kc = page.keys.find(k => k.index === idx)
if (!kc && page.dynamic_keys && page.effective_keys?.find(k => k.index === idx)) {
return
}
this.editing = idx
this.showAdvanced = false
const existing = kc?.actions || []
const tap = existing.find(a => a.trigger === 'tap') || null
const longPress = existing.find(a => a.trigger === 'long_press') || null
const doubleTap = existing.find(a => a.trigger === 'double_tap') || null
const holdStart = existing.find(a => a.trigger === 'hold_start') || null
const holdEnd = existing.find(a => a.trigger === 'hold_end') || null
const build = (src, trigger) => {
const def = this.defaultAction(trigger)
if (!src) return def
const parts = (src.keys || '').toLowerCase().split('+')
const main = parts.length > 0 ? parts.pop() : ''
return {
...def,
type: src.type || 'none',
command: src.command || '',
builtin: src.builtin || '',
script: src.script || '',
page: src.page || '',
keys: src.keys || '',
background: src.background ?? true,
modCtrl: parts.includes('ctrl'),
modAlt: parts.includes('alt'),
modShift: parts.includes('shift'),
modSuper: parts.includes('super'),
mainKey: main,
}
}
this.editForm = {
icon: kc?.icon || '',
label: kc?.label || '',
icon_scale: kc?.icon_scale ?? 0.55,
font_size: kc?.font_size ?? 16,
bg_color: kc?.background || '',
activeTrigger: 'tap',
holdPhase: 'start',
actions: {
tap: build(tap, 'tap'),
long_press: build(longPress, 'long_press'),
double_tap: build(doubleTap, 'double_tap'),
hold: {
start: build(holdStart, 'hold_start'),
end: build(holdEnd, 'hold_end'),
},
},
display_mode: kc?.display ? (kc.display.command ? 'command' : 'script') : 'none',
display_command: kc?.display?.command || '',
display_script: kc?.display?.script || '',
display_interval: kc?.display?.interval || '30s',
display_max_len: kc?.display?.max_len || 128,
display_timeout: kc?.display?.timeout || '',
}
},
get editPreviewUrl() {
const f = this.editForm
let url = '/api/render?key_size=96'
if (f.icon) url += `&icon=${encodeURIComponent(f.icon)}`
if (f.label) url += `&label=${encodeURIComponent(f.label)}`
if (f.icon_scale != null) url += `&icon_scale=${f.icon_scale}`
if (f.font_size != null) url += `&font_size=${f.font_size}`
if (f.bg_color) url += `&background=${encodeURIComponent(f.bg_color)}`
return url
},
get editPreviewFaClass() {
return this.faClass(this.editForm.icon)
},
async saveKey() {
const page = this.currentPage
if (!page) return
const dm = this.editForm.display_mode
const hasDisplay = dm !== 'none'
if (dm === 'command' && !this.editForm.display_command) {
this.showToast('Display command is required', 'error')
return
}
if (dm === 'script' && !this.editForm.display_script) {
this.showToast('Display script path is required', 'error')
return
}
// Validate and collect actions
const actions = []
const checkAction = (a) => {
if (!a || !a.type || a.type === 'none') return
if (a.type === 'command' && !a.command) return
if (a.type === 'script' && !a.script) return
if (a.type === 'page' && !a.page) return
if (a.type === 'keyboard' && !a.keys) return
const entry = {
trigger: a.trigger,
type: a.type,
command: a.type === 'command' ? a.command : undefined,
builtin: a.type === 'builtin' ? a.builtin : undefined,
script: a.type === 'script' ? a.script : undefined,
page: a.type === 'page' ? a.page : undefined,
keys: a.type === 'keyboard' ? a.keys : undefined,
background: a.background || undefined,
}
// Clean undefined
for (const k of Object.keys(entry)) {
if (entry[k] === undefined) delete entry[k]
}
actions.push(entry)
}
checkAction(this.editForm.actions.tap)
checkAction(this.editForm.actions.long_press)
checkAction(this.editForm.actions.double_tap)
checkAction(this.editForm.actions.hold.start)
checkAction(this.editForm.actions.hold.end)
const kc = {
index: this.editing,
icon: this.editForm.icon || undefined,
label: this.editForm.label || undefined,
icon_scale: this.editForm.icon_scale !== 0.55 ? this.editForm.icon_scale : undefined,
font_size: this.editForm.font_size !== 16 ? this.editForm.font_size : undefined,
background: this.editForm.bg_color || undefined,
}
if (actions.length > 0) {
kc.actions = actions
}
if (hasDisplay) {
kc.display = {
command: dm === 'command' ? this.editForm.display_command : '',
script: dm === 'script' ? this.editForm.display_script : '',
interval: this.editForm.display_interval || '30s',
max_len: this.editForm.display_max_len || 0,
}
if (this.editForm.display_timeout) {
kc.display.timeout = this.editForm.display_timeout
}
}
const existingIdx = page.keys.findIndex(k => k.index === this.editing)
if (existingIdx >= 0) {
page.keys[existingIdx] = kc
} else {
page.keys.push(kc)
}
await this.saveConfig()
this.editing = null
},
async deleteKey() {
const page = this.currentPage
if (!page) return
page.keys = page.keys.filter(k => k.index !== this.editing)
await this.saveConfig()
this.editing = null
},
// ── Image picker ──
pickImage() {
this.$refs.fileInput.click()
},
async onImagePicked(e) {
const file = e.target.files[0]
if (!file) return
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData })
if (!res.ok) { this.showToast('Upload failed', 'error'); return }
const data = await res.json()
this.editForm.icon = data.path
} catch (err) {
this.showToast('Upload failed', 'error')
}
e.target.value = ''
},
// ── Page management ──
switchPage(name) {
this.activePage = name
localStorage.setItem('sd_active_page', name)
this.displayOutputs = {}
fetch('/api/activate-page', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page: name })
}).catch(() => {})
},
async addPage() {
const name = prompt('New page name:')
if (!name || name.trim() === '') return
if (this.pages.find(p => p.name === name)) {
this.showToast('Page already exists', 'error')
return
}
this.pages.push({ name: name.trim(), keys: [] })
this.activePage = name.trim()
this.config.pages = this.pages
if (!this.config.default_page) {
this.config.default_page = name.trim()
}
await this.saveConfig()
},
async deletePage() {
if (this.pages.length <= 1) {
this.showToast('Cannot delete the last page', 'error')
return
}
if (!confirm(`Delete page "${this.activePage}"?`)) return
const oldName = this.activePage
this.pages = this.pages.filter(p => p.name !== oldName)
this.activePage = this.pages[0].name
if (this.config.default_page === oldName) {
this.config.default_page = this.activePage
}
this.config.pages = this.pages
await this.saveConfig()
},
async renamePage() {
const oldName = this.activePage
const newName = prompt('Rename page:', oldName)
if (!newName || newName.trim() === '' || newName === oldName) return
if (this.pages.find(p => p.name === newName.trim())) {
this.showToast('Page already exists', 'error')
return
}
const page = this.pages.find(p => p.name === oldName)
if (page) page.name = newName.trim()
this.activePage = newName.trim()
if (this.config.default_page === oldName) {
this.config.default_page = newName.trim()
}
for (const p of this.pages) {
for (const k of p.keys) {
for (const a of (k.actions || [])) {
if (a.page === oldName) a.page = newName.trim()
}
}
}
for (const r of (this.config.auto_switch || [])) {
if (r.page === oldName) r.page = newName.trim()
}
await this.saveConfig()
},
// ── Save / Reload ──
async saveConfig() {
this.config.pages = this.pages
try {
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.config),
})
if (res.ok) {
this.showToast('Saved', 'success')
} else {
const text = await res.text()
this.showToast(`Save failed: ${text}`, 'error')
}
} catch (e) {
this.showToast('Save failed', 'error')
}
},
async reloadConfig() {
await this.loadConfig()
this.showToast('Reloaded', 'success')
},
// ── Settings ──
async saveSettings() {
if (!this.config.devices) this.config.devices = []
let dev = this.config.devices.find(d => d.serial === this.activeDeckSerial)
if (!dev) {
dev = { serial: this.activeDeckSerial, brightness: 75 }
this.config.devices.push(dev)
}
dev.brightness = parseInt(this.settingsForm.brightness)
this.config.font = this.settingsForm.font || 'medium'
this.config.show_label_background = this.settingsForm.show_label_background
this.config.screensaver = {
enabled: this.settingsForm.screensaver_enabled,
idle_seconds: parseInt(this.settingsForm.screensaver_idle) || 30,
brightness: parseInt(this.settingsForm.screensaver_brightness) || 10,
}
this.config.timing = {
long_press_ms: parseInt(this.settingsForm.long_press_ms) || 500,
double_tap_ms: parseInt(this.settingsForm.double_tap_ms) || 300,
}
this.config.auto_switch = this.autoSwitchRules
await this.saveConfig()
},
addAutoSwitchRule() {
this.autoSwitchRules.push({ wm_class: '', title: '', page: '', stay: false, devices: [] })
},
removeAutoSwitchRule(idx) {
this.autoSwitchRules.splice(idx, 1)
},
// ── Backup ──
backups: [],
async loadBackups() {
try {
const res = await fetch('/api/backups')
this.backups = await res.json()
} catch (e) {
this.backups = []
}
},
downloadConfig() {
window.open('/api/config/download', '_blank')
},
async downloadBackup(name) {
window.open(`/api/backups/${encodeURIComponent(name)}`, '_blank')
},
async restoreConfig() {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = async () => {
const file = input.files[0]
if (!file) return
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/config/restore', {
method: 'POST',
body: formData,
})
if (res.ok) {
await this.loadConfig()
this.showToast('Config restored', 'success')
} else {
const text = await res.text()
this.showToast(`Restore failed: ${text}`, 'error')
}
} catch (e) {
this.showToast('Restore failed', 'error')
}
}
input.click()
},
async restoreBackup(filename) {
if (!confirm(`Restore "${filename}"?`)) return
try {
const res = await fetch(`/api/backups/${encodeURIComponent(filename)}`)
if (!res.ok) {
this.showToast('Failed to download backup', 'error')
return
}
const blob = await res.blob()
const formData = new FormData()
formData.append('file', blob, filename)
const putRes = await fetch('/api/config/restore', {
method: 'POST',
body: formData,
})
if (putRes.ok) {
await this.loadConfig()
this.showToast('Backup restored', 'success')
} else {
const text = await putRes.text()
this.showToast(`Restore failed: ${text}`, 'error')
}
} catch (e) {
this.showToast('Restore failed', 'error')
}
},
// ── Toast ──
showToast(msg, type = 'success') {
this.toast = { msg, type }
setTimeout(() => { this.toast = null }, 2500)
},
// ── Formatting ──
formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
return (bytes / 1024).toFixed(1) + ' KB'
},
formatTime(iso) {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleString()
},
backupNameTime(name) {
const m = name.match(/config\.(.+)\.json/)
if (!m) return name
return m[1].replace('T', ' ')
},
}))
})
File diff suppressed because one or more lines are too long
+589
View File
@@ -0,0 +1,589 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StreamDeck</title>
<script src="/static/app.js"></script>
<script defer src="/static/alpine.min.js"></script>
<link rel="stylesheet" href="/static/fontawesome/css/all.min.css">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div x-data="deckApp" x-init="init()" class="app" @keydown.escape="editing = null; subView = null">
<!-- ═══ Header ═══ -->
<header>
<button class="icon-btn" @click="view = 'grid'; subView = 'switcher'" title="Switch page">
<i class="fas fa-layer-group"></i>
</button>
<div class="page-nav" x-show="view === 'grid'">
<template x-for="(p, i) in pages" :key="p.name">
<span class="dot"
:class="{ active: p.name === activePage }"
@click="switchPage(p.name)"
:title="p.name"></span>
</template>
</div>
<span x-show="view !== 'grid'" x-text="view === 'settings' ? 'Settings' : 'Backups'"
style="font-size:14px;font-weight:600;"></span>
<div class="header-actions">
<button class="icon-btn" @click="view = 'grid'; subView = null" title="Grid">
<i class="fas fa-table-cells"></i>
</button>
<button class="icon-btn" @click="view = 'settings'; subView = null" title="Settings">
<i class="fas fa-gear"></i>
</button>
<button class="icon-btn" @click="view = 'backups'; loadBackups(); subView = null" title="Backups">
<i class="fas fa-floppy-disk"></i>
</button>
</div>
</header>
<!-- ═══ Grid View ═══ -->
<main x-show="view === 'grid' && !subView">
<div class="deck-selector" x-show="decks.length > 1">
<template x-for="d in decks" :key="d.serial">
<button class="deck-btn"
:class="{ active: d.serial === activeDeckSerial }"
@click="activeDeckSerial = d.serial"
x-text="d.model + ' (' + d.keys_x + '\u00d7' + d.keys_y + ')'"></button>
</template>
</div>
<div class="deck-grid" :style="'grid-template-columns: repeat(' + activeDeck.keys_x + ', minmax(0, 1fr))'">
<template x-for="key in gridKeys" :key="key.index">
<button class="key-btn"
:style="key.displayBg ? { backgroundColor: key.displayBg } : {}"
:class="{ empty: !key.configured }"
@click="editKey(key.index)">
<img x-show="key.configured && (key.icon || key.label || key.displayText || key.displayBg || key.background)"
:src="key.previewUrl" alt="">
<span x-show="!key.configured" style="color:var(--text-muted);font-size:24px;opacity:0.5;line-height:1;">+</span>
<div class="action-icons" x-show="key.hasAction">
<template x-for="(t, ti) in key.actionTypes.slice(0,3)" :key="ti">
<i :class="'fas fa-' + actionIconMap[t] + ' action-icon type-' + t"
:title="actionTitleMap[t]"></i>
</template>
<span class="action-icon" x-show="key.actionTypes.length > 3"
style="background:var(--text-muted);font-size:7px;font-weight:700;padding:2px 3px;"
x-text="'+' + (key.actionTypes.length - 3)"></span>
</div>
<i x-show="key.hasDisplay" class="fas fa-arrows-rotate display-indicator" title="Periodic display"></i>
</button>
</template>
</div>
<div class="toolbar">
<button class="btn btn-outline" @click="addPage">+ Add page</button>
<button class="btn btn-outline" @click="deletePage"
:disabled="pages.length <= 1"
style="margin-left:auto;">Delete page</button>
<button class="btn btn-outline" @click="renamePage">Rename</button>
</div>
</main>
<!-- ═══ Page Switcher (sub-view) ═══ -->
<main x-show="view === 'grid' && subView === 'switcher'" style="justify-content:flex-start;padding-top:12px;">
<div class="panel" style="width:100%;">
<h3>Pages</h3>
<template x-for="p in pages" :key="p.name">
<button class="btn btn-outline" style="display:block;width:100%;margin-bottom:4px;text-align:left;"
:style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }"
@click="switchPage(p.name); subView = null">
<span x-text="p.name"></span>
<span x-show="p.dynamic_keys"
title="Dynamic keys generator"
style="color:var(--accent);font-size:11px;margin-left:4px;"></span>
<span style="float:right;color:var(--text-muted);font-size:11px;"
x-text="(p.effective_keys?.length || p.keys.length) + ' keys'"></span>
</button>
</template>
</div>
</main>
<!-- ═══ Settings View ═══ -->
<main x-show="view === 'settings'" style="justify-content:flex-start;padding-top:12px;">
<div class="panel" style="width:100%;">
<!-- Device -->
<div class="panel-section">
<h4>Device</h4>
<div class="form-group">
<label>Brightness</label>
<div class="slider-group">
<input type="range" min="0" max="100" x-model.number="settingsForm.brightness">
<span class="value" x-text="settingsForm.brightness + '%'"></span>
</div>
</div>
<div class="form-group">
<label>Default Font</label>
<select x-model="settingsForm.font">
<option value="medium">Medium</option>
<option value="regular">Regular</option>
</select>
</div>
<div class="checkbox-row">
<input type="checkbox" id="show-label-bg" x-model="settingsForm.show_label_background">
<label for="show-label-bg">Show label background</label>
</div>
</div>
<!-- Screensaver -->
<div class="panel-section">
<h4>Screensaver</h4>
<div class="checkbox-row">
<input type="checkbox" id="ss-enabled" x-model="settingsForm.screensaver_enabled">
<label for="ss-enabled">Enable screensaver</label>
</div>
<div class="form-row" x-show="settingsForm.screensaver_enabled">
<div class="form-group">
<label>Idle (seconds)</label>
<input type="number" min="5" x-model.number="settingsForm.screensaver_idle">
</div>
<div class="form-group">
<label>Brightness on idle</label>
<input type="number" min="0" max="100" x-model.number="settingsForm.screensaver_brightness">
</div>
</div>
</div>
<!-- Gesture Timing -->
<div class="panel-section">
<h4>Gesture Timing</h4>
<div class="form-group">
<label>Long press threshold</label>
<div class="slider-group">
<input type="range" min="200" max="1500" step="50" x-model.number="settingsForm.long_press_ms">
<span class="value" x-text="settingsForm.long_press_ms + 'ms'"></span>
</div>
</div>
<div class="form-group">
<label>Double tap threshold</label>
<div class="slider-group">
<input type="range" min="100" max="600" step="25" x-model.number="settingsForm.double_tap_ms">
<span class="value" x-text="settingsForm.double_tap_ms + 'ms'"></span>
</div>
</div>
</div>
<!-- Auto-switch -->
<div class="panel-section">
<h4>Auto-switch rules</h4>
<template x-for="(rule, i) in autoSwitchRules" :key="i">
<div class="rule-item">
<div style="flex:1;">
<div class="form-row">
<div class="form-group" style="margin-bottom:4px;">
<label>WM Class</label>
<input type="text" x-model="rule.wm_class" placeholder="regex">
</div>
<div class="form-group" style="margin-bottom:4px;">
<label>Title</label>
<input type="text" x-model="rule.title" placeholder="regex">
</div>
</div>
<div class="form-row">
<div class="form-group" style="margin-bottom:0;">
<label>Page</label>
<select x-model="rule.page">
<option value="">--</option>
<template x-for="p in pages" :key="p.name">
<option :value="p.name" x-text="p.name"></option>
</template>
</select>
</div>
<div class="checkbox-row" style="margin-bottom:0;padding-top:16px;">
<input type="checkbox" x-model="rule.stay">
<label>Stay</label>
</div>
</div>
</div>
<button class="btn btn-danger" style="flex-shrink:0;" @click="removeAutoSwitchRule(i)">x</button>
</div>
</template>
<button class="btn btn-outline" @click="addAutoSwitchRule" style="width:100%;margin-top:8px;">
+ Add rule
</button>
</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="saveSettings">Save settings</button>
</div>
</div>
</main>
<!-- ═══ Backup View ═══ -->
<main x-show="view === 'backups'" style="justify-content:flex-start;padding-top:12px;">
<div class="panel" style="width:100%;">
<div class="panel-section">
<h4>Actions</h4>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary" @click="downloadConfig">
<i class="fas fa-download" style="margin-right:4px;"></i> Export JSON
</button>
<button class="btn btn-outline" @click="restoreConfig">
<i class="fas fa-upload" style="margin-right:4px;"></i> Restore
</button>
</div>
</div>
<div class="panel-section">
<h4>Auto backups <span style="font-weight:400;color:var(--text-muted);"
x-text="backups.length ? '(' + backups.length + ')' : ''"></span></h4>
<template x-if="backups.length === 0">
<p style="color:var(--text-muted);font-size:12px;">No backups yet. They are created automatically when you save.</p>
</template>
<template x-for="b in backups" :key="b.name">
<div class="backup-item">
<div>
<div x-text="backupNameTime(b.name)"></div>
<div class="meta" x-text="formatSize(b.size)"></div>
</div>
<div style="display:flex;gap:4px;">
<button class="btn btn-outline" style="padding:4px 8px;font-size:11px;"
@click="downloadBackup(b.name)" title="Download">
<i class="fas fa-download"></i>
</button>
<button class="btn btn-outline" style="padding:4px 8px;font-size:11px;"
@click="restoreBackup(b.name)" title="Restore">
<i class="fas fa-rotate-left"></i>
</button>
</div>
</div>
</template>
</div>
</div>
</main>
<!-- ═══ Key Editor Modal ═══ -->
<div class="modal-overlay" x-show="editing !== null" @click.self="editing = null">
<div class="modal" x-show="editing !== null" x-transition>
<h3>Edit Key <span x-text="editing + 1"></span></h3>
<!-- Preview -->
<div class="preview-box" @click="pickImage" title="Click to upload image">
<img :src="editPreviewUrl" alt="">
<div class="preview-overlay">
<i class="fas fa-image"></i>
</div>
</div>
<input type="file" accept="image/*" id="image-picker" style="display:none"
x-ref="fileInput" @change="onImagePicked">
<!-- Basic fields -->
<div class="form-group">
<label>Icon</label>
<input type="text" x-model="editForm.icon"
placeholder="fa:terminal, @system-icon, /path/to/image.png">
</div>
<div class="form-group">
<label>Label</label>
<input type="text" x-model="editForm.label" placeholder="Optional text">
</div>
<!-- Actions section -->
<h4 style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;margin-top:12px;">Actions</h4>
<!-- Action tabs -->
<div class="action-tabs">
<template x-for="t in ['tap','long_press','double_tap','hold']" :key="t">
<button class="action-tab"
:class="{ active: editForm.activeTrigger === t, configured: (t === 'hold' ? (hasHoldAction('start') || hasHoldAction('end')) : hasCurrentAction(t)) }"
@click="editForm.activeTrigger = t">
<span x-text="triggerLabel(t)"></span>
<span class="tab-check" x-show="(t === 'hold' ? (hasHoldAction('start') || hasHoldAction('end')) : hasCurrentAction(t))"></span>
</button>
</template>
</div>
<!-- Non-hold trigger panes -->
<template x-for="t in ['tap','long_press','double_tap']" :key="t">
<div x-show="editForm.activeTrigger === t">
<div class="form-group">
<select x-model="editForm.actions[t].type">
<option value="none">None</option>
<option value="command">Command</option>
<option value="builtin">Builtin</option>
<option value="script">Script</option>
<option value="page">Switch page</option>
<option value="keyboard">Keyboard shortcut</option>
</select>
</div>
<template x-if="editForm.actions[t].type === 'command'">
<div>
<div class="form-group">
<input type="text" x-model="editForm.actions[t].command" placeholder="firefox">
</div>
<div class="checkbox-row">
<input type="checkbox" :id="'bg-cb-' + t" x-model="editForm.actions[t].background">
<label :for="'bg-cb-' + t">Run in background</label>
</div>
</div>
</template>
<template x-if="editForm.actions[t].type === 'builtin'">
<div class="form-group">
<select x-model="editForm.actions[t].builtin">
<option value="">-- select --</option>
<option value="volume_up">Volume Up</option>
<option value="volume_down">Volume Down</option>
<option value="volume_mute">Volume Mute</option>
<option value="brightness_up">Brightness Up</option>
<option value="brightness_down">Brightness Down</option>
<option value="media_play_pause">Play/Pause</option>
<option value="media_next">Next Track</option>
<option value="media_prev">Previous Track</option>
<option value="media_stop">Stop</option>
</select>
</div>
</template>
<template x-if="editForm.actions[t].type === 'script'">
<div class="form-group">
<input type="text" x-model="editForm.actions[t].script" placeholder="/path/to/script.sh">
</div>
</template>
<div class="form-group" x-show="editForm.actions[t].type === 'page'">
<select x-model="editForm.actions[t].page">
<option value="">-- select --</option>
<template x-for="p in pages" :key="p.name">
<option :value="p.name" x-text="p.name"></option>
</template>
</select>
</div>
<template x-if="editForm.actions[t].type === 'keyboard'">
<div class="form-group">
<label>Key combination</label>
<div class="mod-row">
<button class="mod-btn" :class="{ active: editForm.actions[t].modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
<button class="mod-btn" :class="{ active: editForm.actions[t].modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
<button class="mod-btn" :class="{ active: editForm.actions[t].modShift }" @click="toggleMod('shift')" type="button">Shift</button>
<button class="mod-btn" :class="{ active: editForm.actions[t].modSuper }" @click="toggleMod('super')" type="button">Super</button>
</div>
<div class="key-capture"
x-ref="keyCapture"
:class="{ capturing: isCapturingKey }"
tabindex="0"
@keydown.prevent="onCaptureKey"
@blur="isCapturingKey = false"
@click="startKeyCapture">
<span x-show="!isCapturingKey && editForm.actions[t].mainKey" class="captured-key" x-text="displayKey(editForm.actions[t].mainKey)"></span>
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
<span x-show="!isCapturingKey && !editForm.actions[t].mainKey" class="capture-placeholder">Click to capture</span>
</div>
<div class="key-chips" x-show="editForm.actions[t].keys" style="margin-top:8px;">
<template x-for="(chip, ki) in keyChips" :key="ki">
<span class="key-chip" x-text="chip"></span>
<span x-show="ki < keyChips.length - 1" class="key-plus">+</span>
</template>
</div>
</div>
</template>
</div>
</template>
<!-- Hold pane -->
<div x-show="editForm.activeTrigger === 'hold'">
<div class="hold-subtabs">
<button class="hold-stab" :class="{ active: editForm.holdPhase === 'start' }"
@click="editForm.holdPhase = 'start'">On press &amp; hold</button>
<button class="hold-stab" :class="{ active: editForm.holdPhase === 'end' }"
@click="editForm.holdPhase = 'end'">On release</button>
</div>
<template x-for="phase in ['start','end']" :key="phase">
<div x-show="editForm.holdPhase === phase">
<div class="form-group">
<select x-model="editForm.actions.hold[phase].type">
<option value="none">None</option>
<option value="command">Command</option>
<option value="builtin">Builtin</option>
<option value="script">Script</option>
<option value="page">Switch page</option>
<option value="keyboard">Keyboard shortcut</option>
</select>
</div>
<template x-if="editForm.actions.hold[phase].type === 'command'">
<div>
<div class="form-group">
<input type="text" x-model="editForm.actions.hold[phase].command" placeholder="firefox">
</div>
<div class="checkbox-row">
<input type="checkbox" :id="'hold-bg-' + phase" x-model="editForm.actions.hold[phase].background">
<label :for="'hold-bg-' + phase">Run in background</label>
</div>
</div>
</template>
<template x-if="editForm.actions.hold[phase].type === 'builtin'">
<div class="form-group">
<select x-model="editForm.actions.hold[phase].builtin">
<option value="">-- select --</option>
<option value="volume_up">Volume Up</option>
<option value="volume_down">Volume Down</option>
<option value="volume_mute">Volume Mute</option>
<option value="brightness_up">Brightness Up</option>
<option value="brightness_down">Brightness Down</option>
<option value="media_play_pause">Play/Pause</option>
<option value="media_next">Next Track</option>
<option value="media_prev">Previous Track</option>
<option value="media_stop">Stop</option>
</select>
</div>
</template>
<template x-if="editForm.actions.hold[phase].type === 'script'">
<div class="form-group">
<input type="text" x-model="editForm.actions.hold[phase].script" placeholder="/path/to/script.sh">
</div>
</template>
<div class="form-group" x-show="editForm.actions.hold[phase].type === 'page'">
<select x-model="editForm.actions.hold[phase].page">
<option value="">-- select --</option>
<template x-for="p in pages" :key="p.name">
<option :value="p.name" x-text="p.name"></option>
</template>
</select>
</div>
<template x-if="editForm.actions.hold[phase].type === 'keyboard'">
<div class="form-group">
<label>Key combination</label>
<div class="mod-row">
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modShift }" @click="toggleMod('shift')" type="button">Shift</button>
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modSuper }" @click="toggleMod('super')" type="button">Super</button>
</div>
<div class="key-capture"
x-ref="keyCapture"
:class="{ capturing: isCapturingKey }"
tabindex="0"
@keydown.prevent="onCaptureKey"
@blur="isCapturingKey = false"
@click="startKeyCapture">
<span x-show="!isCapturingKey && editForm.actions.hold[phase].mainKey" class="captured-key" x-text="displayKey(editForm.actions.hold[phase].mainKey)"></span>
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
<span x-show="!isCapturingKey && !editForm.actions.hold[phase].mainKey" class="capture-placeholder">Click to capture</span>
</div>
<div class="key-chips" x-show="editForm.actions.hold[phase].keys" style="margin-top:8px;">
<template x-for="(chip, ki) in keyChips" :key="ki">
<span class="key-chip" x-text="chip"></span>
<span x-show="ki < keyChips.length - 1" class="key-plus">+</span>
</template>
</div>
</div>
</template>
</div>
</template>
</div>
<!-- Advanced toggle -->
<button class="collapse-toggle" :class="{ open: showAdvanced }" @click="showAdvanced = !showAdvanced" style="margin-top:8px;">
<i class="fas fa-chevron-right"></i> More
</button>
<!-- Advanced fields -->
<div x-show="showAdvanced" x-transition>
<div class="form-row">
<div class="form-group">
<label>Icon Scale</label>
<input type="number" step="0.01" min="0.1" max="1.0" x-model.number="editForm.icon_scale">
</div>
<div class="form-group">
<label>Font Size</label>
<input type="number" step="0.5" min="8" max="32" x-model.number="editForm.font_size">
</div>
</div>
<div class="form-group">
<label>Background</label>
<div style="display:flex;gap:8px;align-items:center;">
<input type="color"
x-init="$el.value = editForm.bg_color || '#000000'; $el.addEventListener('input', () => editForm.bg_color = $el.value); $watch('editForm.bg_color', v => $el.value = v || '#000000')"
style="width:40px;height:40px;padding:2px;border-radius:var(--radius-xs);cursor:pointer;">
<input type="text" x-model="editForm.bg_color"
placeholder="#rrggbb or #rrggbbaa" maxlength="9"
style="flex:1;">
<button class="icon-btn" style="width:24px;height:24px;font-size:10px;flex-shrink:0;"
@click="editForm.bg_color = ''" title="Clear"
x-show="editForm.bg_color !== ''">
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
<div class="form-group">
<label>Display mode</label>
<div class="tab-row">
<button class="tab-btn" :class="{ active: editForm.display_mode === 'none' }"
@click="editForm.display_mode = 'none'">Off</button>
<button class="tab-btn" :class="{ active: editForm.display_mode === 'command' }"
@click="editForm.display_mode = 'command'">Command</button>
<button class="tab-btn" :class="{ active: editForm.display_mode === 'script' }"
@click="editForm.display_mode = 'script'">Script</button>
</div>
</div>
<template x-if="editForm.display_mode === 'command'">
<div class="form-group">
<label>Command</label>
<input type="text" x-model="editForm.display_command"
placeholder="curl -s http://...">
</div>
</template>
<template x-if="editForm.display_mode === 'script'">
<div class="form-group">
<label>Script path</label>
<input type="text" x-model="editForm.display_script"
placeholder="/path/to/script.sh">
</div>
</template>
<template x-if="editForm.display_mode !== 'none'">
<div>
<div class="form-row">
<div class="form-group">
<label>Interval</label>
<input type="text" x-model="editForm.display_interval" placeholder="30s">
</div>
<div class="form-group">
<label>Timeout</label>
<input type="text" x-model="editForm.display_timeout" placeholder="30s">
</div>
</div>
<div class="form-group">
<label>Max output length</label>
<input type="number" x-model.number="editForm.display_max_len" min="16" max="4096" placeholder="128">
</div>
</div>
</template>
</div>
<!-- Actions -->
<div class="modal-actions">
<button class="btn btn-primary" @click="saveKey">Save</button>
<button class="btn btn-danger" @click="deleteKey">Delete</button>
<button class="btn btn-outline" @click="editing = null">Cancel</button>
</div>
</div>
</div>
<!-- ═══ Toast ═══ -->
<div class="toast" :class="toast?.type" x-show="toast" x-transition x-text="toast?.msg"></div>
</div>
</body>
</html>
+813
View File
@@ -0,0 +1,813 @@
:root {
--bg: #0a0a10;
--surface: #141420;
--surface-hover: #1a1a2a;
--border: #252540;
--border-light: #333358;
--text: #e4e4f0;
--text-muted: #7a7a90;
--accent: #6366f1;
--accent-glow: rgba(99, 102, 241, 0.3);
--danger: #ef4444;
--success: #22c55e;
--radius: 12px;
--radius-sm: 8px;
--radius-xs: 6px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
max-width: 780px;
margin: 0 auto;
padding: 0 16px;
}
/* ── Header ── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
gap: 12px;
flex-shrink: 0;
}
header h1 {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.3px;
}
.page-nav {
display: flex;
align-items: center;
gap: 6px;
}
.page-nav .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
cursor: pointer;
transition: background var(--transition);
}
.page-nav .dot:hover { background: var(--text-muted); }
.page-nav .dot.active { background: var(--accent); box-shadow: 0 0 8px var(--accent-glow); }
.header-actions {
display: flex;
gap: 8px;
}
.icon-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
width: 32px;
height: 32px;
border-radius: var(--radius-xs);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition);
font-size: 14px;
}
.icon-btn:hover {
background: var(--surface-hover);
border-color: var(--border-light);
color: var(--text);
}
/* ── Grid ── */
main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.deck-grid {
display: grid;
gap: 8px;
padding: 20px;
background: var(--surface);
border-radius: var(--radius);
border: 1px solid var(--border);
width: 100%;
max-width: 525px;
contain: layout style;
}
.deck-selector {
display: flex;
gap: 4px;
margin-bottom: 8px;
align-self: stretch;
}
.deck-btn {
flex: 1;
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all var(--transition);
text-align: center;
}
.deck-btn:hover { color: var(--text); border-color: var(--border-light); }
.deck-btn.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.key-btn {
aspect-ratio: 1;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #0d0d18;
cursor: pointer;
overflow: hidden;
position: relative;
transition: all var(--transition);
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-width: 0;
min-height: 0;
}
.key-btn:hover {
border-color: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
transform: scale(1.03);
}
.key-btn:active { transform: scale(0.97); }
.key-btn img {
width: 100%;
height: 100%;
object-fit: cover;
}
.key-btn.empty {
border-color: var(--border-light);
}
.key-btn.empty:hover {
opacity: 0.5;
border-color: var(--border-light);
box-shadow: none;
}
.key-label {
position: absolute;
bottom: 3px;
left: 4px;
right: 4px;
font-size: 9px;
color: rgba(255, 255, 255, 0.75);
text-align: center;
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
line-height: 1;
}
.fa-preview {
font-size: 34px;
color: #fff;
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 150ms ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
width: 360px;
max-width: calc(100vw - 32px);
box-shadow: var(--shadow);
animation: slideUp 200ms cubic-bezier(0.16, 1, 0.3, 1);
max-height: 85vh;
overflow-y: auto;
}
@keyframes slideUp {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
letter-spacing: -0.2px;
}
.preview-box {
width: 100px;
height: 100px;
margin: 0 auto 16px;
border-radius: var(--radius-xs);
overflow: hidden;
border: 1px solid var(--border);
position: relative;
background: #0d0d18;
cursor: pointer;
}
.preview-box img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity var(--transition);
border-radius: var(--radius-xs);
}
.preview-box:hover .preview-overlay { opacity: 1; }
.preview-overlay i {
font-size: 24px;
color: #fff;
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color var(--transition);
}
.form-group input:focus,
.form-group select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
}
.form-group input::placeholder {
color: var(--text-muted);
opacity: 0.5;
}
.form-group select {
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%237a7a90' stroke-width='1.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
}
.form-row {
display: flex;
gap: 8px;
}
.form-row .form-group { flex: 1; }
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.checkbox-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.checkbox-row label {
font-size: 13px;
color: var(--text);
cursor: pointer;
}
/* Collapsible */
.collapse-toggle {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 0;
margin-bottom: 8px;
font-family: inherit;
}
.collapse-toggle:hover { color: var(--accent); }
.collapse-toggle i { font-size: 10px; transition: transform var(--transition); }
.collapse-toggle.open i { transform: rotate(90deg); }
.modal-actions {
display: flex;
gap: 8px;
margin-top: 20px;
}
.btn {
padding: 8px 16px;
border-radius: var(--radius-xs);
font-size: 13px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
border: 1px solid transparent;
transition: all var(--transition);
}
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
flex: 1;
}
.btn-primary:hover {
background: #4f46e5;
box-shadow: 0 0 16px var(--accent-glow);
}
.btn-outline {
background: transparent;
border-color: var(--border);
color: var(--text);
}
.btn-outline:hover {
background: var(--surface-hover);
border-color: var(--border-light);
}
.btn-danger {
color: var(--danger);
border-color: transparent;
background: transparent;
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.3);
}
/* ── Settings / Backup panels ── */
.panel {
width: 100%;
padding: 20px;
background: var(--surface);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.panel h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
}
.panel-section {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.panel-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.panel-section h4 {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.backup-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.backup-item:last-child { border-bottom: none; }
.backup-item .meta {
color: var(--text-muted);
font-size: 11px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--bg);
border-radius: 20px;
font-size: 12px;
border: 1px solid var(--border);
}
.status-badge .dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-badge .dot.online { background: var(--success); }
.status-badge .dot.offline { background: var(--text-muted); }
/* ── Toasts ── */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--surface);
border: 1px solid var(--border);
padding: 12px 24px;
border-radius: var(--radius);
font-size: 13px;
z-index: 200;
box-shadow: var(--shadow);
animation: slideUp 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.toast.success { border-color: var(--success); }
.toast.error { border-color: var(--danger); }
.slider-group {
display: flex;
align-items: center;
gap: 10px;
}
.slider-group input[type="range"] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: var(--border);
outline: none;
}
.slider-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--bg);
}
.slider-group .value {
font-size: 12px;
color: var(--text-muted);
min-width: 36px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── Toolbar ── */
.toolbar {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
flex-shrink: 0;
}
.toolbar .btn {
font-size: 12px;
padding: 6px 14px;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
/* ── Auto-switch rule item ── */
.rule-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg);
border-radius: var(--radius-xs);
margin-bottom: 6px;
font-size: 12px;
}
.rule-item code {
background: var(--surface);
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
color: var(--accent);
}
/* ── Indicators on grid keys ── */
.display-indicator {
position: absolute;
bottom: 4px;
right: 4px;
font-size: 7px;
color: var(--accent);
opacity: 0.6;
pointer-events: none;
}
.action-icons {
position: absolute;
top: 4px;
left: 4px;
display: flex;
gap: 3px;
pointer-events: none;
flex-wrap: wrap;
max-width: calc(100% - 6px);
}
.action-icon {
font-size: 7px;
color: var(--accent);
opacity: 0.6;
line-height: 1;
}
/* ── Tab buttons ── */
.tab-row {
display: flex;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius-xs);
overflow: hidden;
}
.tab-btn {
flex: 1;
padding: 6px 8px;
background: var(--bg);
border: none;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all var(--transition);
border-right: 1px solid var(--border);
}
.tab-btn:last-child { border-right: none; }
.tab-btn:hover { color: var(--text); }
.tab-btn.active {
background: var(--accent);
color: #fff;
}
/* ── Modifier buttons ── */
.mod-row {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.mod-btn {
flex: 1;
padding: 6px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all var(--transition);
text-align: center;
}
.mod-btn:hover { color: var(--text); border-color: var(--border-light); }
.mod-btn.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* ── Key capture ── */
.key-capture {
border: 1px dashed var(--border);
border-radius: var(--radius-xs);
padding: 8px;
min-height: 34px;
cursor: pointer;
text-align: center;
transition: all var(--transition);
outline: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.key-capture:hover { border-color: var(--accent); }
.key-capture:focus-visible { border-color: var(--accent); }
.key-capture.capturing {
border-color: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
animation: recorderPulse 1s infinite;
}
.captured-key {
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--accent);
font-size: 13px;
}
.capture-hint { color: var(--accent); font-weight: 600; }
.capture-placeholder { color: var(--text-muted); opacity: 0.6; }
@keyframes recorderPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.key-chips {
display: flex;
align-items: center;
gap: 2px;
flex-wrap: wrap;
justify-content: center;
}
.key-chip {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--accent);
}
.key-plus {
color: var(--text-muted);
font-size: 11px;
margin: 0 2px;
}
/* ── Action tabs ── */
.action-tabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.action-tab {
flex: 1;
padding: 5px 2px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text-muted);
font-size: 9px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
text-align: center;
transition: all var(--transition);
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
line-height: 1.3;
}
.action-tab:hover { color: var(--text); border-color: var(--border-light); }
.action-tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.action-tab.configured {
border-color: var(--success);
color: var(--success);
}
.action-tab.active.configured {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.tab-check { font-size: 8px; }
/* ── Hold sub-tabs ── */
.hold-subtabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.hold-stab {
flex: 1;
padding: 4px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text-muted);
font-size: 10px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
text-align: center;
transition: all var(--transition);
}
.hold-stab:hover { color: var(--text); border-color: var(--border-light); }
.hold-stab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
+693
View File
@@ -0,0 +1,693 @@
package web
import (
"context"
"embed"
"encoding/json"
"fmt"
"image/png"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"streamdeck-lets-go/internal/config"
"streamdeck-lets-go/internal/deck"
"streamdeck-lets-go/internal/render"
)
//go:embed static/*
var staticFS embed.FS
var staticRoot fs.FS
func init() {
var err error
staticRoot, err = fs.Sub(staticFS, "static")
if err != nil {
panic(fmt.Sprintf("static embed: %v", err))
}
}
type WebServer struct {
cfg *config.Config
configPath string
pm *render.PageManager
extraPMs []*render.PageManager
decks []*deck.Deck
mu sync.RWMutex
sseClients map[chan string]struct{}
sseMu sync.RWMutex
}
func NewWebServer(cfg *config.Config, configPath string) *WebServer {
return &WebServer{
cfg: cfg,
configPath: configPath,
sseClients: make(map[chan string]struct{}),
}
}
func (s *WebServer) UpdateConfig(cfg *config.Config) {
s.mu.Lock()
defer s.mu.Unlock()
s.cfg = cfg
}
func (s *WebServer) SetPageManager(pm *render.PageManager) {
s.mu.Lock()
defer s.mu.Unlock()
s.pm = pm
}
func (s *WebServer) SetDecks(decks []*deck.Deck) {
s.mu.Lock()
defer s.mu.Unlock()
s.decks = decks
}
func (s *WebServer) SetExtraPageManagers(pms []*render.PageManager) {
s.mu.Lock()
defer s.mu.Unlock()
s.extraPMs = pms
}
func (s *WebServer) BroadcastPageChange(page string) {
s.sseMu.RLock()
defer s.sseMu.RUnlock()
for ch := range s.sseClients {
select {
case ch <- page:
default:
}
}
}
func (s *WebServer) Serve(ctx context.Context, addr string) error {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/config", s.handleGetConfig)
mux.HandleFunc("PUT /api/config", s.handlePutConfig)
mux.HandleFunc("GET /api/config/download", s.handleDownloadConfig)
mux.HandleFunc("POST /api/config/restore", s.handleRestoreConfig)
mux.HandleFunc("GET /api/pages", s.handleGetPages)
mux.HandleFunc("GET /api/render", s.handleRender)
mux.HandleFunc("GET /api/display-outputs", s.handleDisplayOutputs)
mux.HandleFunc("GET /api/backups", s.handleListBackups)
mux.HandleFunc("GET /api/backups/{filename}", s.handleGetBackup)
mux.HandleFunc("GET /api/models", s.handleGetModels)
mux.HandleFunc("GET /api/decks", s.handleGetDecks)
mux.HandleFunc("GET /api/status", s.handleGetStatus)
mux.HandleFunc("GET /api/events", s.handleSSE)
mux.HandleFunc("POST /api/activate-page", s.handleActivatePage)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
mux.HandleFunc("/", s.handleSPA)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/api/upload" {
s.handleUpload(w, r)
return
}
mux.ServeHTTP(w, r)
})
srv := &http.Server{Addr: addr, Handler: handler}
go func() {
<-ctx.Done()
slog.Info("shutting down web server")
srv.Close()
}()
slog.Info("web server listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("web server: %w", err)
}
return nil
}
func (s *WebServer) handleSPA(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
data, err := fs.ReadFile(staticRoot, "index.html")
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
}
func (s *WebServer) handleSSE(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ch := make(chan string, 8)
s.sseMu.Lock()
s.sseClients[ch] = struct{}{}
s.sseMu.Unlock()
s.mu.RLock()
if s.pm != nil {
if active := s.pm.ActivePageName(); active != "" {
ch <- active
}
}
s.mu.RUnlock()
notify := r.Context().Done()
go func() {
<-notify
s.sseMu.Lock()
delete(s.sseClients, ch)
s.sseMu.Unlock()
}()
flusher.Flush()
for {
select {
case <-notify:
return
case page := <-ch:
data, _ := json.Marshal(map[string]string{"page": page})
fmt.Fprintf(w, "event: page_changed\ndata: %s\n\n", data)
flusher.Flush()
}
}
}
func (s *WebServer) handleActivatePage(w http.ResponseWriter, r *http.Request) {
var req struct {
Page string `json:"page"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
return
}
if req.Page == "" {
http.Error(w, "page is required", http.StatusBadRequest)
return
}
s.mu.RLock()
allPMs := append([]*render.PageManager{s.pm}, s.extraPMs...)
s.mu.RUnlock()
for _, pm := range allPMs {
if pm == nil {
continue
}
if err := pm.ActivatePage(req.Page); err != nil {
slog.Warn("activate page", "page", req.Page, "error", err)
} else {
pm.StopPeriodicKeys()
pm.StartPeriodicKeys()
}
}
s.BroadcastPageChange(req.Page)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "page": req.Page})
}
func (s *WebServer) handleGetConfig(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(s.cfg)
}
func (s *WebServer) handlePutConfig(w http.ResponseWriter, r *http.Request) {
var newCfg config.Config
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err := newCfg.Validate(); err != nil {
http.Error(w, fmt.Sprintf("invalid config: %v", err), http.StatusBadRequest)
return
}
path := config.ConfigPath(s.configPath)
s.mu.Lock()
oldCfg := s.cfg
s.cfg = &newCfg
s.mu.Unlock()
if err := s.cfg.Save(s.configPath); err != nil {
s.mu.Lock()
s.cfg = oldCfg
s.mu.Unlock()
slog.Error("save config", "error", err)
http.Error(w, fmt.Sprintf("save failed: %v", err), http.StatusInternalServerError)
return
}
backupOldConfig(path)
gcImages(&newCfg)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "saved"})
}
func (s *WebServer) handleDownloadConfig(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := json.MarshalIndent(s.cfg, "", " ")
if err != nil {
http.Error(w, "serialization error", http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("streamdeck-config-%s.json", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Write(data)
}
func (s *WebServer) handleRestoreConfig(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
var newCfg config.Config
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, fmt.Sprintf("file field 'file' required: %v", err), http.StatusBadRequest)
return
}
defer file.Close()
if err := json.NewDecoder(file).Decode(&newCfg); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
return
}
} else {
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
return
}
}
if err := newCfg.Validate(); err != nil {
http.Error(w, fmt.Sprintf("invalid config: %v", err), http.StatusBadRequest)
return
}
path := config.ConfigPath(s.configPath)
backupOldConfig(path)
s.mu.Lock()
s.cfg = &newCfg
s.mu.Unlock()
if err := s.cfg.Save(s.configPath); err != nil {
http.Error(w, fmt.Sprintf("save failed: %v", err), http.StatusInternalServerError)
return
}
gcImages(&newCfg)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "restored"})
}
func (s *WebServer) handleGetPages(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
type pageInfo struct {
Name string `json:"name"`
Keys []config.KeyConfig `json:"keys"`
Icon string `json:"icon,omitempty"`
DynamicKeys *config.DynamicKeyGen `json:"dynamic_keys,omitempty"`
Background string `json:"background,omitempty"`
EffectiveKeys []config.KeyConfig `json:"effective_keys,omitempty"`
}
pages := make([]pageInfo, 0, len(s.cfg.Pages))
for _, p := range s.cfg.Pages {
pi := pageInfo{
Name: p.Name,
Keys: p.Keys,
Icon: p.Icon,
DynamicKeys: p.DynamicKeys,
Background: p.Background,
}
if p.DynamicKeys != nil && s.pm != nil {
pi.EffectiveKeys = s.pm.GetEffectiveKeys(p.Name)
}
pages = append(pages, pi)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(pages)
}
func (s *WebServer) handleRender(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
keySize := 72
if s := q.Get("key_size"); s != "" {
fmt.Sscanf(s, "%d", &keySize)
}
kc := &config.KeyConfig{}
if page := q.Get("page"); page != "" {
key := q.Get("key")
s.mu.RLock()
for _, p := range s.cfg.Pages {
if p.Name == page {
for _, k := range p.Keys {
if fmt.Sprintf("%d", k.Index) == key {
kc = &k
break
}
}
break
}
}
s.mu.RUnlock()
}
if icon := q.Get("icon"); icon != "" {
kc.Icon = icon
if s := q.Get("icon_scale"); s != "" {
v := 0.0
fmt.Sscanf(s, "%f", &v)
kc.IconScale = &v
}
}
if label := q.Get("label"); label != "" {
kc.Label = label
}
if fs := q.Get("font_size"); fs != "" {
v := 0.0
fmt.Sscanf(fs, "%f", &v)
kc.FontSize = &v
}
if bg := q.Get("background"); bg != "" {
kc.Background = bg
}
img := render.RenderKeyToImage(kc, keySize, s.cfg.ShowLabelBackground)
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-cache")
png.Encode(w, img)
}
func (s *WebServer) handleDisplayOutputs(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
pm := s.pm
s.mu.RUnlock()
if pm == nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{}"))
return
}
outputs := pm.GetDisplayOutputs()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(outputs)
}
func backupOldConfig(path string) {
backupDir := filepath.Join(filepath.Dir(path), "backups")
if err := os.MkdirAll(backupDir, 0755); err != nil {
slog.Warn("backup: create dir", "error", err)
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
name := fmt.Sprintf("config.%s.json", time.Now().Format("2006-01-02T15-04-05"))
dst := filepath.Join(backupDir, name)
if err := os.WriteFile(dst, data, 0644); err != nil {
slog.Warn("backup: write", "error", err)
return
}
entries, _ := os.ReadDir(backupDir)
if len(entries) > 10 {
sort.Slice(entries, func(i, j int) bool {
fi, _ := entries[i].Info()
fj, _ := entries[j].Info()
return fi.ModTime().Before(fj.ModTime())
})
for _, e := range entries[:len(entries)-10] {
os.Remove(filepath.Join(backupDir, e.Name()))
}
}
}
func collectIcons(cfg *config.Config, used map[string]bool) {
for _, p := range cfg.Pages {
for _, k := range p.Keys {
if k.Icon != "" {
used[k.Icon] = true
}
}
}
}
func gcImages(cfg *config.Config) {
imgDir := filepath.Join(config.ConfigDir(), "images")
entries, err := os.ReadDir(imgDir)
if err != nil {
return
}
used := make(map[string]bool)
collectIcons(cfg, used)
backupDir := filepath.Join(config.ConfigDir(), "backups")
backupEntries, _ := os.ReadDir(backupDir)
for _, be := range backupEntries {
if be.IsDir() {
continue
}
data, err := os.ReadFile(filepath.Join(backupDir, be.Name()))
if err != nil {
continue
}
var backupCfg config.Config
if err := json.Unmarshal(data, &backupCfg); err != nil {
continue
}
collectIcons(&backupCfg, used)
}
for _, e := range entries {
if e.IsDir() {
continue
}
path := filepath.Join(imgDir, e.Name())
if !used[path] {
if err := os.Remove(path); err != nil {
slog.Warn("gc: remove orphan image", "path", path, "error", err)
} else {
slog.Debug("gc: removed orphan image", "path", path)
}
}
}
}
func (s *WebServer) handleListBackups(w http.ResponseWriter, r *http.Request) {
path := config.ConfigPath(s.configPath)
backupDir := filepath.Join(filepath.Dir(path), "backups")
entries, err := os.ReadDir(backupDir)
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]map[string]interface{}{})
return
}
type backupInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
Time string `json:"time"`
}
var backups []backupInfo
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "config.") || !strings.HasSuffix(e.Name(), ".json") {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
backups = append(backups, backupInfo{
Name: e.Name(),
Size: fi.Size(),
Time: fi.ModTime().Format(time.RFC3339),
})
}
sort.Slice(backups, func(i, j int) bool {
return backups[i].Time > backups[j].Time
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(backups)
}
func (s *WebServer) handleGetBackup(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
if filename == "" || strings.Contains(filename, "/") || strings.Contains(filename, "..") {
http.Error(w, "invalid filename", http.StatusBadRequest)
return
}
path := config.ConfigPath(s.configPath)
backupDir := filepath.Join(filepath.Dir(path), "backups")
fullPath := filepath.Join(backupDir, filename)
if !strings.HasPrefix(fullPath, backupDir) {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
data, err := os.ReadFile(fullPath)
if err != nil {
http.Error(w, "backup not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Write(data)
}
func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) {
type modelInfo struct {
PID uint16 `json:"pid"`
Name string `json:"name"`
KeysX int `json:"keys_x"`
KeysY int `json:"keys_y"`
KeySize int `json:"key_size"`
}
models := make([]modelInfo, 0, len(deck.KnownDecks))
for _, d := range deck.KnownDecks {
models = append(models, modelInfo{
PID: d.PID,
Name: d.Name,
KeysX: d.KeysX,
KeysY: d.KeysY,
KeySize: d.KeySize,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models)
}
func (s *WebServer) handleGetDecks(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
infos := make([]deck.DeckInfo, 0, len(s.decks))
for _, d := range s.decks {
infos = append(infos, d.DeckInfo())
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(infos)
}
func (s *WebServer) handleGetStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"config_version": s.cfg.Version,
"default_page": s.cfg.DefaultPage,
"page_count": len(s.cfg.Pages),
"device_count": len(s.cfg.Devices),
})
}
func (s *WebServer) handleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
return
}
file, handler, err := r.FormFile("file")
if err != nil {
http.Error(w, fmt.Sprintf("file field 'file' required: %v", err), http.StatusBadRequest)
return
}
defer file.Close()
uploadDir := filepath.Join(config.ConfigDir(), "images")
if err := os.MkdirAll(uploadDir, 0755); err != nil {
http.Error(w, fmt.Sprintf("create upload dir: %v", err), http.StatusInternalServerError)
return
}
ext := filepath.Ext(handler.Filename)
if ext == "" {
ext = ".png"
}
filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
savePath := filepath.Join(uploadDir, filename)
dst, err := os.Create(savePath)
if err != nil {
http.Error(w, fmt.Sprintf("create file: %v", err), http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, fmt.Sprintf("write file: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"path": savePath})
}