1
0
Maksim Totmin f65ff75a43 Initial commit: Stream Deck daemon with web UI
- Web-based key editor (Alpine.js SPA)
- Actions: shell commands, scripts, built-in media/volume/brightness, page switching
- Keyboard shortcut action (wtype/xdotool)
- On-device display with periodic command output
- Auto page switching (Hyprland, Sway, Niri, GNOME, KDE, X11)
- Screensaver with idle detection
- Config hot-reload
- Multi-device support
2026-06-16 22:35:14 +07:00

530 lines
13 KiB
Go

package main
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"
)
//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 *PageManager
mu sync.RWMutex
}
func NewWebServer(cfg *config.Config, configPath string) *WebServer {
return &WebServer{cfg: cfg, configPath: configPath}
}
func (s *WebServer) UpdateConfig(cfg *config.Config) {
s.mu.Lock()
defer s.mu.Unlock()
s.cfg = cfg
}
func (s *WebServer) SetPageManager(pm *PageManager) {
s.mu.Lock()
defer s.mu.Unlock()
s.pm = pm
}
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/status", s.handleGetStatus)
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) 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"`
}
pages := make([]pageInfo, 0, len(s.cfg.Pages))
for _, p := range s.cfg.Pages {
pages = append(pages, pageInfo{
Name: p.Name,
Keys: p.Keys,
Icon: p.Icon,
})
}
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 := RenderKeyToImage(kc, keySize)
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 gcImages(cfg *config.Config) {
imgDir := filepath.Join(configDir(), "images")
entries, err := os.ReadDir(imgDir)
if err != nil {
return
}
used := make(map[string]bool, len(entries))
for _, p := range cfg.Pages {
for _, k := range p.Keys {
if k.Icon != "" {
used[k.Icon] = true
}
}
}
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(knownDecks))
for _, d := range 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) 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(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})
}