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
This commit is contained in:
commit
f65ff75a43
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
streamdeck-lets-go
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
*.exe
|
||||||
|
/images/
|
||||||
137
README.md
Normal file
137
README.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# streamdeck-lets-go
|
||||||
|
|
||||||
|
> A lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Web-based editor** — Alpine.js SPA for managing pages, keys, icons, and actions
|
||||||
|
- **Multi-action support** — assign shell commands, scripts, page switching, media/volume/brightness controls, and **keyboard shortcuts** to any key
|
||||||
|
- **On-device display** — render icons, text labels, and periodic command output directly on Stream Deck keys
|
||||||
|
- **Auto page switching** — automatically change pages based on the focused window (Hyprland, Sway, Niri, GNOME, KDE, X11)
|
||||||
|
- **Screensaver** — dim or blank the deck after a configurable idle timeout
|
||||||
|
- **Hot-reload** — config changes via the web UI are applied live; manual edits to `config.json` are detected and reloaded automatically
|
||||||
|
- **No cloud, no Electron** — single Go binary with an embedded web frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Linux with **CGO** enabled (required by `bearsh/hid` / libusb)
|
||||||
|
- Stream Deck connected via USB
|
||||||
|
|
||||||
|
### Install from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o streamdeck-lets-go .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full daemon (deck hardware + web UI)
|
||||||
|
./streamdeck-lets-go daemon
|
||||||
|
|
||||||
|
# Web UI only (config editor without deck)
|
||||||
|
./streamdeck-lets-go serve -addr :9090
|
||||||
|
|
||||||
|
# List connected devices
|
||||||
|
./streamdeck-lets-go discover
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:9090` in your browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The config file is stored at `~/.config/streamdeck-lets-go/config.json`. You can edit it manually or through the web UI.
|
||||||
|
|
||||||
|
### Key actions
|
||||||
|
|
||||||
|
| Type | Field | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `command` | `command` | Run a shell command |
|
||||||
|
| `script` | `script` | Run an executable script |
|
||||||
|
| `builtin` | `builtin` | Built-in media/volume/brightness controls |
|
||||||
|
| `page` | `page` | Switch to another page |
|
||||||
|
| `keyboard` | `keys` | Send a keyboard shortcut (e.g. `ctrl+t`, `super+return`) |
|
||||||
|
|
||||||
|
### Keyboard actions
|
||||||
|
|
||||||
|
Sends a key combination to the currently focused window.
|
||||||
|
|
||||||
|
| Platform | Tool required |
|
||||||
|
|---|---|
|
||||||
|
| Wayland | `wtype` |
|
||||||
|
| X11 | `xdotool` |
|
||||||
|
|
||||||
|
The daemon warns on startup if the required tool is missing.
|
||||||
|
|
||||||
|
### Built-in actions
|
||||||
|
|
||||||
|
| Value | Action |
|
||||||
|
|---|---|
|
||||||
|
| `volume_up` / `volume_down` / `volume_mute` | PipeWire volume (via `wpctl`) |
|
||||||
|
| `brightness_up` / `brightness_down` | Display brightness (via `brightnessctl`) |
|
||||||
|
| `media_play_pause` / `media_next` / `media_prev` / `media_stop` | MPRIS media (via `playerctl`) |
|
||||||
|
|
||||||
|
### Periodic display
|
||||||
|
|
||||||
|
Any key can display the output of a command or script, updated on an interval. Use this for weather, system stats, calendar, etc.
|
||||||
|
|
||||||
|
### Auto-switch rules
|
||||||
|
|
||||||
|
Automatically change pages based on the focused window:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wm_class": "firefox",
|
||||||
|
"page": "browser"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported compositors: Hyprland, Sway, Niri, GNOME, KDE, X11.
|
||||||
|
|
||||||
|
### Screensaver
|
||||||
|
|
||||||
|
After a configurable idle period the deck dims or shows a custom image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.totmin.ru:en2zmax/streamdeck-lets-go.git
|
||||||
|
cd streamdeck-lets-go
|
||||||
|
go build -o streamdeck-lets-go .
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `libusb-1.0` | HID communication via CGO |
|
||||||
|
| `librsvg` | SVG icon rendering (optional) |
|
||||||
|
| `fontconfig` | System font detection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported devices
|
||||||
|
|
||||||
|
| Model | PID | Keys |
|
||||||
|
|---|---|---|
|
||||||
|
| Stream Deck Original | `0x006d` | 15 |
|
||||||
|
| Stream Deck Mini | `0x0063` | 6 |
|
||||||
|
| Stream Deck XL | `0x006c` | 32 |
|
||||||
|
| Stream Deck MK.2 | `0x0080` | 15 |
|
||||||
|
| Stream Deck Pedal | `0x0086` | 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
311
action.go
Normal file
311
action.go
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"streamdeck-lets-go/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExecuteAction(a *config.Action, deck *Deck, pm *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 execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if d.Command != "" {
|
||||||
|
cmd = exec.CommandContext(ctx, "sh", "-c", d.Command)
|
||||||
|
} else {
|
||||||
|
cmd = exec.CommandContext(ctx, d.Script)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
output := stdout.String()
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
if output != "" {
|
||||||
|
output += "\n"
|
||||||
|
}
|
||||||
|
output += stderr.String()
|
||||||
|
}
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func execBuiltin(a *config.Action, deck *Deck, pm *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 *PageManager) error {
|
||||||
|
switch action {
|
||||||
|
case "next":
|
||||||
|
pageNames := pm.PageNames()
|
||||||
|
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.PageNames()
|
||||||
|
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) 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("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 "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 (pm *PageManager) PageNames() []string {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
names := make([]string, 0, len(pm.pages))
|
||||||
|
for name := range pm.pages {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
BIN
assets/Font_Awesome_7_BrandsRegular400.otf
Normal file
BIN
assets/Font_Awesome_7_BrandsRegular400.otf
Normal file
Binary file not shown.
BIN
assets/Font_Awesome_7_FreeRegular400.otf
Normal file
BIN
assets/Font_Awesome_7_FreeRegular400.otf
Normal file
Binary file not shown.
BIN
assets/Font_Awesome_7_FreeSolid900.otf
Normal file
BIN
assets/Font_Awesome_7_FreeSolid900.otf
Normal file
Binary file not shown.
451
autoswitch.go
Normal file
451
autoswitch.go
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
autoswitch_test.go
Normal file
223
autoswitch_test.go
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
209
cbdt.go
Normal file
209
cbdt.go
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
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 < 1 {
|
||||||
|
displaySize = targetSize
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
46
config.example.json
Normal file
46
config.example.json
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"log_level": "info",
|
||||||
|
"default_page": "main",
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"serial": "",
|
||||||
|
"brightness": 75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"icon": "fa:terminal",
|
||||||
|
"action": {
|
||||||
|
"type": "command",
|
||||||
|
"command": "kitty",
|
||||||
|
"background": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"icon": "fab:firefox",
|
||||||
|
"label": "Browser",
|
||||||
|
"font": "medium",
|
||||||
|
"font_size": 11,
|
||||||
|
"action": {
|
||||||
|
"type": "command",
|
||||||
|
"command": "firefox",
|
||||||
|
"background": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auto_switch": [],
|
||||||
|
"screensaver": {
|
||||||
|
"enabled": false,
|
||||||
|
"idle_seconds": 30,
|
||||||
|
"image": "",
|
||||||
|
"brightness": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
256
daemon.go
Normal file
256
daemon.go
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"streamdeck-lets-go/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RunOptions struct {
|
||||||
|
ConfigPath string
|
||||||
|
HTTPAddr string
|
||||||
|
HTTPEnabled bool
|
||||||
|
NoDeck bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
||||||
|
web := NewWebServer(cfg, opts.ConfigPath)
|
||||||
|
|
||||||
|
if !opts.NoDeck || opts.HTTPEnabled {
|
||||||
|
checkKeyboardTool()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.HTTPEnabled {
|
||||||
|
go func() {
|
||||||
|
if err := web.Serve(ctx, opts.HTTPAddr); err != nil {
|
||||||
|
slog.Error("web server error", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.NoDeck {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var deck *Deck
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for {
|
||||||
|
deck, err = OpenDeck("")
|
||||||
|
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):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer deck.Close()
|
||||||
|
|
||||||
|
deck.SetBrightness(deviceBrightness(cfg))
|
||||||
|
|
||||||
|
pm := NewPageManager(deck)
|
||||||
|
pm.LoadPages(cfg.Pages)
|
||||||
|
web.SetPageManager(pm)
|
||||||
|
|
||||||
|
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
|
||||||
|
slog.Warn("activate default page", "error", err)
|
||||||
|
}
|
||||||
|
pm.startPeriodicKeys()
|
||||||
|
|
||||||
|
var windowCh <-chan Window
|
||||||
|
var detector WindowDetector
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if detector != nil {
|
||||||
|
detector.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if len(cfg.AutoSwitch) > 0 {
|
||||||
|
detector = NewWindowDetector()
|
||||||
|
windowCh, _ = detector.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
asm := NewAutoSwitchManager(cfg.AutoSwitch, cfg.DefaultPage)
|
||||||
|
|
||||||
|
ssCtrl := NewScreensaver(&cfg.Screensaver)
|
||||||
|
|
||||||
|
reconnectTicker := time.NewTicker(5 * time.Second)
|
||||||
|
defer reconnectTicker.Stop()
|
||||||
|
|
||||||
|
ssTicker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ssTicker.Stop()
|
||||||
|
|
||||||
|
configTicker := time.NewTicker(3 * time.Second)
|
||||||
|
defer configTicker.Stop()
|
||||||
|
var lastConfigMod time.Time
|
||||||
|
|
||||||
|
slog.Info("daemon started")
|
||||||
|
defer slog.Info("daemon stopped")
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case evt, ok := <-deck.Events():
|
||||||
|
if !ok {
|
||||||
|
slog.Warn("deck event channel closed, attempting reconnect...")
|
||||||
|
deck.Close()
|
||||||
|
deck = reconnectDeck(ctx, cfg, &pm, asm)
|
||||||
|
if deck == nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
deck.SetBrightness(deviceBrightness(cfg))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wasSsActive := ssCtrl.IsActive()
|
||||||
|
ssCtrl.NotifyInput()
|
||||||
|
|
||||||
|
if evt.Kind == EventKeyPressed {
|
||||||
|
if wasSsActive {
|
||||||
|
ssCtrl.Deactivate(deck)
|
||||||
|
|
||||||
|
savedOutputs := pm.GetDisplayOutputs()
|
||||||
|
|
||||||
|
if page := pm.ActivePage(); page != nil {
|
||||||
|
if err := pm.ActivatePage(pm.ActivePageName()); err != nil {
|
||||||
|
slog.Warn("screensaver: re-render page", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, output := range savedOutputs {
|
||||||
|
pm.ReRenderDisplayKey(idx, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page := pm.ActivePage()
|
||||||
|
if page == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, k := range page.Keys {
|
||||||
|
if k.Index == evt.Index && k.Action != nil {
|
||||||
|
slog.Debug("key pressed", "index", evt.Index, "action", k.Action.Type)
|
||||||
|
|
||||||
|
if k.Action.Type == "page" {
|
||||||
|
asm.NotifyManualPage(k.Action.Page)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(a *config.Action) {
|
||||||
|
if err := ExecuteAction(a, deck, pm); err != nil {
|
||||||
|
slog.Error("execute action", "error", err)
|
||||||
|
}
|
||||||
|
}(k.Action)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case win := <-windowCh:
|
||||||
|
if page, ok := asm.Evaluate(win, pm.ActivePageName()); ok {
|
||||||
|
if err := pm.ActivatePage(page); err != nil {
|
||||||
|
slog.Warn("auto-switch: activate page", "error", err)
|
||||||
|
}
|
||||||
|
pm.startPeriodicKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
deck.SetBrightness(deviceBrightness(cfg))
|
||||||
|
pm.stopPeriodicKeys()
|
||||||
|
pm.LoadPages(cfg.Pages)
|
||||||
|
asm.Reload(cfg.AutoSwitch)
|
||||||
|
if len(cfg.AutoSwitch) > 0 && detector == nil {
|
||||||
|
detector = NewWindowDetector()
|
||||||
|
windowCh, _ = detector.Start(ctx)
|
||||||
|
}
|
||||||
|
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
|
||||||
|
slog.Warn("reload: activate default page", "error", err)
|
||||||
|
}
|
||||||
|
pm.startPeriodicKeys()
|
||||||
|
}
|
||||||
|
if lastConfigMod.IsZero() {
|
||||||
|
lastConfigMod = mt
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-reconnectTicker.C:
|
||||||
|
if err := deck.SetBrightness(deck.Brightness()); err != nil {
|
||||||
|
slog.Warn("deck connection lost, reconnecting...", "error", err)
|
||||||
|
deck.Close()
|
||||||
|
deck = reconnectDeck(ctx, cfg, &pm, asm)
|
||||||
|
if deck == nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
deck.SetBrightness(deviceBrightness(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ssTicker.C:
|
||||||
|
if ssCtrl.Check() {
|
||||||
|
ssCtrl.Activate(deck, &cfg.Screensaver)
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkKeyboardTool() {
|
||||||
|
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||||
|
if _, err := exec.LookPath("wtype"); err != nil {
|
||||||
|
slog.Warn("keyboard actions require wtype on Wayland — install it (e.g. apk add wtype) 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) int {
|
||||||
|
if len(cfg.Devices) > 0 && cfg.Devices[0].Brightness > 0 {
|
||||||
|
return cfg.Devices[0].Brightness
|
||||||
|
}
|
||||||
|
return 75
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager, asm *AutoSwitchManager) *Deck {
|
||||||
|
for {
|
||||||
|
newDeck, err := OpenDeck("")
|
||||||
|
if err == nil {
|
||||||
|
*pm = NewPageManager(newDeck)
|
||||||
|
(*pm).LoadPages(cfg.Pages)
|
||||||
|
(*pm).ActivatePage(cfg.DefaultPage)
|
||||||
|
(*pm).startPeriodicKeys()
|
||||||
|
asm.NotifyManualPage(cfg.DefaultPage)
|
||||||
|
return newDeck
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
331
deck.go
Normal file
331
deck.go
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
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}:
|
||||||
|
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) 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) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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(len(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)
|
||||||
|
}
|
||||||
209
emoji.go
Normal file
209
emoji.go
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
138
fontawesome.go
Normal file
138
fontawesome.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
offX := (size - int(fontSize)) / 2
|
||||||
|
if offX < 0 {
|
||||||
|
offX = size / 8
|
||||||
|
}
|
||||||
|
baselineY := size * 7 / 10
|
||||||
|
|
||||||
|
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
fontawesome_data.go
Normal file
71
fontawesome_data.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
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:]
|
||||||
|
}
|
||||||
1979
fontawesome_icons.go
Normal file
1979
fontawesome_icons.go
Normal file
File diff suppressed because it is too large
Load Diff
16
go.mod
Normal file
16
go.mod
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
module streamdeck-lets-go
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bearsh/hid v1.6.0
|
||||||
|
github.com/dh1tw/streamdeck v1.0.0
|
||||||
|
github.com/disintegration/gift v1.2.1
|
||||||
|
golang.org/x/image v0.42.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
)
|
||||||
22
go.sum
Normal file
22
go.sum
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
github.com/bearsh/hid v1.6.0 h1:eOBSuF2pg+SCytKGuGjOzZx73xQ72gevJ5IlFvzgfGE=
|
||||||
|
github.com/bearsh/hid v1.6.0/go.mod h1:7JhM3r/tm4ALu4WWFqshda+Q6aIcnGRpUR08sx/dHdc=
|
||||||
|
github.com/dgottlieb/smarty-assertions v1.2.6 h1:YAXgSslRBbVtd54iTqM4yGT2k1a2qS6cffNQo0SDxDY=
|
||||||
|
github.com/dgottlieb/smarty-assertions v1.2.6/go.mod h1:x1wpV/RTxYWtN+vgrcRuCF4hjUmonK5NR59ZzQSym2k=
|
||||||
|
github.com/dh1tw/streamdeck v1.0.0 h1:YyK0g3BLuPQDXnNypwMBbDjbAUDdY073fYeTG5qPXH4=
|
||||||
|
github.com/dh1tw/streamdeck v1.0.0/go.mod h1:+EqLcYGV7m9jyRVSLTpQkl0BDmRTJEcOTSfcrf1FRzs=
|
||||||
|
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
|
||||||
|
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
go.viam.com/test v1.2.4 h1:JYgZhsuGAQ8sL9jWkziAXN9VJJiKbjoi9BsO33TW3ug=
|
||||||
|
go.viam.com/test v1.2.4/go.mod h1:zI2xzosHdqXAJ/kFqcN+OIF78kQuTV2nIhGZ8EzvaJI=
|
||||||
|
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||||
|
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
205
icons.go
Normal file
205
icons.go
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "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) (image.Image, error) {
|
||||||
|
cmd := exec.Command("rsvg-convert",
|
||||||
|
"-w", strconv.Itoa(targetSize),
|
||||||
|
"-h", strconv.Itoa(targetSize),
|
||||||
|
"-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)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
266
internal/config/config.go
Normal file
266
internal/config/config.go
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
LogLevel string `json:"log_level"`
|
||||||
|
DefaultPage string `json:"default_page"`
|
||||||
|
Devices []DeviceConfig `json:"devices"`
|
||||||
|
Pages []PageConfig `json:"pages"`
|
||||||
|
AutoSwitch []SwitchRule `json:"auto_switch"`
|
||||||
|
Screensaver ScreensaverCfg `json:"screensaver"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceConfig struct {
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
Brightness int `json:"brightness"`
|
||||||
|
Rotation int `json:"rotation,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageConfig struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Keys []KeyConfig `json:"keys"`
|
||||||
|
Background string `json:"background,omitempty"`
|
||||||
|
Screensaver *ScreensaverCfg `json:"screensaver,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
Action *Action `json:"action,omitempty"`
|
||||||
|
Display *DisplayCfg `json:"display,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
Builtin string `json:"builtin,omitempty"`
|
||||||
|
Script string `json:"script,omitempty"`
|
||||||
|
Page string `json:"page,omitempty"`
|
||||||
|
Background bool `json:"background,omitempty"`
|
||||||
|
Keys string `json:"keys,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisplayCfg struct {
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
Script string `json:"script,omitempty"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
MaxLen int `json:"max_len,omitempty"`
|
||||||
|
Timeout string `json:"timeout,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwitchRule struct {
|
||||||
|
WMClass string `json:"wm_class"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Page string `json:"page"`
|
||||||
|
Devices []string `json:"devices,omitempty"`
|
||||||
|
Stay bool `json:"stay,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScreensaverCfg struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IdleSeconds int `json:"idle_seconds,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Brightness int `json:"brightness,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Version: 1,
|
||||||
|
LogLevel: "info",
|
||||||
|
DefaultPage: "default",
|
||||||
|
Pages: []PageConfig{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Keys: []KeyConfig{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigPath(path string) string {
|
||||||
|
if path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "config.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "streamdeck-lets-go", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
path = ConfigPath(path)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
slog.Info("config not found, using defaults", "path", path)
|
||||||
|
return DefaultConfig(), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("reading config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Save(path string) error {
|
||||||
|
path = ConfigPath(path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating config dir: %w", err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(c, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("writing config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Version < 1 {
|
||||||
|
return fmt.Errorf("unsupported config version: %d", c.Version)
|
||||||
|
}
|
||||||
|
if c.DefaultPage == "" {
|
||||||
|
return fmt.Errorf("default_page is required")
|
||||||
|
}
|
||||||
|
pageNames := make(map[string]bool)
|
||||||
|
for _, p := range c.Pages {
|
||||||
|
if p.Name == "" {
|
||||||
|
return fmt.Errorf("page name is required")
|
||||||
|
}
|
||||||
|
if pageNames[p.Name] {
|
||||||
|
return fmt.Errorf("duplicate page name: %s", p.Name)
|
||||||
|
}
|
||||||
|
pageNames[p.Name] = true
|
||||||
|
|
||||||
|
seenKeys := make(map[int]bool)
|
||||||
|
maxKey := 31
|
||||||
|
for _, k := range p.Keys {
|
||||||
|
if k.Index < 0 {
|
||||||
|
return fmt.Errorf("page %s: key index must be >= 0", p.Name)
|
||||||
|
}
|
||||||
|
if k.Index > maxKey {
|
||||||
|
return fmt.Errorf("page %s: key index %d exceeds max %d", p.Name, k.Index, maxKey)
|
||||||
|
}
|
||||||
|
if seenKeys[k.Index] {
|
||||||
|
return fmt.Errorf("page %s: duplicate key index %d", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
seenKeys[k.Index] = true
|
||||||
|
|
||||||
|
if k.Action != nil {
|
||||||
|
switch k.Action.Type {
|
||||||
|
case "command":
|
||||||
|
if k.Action.Command == "" {
|
||||||
|
return fmt.Errorf("page %s key %d: command required for command action", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
case "builtin":
|
||||||
|
case "script":
|
||||||
|
if k.Action.Script == "" {
|
||||||
|
return fmt.Errorf("page %s key %d: script path required for script action", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
case "page":
|
||||||
|
if k.Action.Page == "" {
|
||||||
|
return fmt.Errorf("page %s key %d: page name required for page action", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
case "keyboard":
|
||||||
|
if k.Action.Keys == "" {
|
||||||
|
return fmt.Errorf("page %s key %d: keys required for keyboard action", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
case "":
|
||||||
|
return fmt.Errorf("page %s key %d: action type is required", p.Name, k.Index)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("page %s key %d: unknown action type: %s", p.Name, k.Index, k.Action.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if k.Display != nil {
|
||||||
|
if k.Display.Command == "" && k.Display.Script == "" {
|
||||||
|
return fmt.Errorf("page %s key %d: display requires command or script", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
if k.Display.Command != "" && k.Display.Script != "" {
|
||||||
|
return fmt.Errorf("page %s key %d: display command and script are mutually exclusive", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
if k.Display.Interval == "" {
|
||||||
|
return fmt.Errorf("page %s key %d: display interval is required", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
interval, err := time.ParseDuration(k.Display.Interval)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("page %s key %d: invalid display interval %q: %w", p.Name, k.Index, k.Display.Interval, err)
|
||||||
|
}
|
||||||
|
if interval < time.Second {
|
||||||
|
return fmt.Errorf("page %s key %d: display interval must be at least 1s", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
if interval > 24*time.Hour {
|
||||||
|
return fmt.Errorf("page %s key %d: display interval must not exceed 24h", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
if k.Display.Timeout != "" {
|
||||||
|
if _, err := time.ParseDuration(k.Display.Timeout); err != nil {
|
||||||
|
return fmt.Errorf("page %s key %d: invalid display timeout %q: %w", p.Name, k.Index, k.Display.Timeout, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if k.Display.MaxLen < 0 {
|
||||||
|
return fmt.Errorf("page %s key %d: max_len must be >= 0", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
if k.Display.MaxLen > 4096 {
|
||||||
|
return fmt.Errorf("page %s key %d: max_len must be <= 4096", p.Name, k.Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range c.Pages {
|
||||||
|
for _, k := range p.Keys {
|
||||||
|
if k.Action != nil && k.Action.Type == "page" {
|
||||||
|
if !pageNames[k.Action.Page] {
|
||||||
|
return fmt.Errorf("page %s key %d: references unknown page %q", p.Name, k.Index, k.Action.Page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sr := range c.AutoSwitch {
|
||||||
|
if sr.WMClass == "" && sr.Title == "" {
|
||||||
|
return fmt.Errorf("auto_switch rule: wm_class or title is required")
|
||||||
|
}
|
||||||
|
if !pageNames[sr.Page] {
|
||||||
|
return fmt.Errorf("auto_switch rule: references unknown page %q", sr.Page)
|
||||||
|
}
|
||||||
|
if _, err := regexp.Compile(sr.WMClass); sr.WMClass != "" && err != nil {
|
||||||
|
return fmt.Errorf("auto_switch rule: invalid wm_class regex %q: %w", sr.WMClass, err)
|
||||||
|
}
|
||||||
|
if _, err := regexp.Compile(sr.Title); sr.Title != "" && err != nil {
|
||||||
|
return fmt.Errorf("auto_switch rule: invalid title regex %q: %w", sr.Title, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !pageNames[c.DefaultPage] {
|
||||||
|
return fmt.Errorf("default_page %q not found in pages", c.DefaultPage)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
94
main.go
Normal file
94
main.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
|
||||||
|
"streamdeck-lets-go/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet("streamdeck-lets-go", flag.ExitOnError)
|
||||||
|
configPath := fs.String("config", "", "path to config.json (default: ~/.config/streamdeck-lets-go/config.json)")
|
||||||
|
httpAddr := fs.String("addr", ":9090", "web UI listen address")
|
||||||
|
noDeck := fs.Bool("no-deck", false, "run without Stream Deck hardware (config editing only)")
|
||||||
|
verbose := fs.Bool("v", false, "verbose output")
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go [flags] [command]\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Commands:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " daemon run the Stream Deck daemon with web UI (default)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " serve run web UI only (no deck hardware)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " discover list connected Stream Decks\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "\nFlags:\n")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := os.Args[1]
|
||||||
|
args := os.Args[2:]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "daemon":
|
||||||
|
fs.Parse(args)
|
||||||
|
case "serve":
|
||||||
|
fs.Parse(args)
|
||||||
|
*noDeck = true
|
||||||
|
case "discover":
|
||||||
|
discover()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
fs.Parse(os.Args[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
if *verbose {
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("config", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
httpEnabled := true
|
||||||
|
if err := Run(ctx, cfg, RunOptions{
|
||||||
|
ConfigPath: *configPath,
|
||||||
|
HTTPAddr: *httpAddr,
|
||||||
|
HTTPEnabled: httpEnabled,
|
||||||
|
NoDeck: *noDeck,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("run", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func discover() {
|
||||||
|
devices, err := EnumerateDevices()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("discover", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
fmt.Println("No Stream Deck devices found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Found %d Stream Deck(s):\n", len(devices))
|
||||||
|
for _, d := range devices {
|
||||||
|
fmt.Printf(" Serial: %s Model: %s PID: 0x%04x\n", d.Serial, d.Model, d.PID)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
media.go
Normal file
11
media.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
slog.Info("media module loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaAction is handled inline in action.go via playerctl/wpctl
|
||||||
781
page.go
Normal file
781
page.go
Normal file
@ -0,0 +1,781 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/disintegration/gift"
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/gofont/goregular"
|
||||||
|
"golang.org/x/image/font/opentype"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
|
|
||||||
|
"streamdeck-lets-go/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyState struct {
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
pages map[string]*config.PageConfig
|
||||||
|
active string
|
||||||
|
deck *Deck
|
||||||
|
keyStates map[int]*keyState
|
||||||
|
displayOutputs map[int]string
|
||||||
|
displayMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPageManager(deck *Deck) *PageManager {
|
||||||
|
return &PageManager{
|
||||||
|
pages: make(map[string]*config.PageConfig),
|
||||||
|
deck: deck,
|
||||||
|
keyStates: make(map[int]*keyState),
|
||||||
|
displayOutputs: make(map[int]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) LoadPages(pages []config.PageConfig) {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
pm.pages = make(map[string]*config.PageConfig, len(pages))
|
||||||
|
for i := range pages {
|
||||||
|
pm.pages[pages[i].Name] = &pages[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) ActivatePage(name string) error {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
page, ok := pm.pages[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("page %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("activating page", "name", name)
|
||||||
|
pm.active = name
|
||||||
|
|
||||||
|
pm.displayMu.Lock()
|
||||||
|
pm.displayOutputs = make(map[int]string)
|
||||||
|
pm.displayMu.Unlock()
|
||||||
|
|
||||||
|
if err := pm.deck.ClearAll(); err != nil {
|
||||||
|
slog.Warn("clear all failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if page.Background != "" {
|
||||||
|
if img, err := loadImage(page.Background, 0, 0); err == nil {
|
||||||
|
if err := pm.deck.FillPanel(img); err != nil {
|
||||||
|
slog.Warn("fill panel failed", "error", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Warn("load background image", "path", page.Background, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keyByIndex := make(map[int]config.KeyConfig, len(page.Keys))
|
||||||
|
for _, k := range page.Keys {
|
||||||
|
keyByIndex[k.Index] = k
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < pm.deck.NumKeys(); i++ {
|
||||||
|
k, ok := keyByIndex[i]
|
||||||
|
if !ok {
|
||||||
|
if page.Background == "" {
|
||||||
|
pm.deck.ClearKey(i)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pm.renderKey(i, &k)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) ActivePageName() string {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
return pm.active
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) ActivePage() *config.PageConfig {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
return pm.pages[pm.active]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) GetDisplayOutputs() map[int]string {
|
||||||
|
pm.displayMu.RLock()
|
||||||
|
defer pm.displayMu.RUnlock()
|
||||||
|
result := make(map[int]string, len(pm.displayOutputs))
|
||||||
|
for k, v := range pm.displayOutputs {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) GetPage(name string) *config.PageConfig {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
return pm.pages[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) RenderKey(keyIndex int) {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
page := pm.pages[pm.active]
|
||||||
|
if page == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, k := range page.Keys {
|
||||||
|
if k.Index == keyIndex {
|
||||||
|
pm.renderKey(keyIndex, &k)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
|
||||||
|
pm.mu.RLock()
|
||||||
|
page := pm.pages[pm.active]
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
if page == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, k := range page.Keys {
|
||||||
|
if k.Index == keyIndex && k.Display != nil {
|
||||||
|
pm.renderKeyOutput(keyIndex, k.Display, output, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
|
||||||
|
fontName := k.Font
|
||||||
|
if fontName == "" {
|
||||||
|
fontName = "medium"
|
||||||
|
}
|
||||||
|
fontSize := 18.0
|
||||||
|
if k.FontSize != nil {
|
||||||
|
fontSize = *k.FontSize
|
||||||
|
}
|
||||||
|
|
||||||
|
faScale := 0.55
|
||||||
|
if k.IconScale != nil {
|
||||||
|
faScale = *k.IconScale
|
||||||
|
}
|
||||||
|
|
||||||
|
if k.Icon != "" {
|
||||||
|
img, err := loadImage(k.Icon, pm.deck.KeySize(), faScale)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("load icon", "path", k.Icon, "error", err)
|
||||||
|
pm.deck.FillColor(idx, 64, 64, 64)
|
||||||
|
if k.Label != "" {
|
||||||
|
pm.deck.WriteText(idx, k.Label, image.Black, fontName, fontSize)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if k.Background != "" {
|
||||||
|
if bg, err := parseHexColor(k.Background); err == nil {
|
||||||
|
img = applyBackground(img, bg)
|
||||||
|
} else {
|
||||||
|
slog.Warn("invalid background color", "value", k.Background, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onImgFontSize := fontSize
|
||||||
|
if k.FontSize == nil {
|
||||||
|
onImgFontSize = 16
|
||||||
|
}
|
||||||
|
onImgFontName := fontName
|
||||||
|
if k.Font == "" {
|
||||||
|
onImgFontName = "regular"
|
||||||
|
}
|
||||||
|
|
||||||
|
if k.Label != "" {
|
||||||
|
if err := pm.deck.WriteTextOnImage(idx, img, k.Label, onImgFontName, onImgFontSize); err != nil {
|
||||||
|
slog.Warn("write text on image", "error", err)
|
||||||
|
pm.deck.FillImage(idx, img)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := pm.deck.FillImage(idx, img); err != nil {
|
||||||
|
slog.Warn("fill image", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if k.Label != "" {
|
||||||
|
if k.Background != "" {
|
||||||
|
bg, err := parseHexColor(k.Background)
|
||||||
|
if err == nil {
|
||||||
|
ks := pm.deck.KeySize()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, ks, ks))
|
||||||
|
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||||
|
if err := pm.deck.WriteTextOnImage(idx, img, k.Label, fontName, fontSize); err != nil {
|
||||||
|
slog.Warn("write text on image", "error", err)
|
||||||
|
pm.deck.FillImage(idx, img)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Warn("invalid background color", "value", k.Background, "error", err)
|
||||||
|
}
|
||||||
|
if err := pm.deck.WriteText(idx, k.Label, image.Black, fontName, fontSize); err != nil {
|
||||||
|
slog.Warn("write text", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) stopPeriodicKeys() {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
for _, ks := range pm.keyStates {
|
||||||
|
ks.cancel()
|
||||||
|
}
|
||||||
|
pm.keyStates = make(map[int]*keyState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) startPeriodicKeys() {
|
||||||
|
pm.mu.Lock()
|
||||||
|
page := pm.pages[pm.active]
|
||||||
|
if page == nil {
|
||||||
|
pm.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type displayKey struct {
|
||||||
|
index int
|
||||||
|
display *config.DisplayCfg
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
var keys []displayKey
|
||||||
|
for _, k := range page.Keys {
|
||||||
|
if k.Display != nil && k.Display.Interval != "" {
|
||||||
|
interval, err := time.ParseDuration(k.Display.Interval)
|
||||||
|
if err != nil || interval < time.Second {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := pm.keyStates[k.Index]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
pm.keyStates[k.Index] = &keyState{cancel: cancel}
|
||||||
|
keys = append(keys, displayKey{index: k.Index, display: k.Display, ctx: ctx})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pm.mu.Unlock()
|
||||||
|
|
||||||
|
for _, dk := range keys {
|
||||||
|
go pm.runDisplayKey(dk.index, dk.display, dk.ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) runDisplayKey(idx int, d *config.DisplayCfg, ctx context.Context) {
|
||||||
|
timeout := 30 * time.Second
|
||||||
|
if d.Timeout != "" {
|
||||||
|
if t, err := time.ParseDuration(d.Timeout); err == nil && t > 0 {
|
||||||
|
timeout = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interval, _ := time.ParseDuration(d.Interval)
|
||||||
|
|
||||||
|
output, execErr := execDisplayCapture(d, timeout)
|
||||||
|
pm.renderKeyOutput(idx, d, output, execErr)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
output, execErr := execDisplayCapture(d, timeout)
|
||||||
|
pm.renderKeyOutput(idx, d, output, execErr)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) {
|
||||||
|
pm.mu.RLock()
|
||||||
|
page := pm.pages[pm.active]
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
if page == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var kc *config.KeyConfig
|
||||||
|
for _, k := range page.Keys {
|
||||||
|
if k.Index == idx {
|
||||||
|
kCopy := k
|
||||||
|
kc = &kCopy
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if execErr != nil {
|
||||||
|
slog.Warn("display command failed", "key", idx, "error", execErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := sanitizeText(output)
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.displayMu.Lock()
|
||||||
|
pm.displayOutputs[idx] = text
|
||||||
|
pm.displayMu.Unlock()
|
||||||
|
|
||||||
|
maxLen := d.MaxLen
|
||||||
|
if maxLen <= 0 {
|
||||||
|
maxLen = 128
|
||||||
|
}
|
||||||
|
if len(text) > maxLen {
|
||||||
|
text = text[:maxLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
faScale := 0.55
|
||||||
|
if kc.IconScale != nil {
|
||||||
|
faScale = *kc.IconScale
|
||||||
|
}
|
||||||
|
|
||||||
|
fontSize := 12.0
|
||||||
|
if kc.FontSize != nil {
|
||||||
|
fontSize = *kc.FontSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if kc.Icon != "" {
|
||||||
|
img, err := loadImage(kc.Icon, pm.deck.KeySize(), faScale)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("load icon for display", "path", kc.Icon, "error", err)
|
||||||
|
textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), color.Black)
|
||||||
|
if err := pm.deck.FillImage(idx, textImg); err != nil {
|
||||||
|
slog.Warn("fill image", "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if kc.Background != "" {
|
||||||
|
if bg, err := parseHexColor(kc.Background); err == nil {
|
||||||
|
img = applyBackground(img, bg)
|
||||||
|
} else {
|
||||||
|
slog.Warn("invalid background color", "value", kc.Background, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composite := renderUnicodeTextOnImage(img, text, fontSize, pm.deck.KeySize())
|
||||||
|
if err := pm.deck.FillImage(idx, composite); err != nil {
|
||||||
|
slog.Warn("fill image", "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if kc.Background != "" {
|
||||||
|
bg, err := parseHexColor(kc.Background)
|
||||||
|
if err == nil {
|
||||||
|
textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), bg)
|
||||||
|
if err := pm.deck.FillImage(idx, textImg); err != nil {
|
||||||
|
slog.Warn("fill image", "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Warn("invalid background color", "value", kc.Background, "error", err)
|
||||||
|
}
|
||||||
|
textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), color.Black)
|
||||||
|
if err := pm.deck.FillImage(idx, textImg); err != nil {
|
||||||
|
slog.Warn("fill image", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
unicodeFontOnce sync.Once
|
||||||
|
unicodeFontData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadUnicodeFontBytes() []byte {
|
||||||
|
unicodeFontOnce.Do(func() {
|
||||||
|
if data := fcRead(":charset=20BD"); data != nil {
|
||||||
|
unicodeFontData = data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data := fcRead("mono"); data != nil {
|
||||||
|
unicodeFontData = data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data := fcRead("sans"); data != nil {
|
||||||
|
unicodeFontData = data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unicodeFontData = goregular.TTF
|
||||||
|
})
|
||||||
|
return unicodeFontData
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
emojiFontOnce sync.Once
|
||||||
|
emojiFontData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadEmojiFontBytes() []byte {
|
||||||
|
emojiFontOnce.Do(func() {
|
||||||
|
if data := fcRead("emoji"); data != nil {
|
||||||
|
if _, err := opentype.Parse(data); err == nil {
|
||||||
|
emojiFontData = data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emojiFontData = loadUnicodeFontBytes()
|
||||||
|
})
|
||||||
|
return emojiFontData
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderEmojiGlyph(r rune, size int, scale float64) image.Image {
|
||||||
|
if img, ok := renderCBDTGlyph(r, size, scale); ok {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
fontSize := float64(size) * scale
|
||||||
|
data := loadEmojiFontBytes()
|
||||||
|
fnt, err := opentype.Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return renderUnicodeText(string(r), fontSize, size, color.Black)
|
||||||
|
}
|
||||||
|
face, err := opentype.NewFace(fnt, &opentype.FaceOptions{
|
||||||
|
Size: fontSize,
|
||||||
|
DPI: 72,
|
||||||
|
Hinting: font.HintingFull,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return renderUnicodeText(string(r), fontSize, size, color.Black)
|
||||||
|
}
|
||||||
|
defer face.Close()
|
||||||
|
|
||||||
|
rgba := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||||
|
|
||||||
|
adv := font.MeasureString(face, string(r)).Ceil()
|
||||||
|
metrics := face.Metrics()
|
||||||
|
height := metrics.Height.Ceil()
|
||||||
|
|
||||||
|
x := (size - adv) / 2
|
||||||
|
if x < 1 {
|
||||||
|
x = 1
|
||||||
|
}
|
||||||
|
y := (size + height) / 2
|
||||||
|
if y < 1 {
|
||||||
|
y = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
d := &font.Drawer{
|
||||||
|
Dst: rgba,
|
||||||
|
Src: image.NewUniform(color.White),
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(string(r))
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
func fcRead(pattern string) []byte {
|
||||||
|
cmd := exec.Command("fc-match", "-f", "%{file}", pattern)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(string(out))
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDisplayFace(size float64) (font.Face, error) {
|
||||||
|
f, err := opentype.Parse(loadUnicodeFontBytes())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return opentype.NewFace(f, &opentype.FaceOptions{
|
||||||
|
Size: size,
|
||||||
|
DPI: 72,
|
||||||
|
Hinting: font.HintingFull,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderUnicodeText(text string, fontSize float64, keySize int, bg color.Color) *image.RGBA {
|
||||||
|
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
||||||
|
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
if text == "" {
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
face, err := parseDisplayFace(fontSize)
|
||||||
|
if err != nil {
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
defer face.Close()
|
||||||
|
|
||||||
|
textAdvance := font.MeasureString(face, text).Ceil()
|
||||||
|
metrics := face.Metrics()
|
||||||
|
height := metrics.Height.Ceil()
|
||||||
|
|
||||||
|
x := (keySize - textAdvance) / 2
|
||||||
|
if x < 1 {
|
||||||
|
x = 1
|
||||||
|
}
|
||||||
|
y := (keySize + height) / 2
|
||||||
|
if y < 1 {
|
||||||
|
y = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
d := &font.Drawer{
|
||||||
|
Dst: rgba,
|
||||||
|
Src: &image.Uniform{color.White},
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(text)
|
||||||
|
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64, keySize int) *image.RGBA {
|
||||||
|
g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
|
||||||
|
rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
||||||
|
g.Draw(rgba, baseImg)
|
||||||
|
|
||||||
|
barHeight := 20
|
||||||
|
if keySize < 72 {
|
||||||
|
barHeight = 16
|
||||||
|
}
|
||||||
|
barRect := image.Rect(0, keySize-barHeight, keySize, keySize)
|
||||||
|
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
|
||||||
|
|
||||||
|
if text == "" {
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
face, err := parseDisplayFace(fontSize)
|
||||||
|
if err != nil {
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
defer face.Close()
|
||||||
|
|
||||||
|
textAdvance := font.MeasureString(face, text).Ceil()
|
||||||
|
metrics := face.Metrics()
|
||||||
|
ascent := metrics.Ascent.Ceil()
|
||||||
|
|
||||||
|
x := (keySize - textAdvance) / 2
|
||||||
|
if x < 1 {
|
||||||
|
x = 1
|
||||||
|
}
|
||||||
|
y := keySize - barHeight/2 + ascent/2
|
||||||
|
if y > keySize-2 {
|
||||||
|
y = keySize - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
d := &font.Drawer{
|
||||||
|
Dst: rgba,
|
||||||
|
Src: &image.Uniform{color.White},
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(text)
|
||||||
|
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeText(s string) string {
|
||||||
|
text := strings.TrimSpace(s)
|
||||||
|
if text == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(text))
|
||||||
|
inEscape := false
|
||||||
|
for _, r := range text {
|
||||||
|
if inEscape {
|
||||||
|
if unicode.IsLetter(r) {
|
||||||
|
inEscape = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == 0x1b {
|
||||||
|
inEscape = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if unicode.IsPrint(r) || r == '\n' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHexColor(s string) (color.RGBA, error) {
|
||||||
|
if len(s) == 0 || s[0] != '#' {
|
||||||
|
return color.RGBA{}, fmt.Errorf("invalid color format: %q", s)
|
||||||
|
}
|
||||||
|
raw := strings.TrimPrefix(s, "#")
|
||||||
|
if len(raw) != 3 && len(raw) != 6 && len(raw) != 8 {
|
||||||
|
return color.RGBA{}, fmt.Errorf("invalid color length: %q", s)
|
||||||
|
}
|
||||||
|
if len(raw) == 3 {
|
||||||
|
raw = string([]byte{raw[0], raw[0], raw[1], raw[1], raw[2], raw[2]})
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseUint(raw, 16, 64)
|
||||||
|
if err != nil {
|
||||||
|
return color.RGBA{}, fmt.Errorf("invalid hex color: %q: %w", s, err)
|
||||||
|
}
|
||||||
|
switch len(raw) {
|
||||||
|
case 6:
|
||||||
|
return color.RGBA{
|
||||||
|
R: uint8(n >> 16),
|
||||||
|
G: uint8(n >> 8),
|
||||||
|
B: uint8(n),
|
||||||
|
A: 255,
|
||||||
|
}, nil
|
||||||
|
case 8:
|
||||||
|
return color.RGBA{
|
||||||
|
R: uint8(n >> 24),
|
||||||
|
G: uint8(n >> 16),
|
||||||
|
B: uint8(n >> 8),
|
||||||
|
A: uint8(n),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return color.RGBA{}, fmt.Errorf("unexpected hex length: %d", len(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBackground(img image.Image, bg color.RGBA) *image.RGBA {
|
||||||
|
b := img.Bounds()
|
||||||
|
rgba := image.NewRGBA(b)
|
||||||
|
draw.Draw(rgba, b, &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(rgba, b, img, image.Point{}, draw.Over)
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageCache sync.Map
|
||||||
|
|
||||||
|
func isEmojiRef(path string) bool {
|
||||||
|
return strings.HasPrefix(path, "emoji:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEmojiRef(path string) (rune, error) {
|
||||||
|
raw := strings.TrimPrefix(path, "emoji:")
|
||||||
|
if raw == "" {
|
||||||
|
return 0, fmt.Errorf("empty emoji ref")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(raw, ":") && strings.HasSuffix(raw, ":") && len(raw) > 2 {
|
||||||
|
name := strings.ToLower(raw[1 : len(raw)-1])
|
||||||
|
if r, ok := emojiShortcodes[name]; ok {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("unknown emoji shortcode: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, size := utf8.DecodeRuneInString(raw)
|
||||||
|
if r == utf8.RuneError || size == 0 {
|
||||||
|
return 0, fmt.Errorf("invalid emoji character")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImage(path string, targetSize int, faScale float64) (image.Image, error) {
|
||||||
|
if cached, ok := imageCache.Load(path); ok {
|
||||||
|
return cached.(image.Image), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isFAIconRef(path) {
|
||||||
|
style, name, err := parseFAIcon(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
img, err := renderFAGlyph(style, name, targetSize, faScale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
imageCache.Store(path, img)
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEmojiRef(path) {
|
||||||
|
r, err := parseEmojiRef(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
img := renderEmojiGlyph(r, targetSize, faScale)
|
||||||
|
imageCache.Store(path, img)
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := path
|
||||||
|
if isSystemIconRef(path) {
|
||||||
|
p, err := findSystemIcon(systemIconName(path), targetSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolved = p
|
||||||
|
} else if !strings.HasPrefix(path, "/") {
|
||||||
|
resolved = filepath.Join(configDir(), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(resolved, ".svg") {
|
||||||
|
img, err := svgToPNG(resolved, targetSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
imageCache.Store(path, img)
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
img, _, err := image.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
imageCache.Store(path, img)
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
132
render.go
Normal file
132
render.go
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
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) 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 != "" {
|
||||||
|
return composeImageWithLabel(img, k.Label, keySize)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return blankImage(keySize, bg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return renderTextImage(k.Label, keySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) 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)
|
||||||
|
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
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) image.Image {
|
||||||
|
rgba := blankImage(keySize, color.RGBA{0, 0, 0, 255}).(*image.RGBA)
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
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
|
||||||
|
}
|
||||||
92
screensaver.go
Normal file
92
screensaver.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"streamdeck-lets-go/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
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, 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 := 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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
557
static/app.js
Normal file
557
static/app.js
Normal file
@ -0,0 +1,557 @@
|
|||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('deckApp', () => ({
|
||||||
|
// ── State ──
|
||||||
|
config: null,
|
||||||
|
pages: [],
|
||||||
|
activePage: '',
|
||||||
|
view: 'grid',
|
||||||
|
showAdvanced: false,
|
||||||
|
showDisplay: false,
|
||||||
|
toast: null,
|
||||||
|
displayOutputs: {},
|
||||||
|
isCapturingKey: false,
|
||||||
|
|
||||||
|
// ── Edit state ──
|
||||||
|
editing: null,
|
||||||
|
editForm: {},
|
||||||
|
|
||||||
|
// ── Sub-views ──
|
||||||
|
subView: null, // 'backups', 'settings', 'switcher', null
|
||||||
|
|
||||||
|
// ── Settings ──
|
||||||
|
settingsForm: {},
|
||||||
|
autoSwitchRules: [],
|
||||||
|
|
||||||
|
// ── Init ──
|
||||||
|
async init() {
|
||||||
|
await this.loadConfig()
|
||||||
|
setInterval(() => this.pollDisplayOutputs(), 3000)
|
||||||
|
},
|
||||||
|
|
||||||
|
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
|
||||||
|
if (this.pages.length > 0) {
|
||||||
|
this.activePage = this.config.default_page || this.pages[0].name
|
||||||
|
}
|
||||||
|
this.syncSettings()
|
||||||
|
} catch (e) {
|
||||||
|
this.showToast('Failed to load config', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncSettings() {
|
||||||
|
this.settingsForm = {
|
||||||
|
brightness: this.config.devices?.[0]?.brightness || 75,
|
||||||
|
screensaver_enabled: this.config.screensaver?.enabled || false,
|
||||||
|
screensaver_idle: this.config.screensaver?.idle_seconds || 30,
|
||||||
|
screensaver_brightness: this.config.screensaver?.brightness || 10,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Grid ──
|
||||||
|
get gridKeys() {
|
||||||
|
const page = this.currentPage
|
||||||
|
if (!page) return []
|
||||||
|
const keys = []
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const kc = page.keys.find(k => k.index === i)
|
||||||
|
keys.push({
|
||||||
|
index: i,
|
||||||
|
...kc,
|
||||||
|
configured: !!kc,
|
||||||
|
hasDisplay: !!(kc?.display),
|
||||||
|
previewUrl: this.keyPreviewUrl(kc ? kc : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
},
|
||||||
|
|
||||||
|
keyPreviewUrl(kc) {
|
||||||
|
const k = kc || {}
|
||||||
|
let url = '/api/render?key_size=72'
|
||||||
|
if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}`
|
||||||
|
const label = this.displayOutputs[k.index] || 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}`
|
||||||
|
if (k.background) url += `&background=${encodeURIComponent(k.background)}`
|
||||||
|
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 ''
|
||||||
|
},
|
||||||
|
|
||||||
|
get keyChips() {
|
||||||
|
if (!this.editForm.keys) return []
|
||||||
|
const map = { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', super: 'Super', meta: 'Super' }
|
||||||
|
return this.editForm.keys.split('+').map(k => map[k] || k.charAt(0).toUpperCase() + k.slice(1))
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMod(mod) {
|
||||||
|
if (mod === 'ctrl') this.editForm.modCtrl = !this.editForm.modCtrl
|
||||||
|
else if (mod === 'alt') this.editForm.modAlt = !this.editForm.modAlt
|
||||||
|
else if (mod === 'shift') this.editForm.modShift = !this.editForm.modShift
|
||||||
|
else if (mod === 'super') this.editForm.modSuper = !this.editForm.modSuper
|
||||||
|
this.rebuildKeys()
|
||||||
|
},
|
||||||
|
|
||||||
|
rebuildKeys() {
|
||||||
|
const mods = []
|
||||||
|
if (this.editForm.modCtrl) mods.push('ctrl')
|
||||||
|
if (this.editForm.modAlt) mods.push('alt')
|
||||||
|
if (this.editForm.modShift) mods.push('shift')
|
||||||
|
if (this.editForm.modSuper) mods.push('super')
|
||||||
|
if (this.editForm.mainKey) mods.push(this.editForm.mainKey)
|
||||||
|
this.editForm.keys = mods.join('+')
|
||||||
|
},
|
||||||
|
|
||||||
|
startKeyCapture() {
|
||||||
|
this.isCapturingKey = true
|
||||||
|
this.$nextTick(() => this.$refs.keyCapture?.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
|
||||||
|
this.editForm.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 ──
|
||||||
|
editKey(idx) {
|
||||||
|
this.isCapturingKey = false
|
||||||
|
const page = this.currentPage
|
||||||
|
if (!page) return
|
||||||
|
const kc = page.keys.find(k => k.index === idx)
|
||||||
|
this.editing = idx
|
||||||
|
this.showAdvanced = false
|
||||||
|
this.showDisplay = !!(kc?.display)
|
||||||
|
|
||||||
|
const action = kc?.action || {}
|
||||||
|
|
||||||
|
const parts = (action.keys || '').toLowerCase().split('+')
|
||||||
|
const mainKey = parts.length > 0 ? parts.pop() : ''
|
||||||
|
|
||||||
|
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 || '',
|
||||||
|
action_type: action.type || 'command',
|
||||||
|
command: action.command || '',
|
||||||
|
builtin: action.builtin || '',
|
||||||
|
script: action.script || '',
|
||||||
|
page: action.page || '',
|
||||||
|
keys: action.keys || '',
|
||||||
|
modCtrl: parts.includes('ctrl'),
|
||||||
|
modAlt: parts.includes('alt'),
|
||||||
|
modShift: parts.includes('shift'),
|
||||||
|
modSuper: parts.includes('super'),
|
||||||
|
mainKey: mainKey,
|
||||||
|
background: action.background ?? true,
|
||||||
|
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'
|
||||||
|
const actionType = this.editForm.action_type
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType && actionType !== 'none') {
|
||||||
|
if (actionType === 'command' && !this.editForm.command) {
|
||||||
|
if (!hasDisplay) { this.showToast('Command is required', 'error'); return }
|
||||||
|
}
|
||||||
|
if (actionType === 'script' && !this.editForm.script) {
|
||||||
|
if (!hasDisplay) { this.showToast('Script path is required', 'error'); return }
|
||||||
|
}
|
||||||
|
if (actionType === 'page' && !this.editForm.page) {
|
||||||
|
this.showToast('Target page is required', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (actionType === 'keyboard' && !this.editForm.keys) {
|
||||||
|
if (!hasDisplay) { this.showToast('Key combination is required', 'error'); return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kc = {
|
||||||
|
index: this.editing,
|
||||||
|
icon: this.editForm.icon,
|
||||||
|
label: this.editForm.label,
|
||||||
|
icon_scale: this.editForm.icon_scale,
|
||||||
|
font_size: this.editForm.font_size,
|
||||||
|
background: this.editForm.bg_color || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType && actionType !== 'none') {
|
||||||
|
if (actionType === 'command' && this.editForm.command) {
|
||||||
|
kc.action = { type: 'command', command: this.editForm.command, background: this.editForm.background }
|
||||||
|
} else if (actionType === 'builtin') {
|
||||||
|
kc.action = { type: 'builtin', builtin: this.editForm.builtin }
|
||||||
|
} else if (actionType === 'script' && this.editForm.script) {
|
||||||
|
kc.action = { type: 'script', script: this.editForm.script }
|
||||||
|
} else if (actionType === 'page' && this.editForm.page) {
|
||||||
|
kc.action = { type: 'page', page: this.editForm.page }
|
||||||
|
} else if (actionType === 'keyboard' && this.editForm.keys) {
|
||||||
|
kc.action = { type: 'keyboard', keys: this.editForm.keys }
|
||||||
|
} else if (!hasDisplay) {
|
||||||
|
kc.action = null
|
||||||
|
} else {
|
||||||
|
kc.action = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
kc.action = null
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
kc.display = null
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
this.displayOutputs = {}
|
||||||
|
},
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update page references
|
||||||
|
for (const p of this.pages) {
|
||||||
|
for (const k of p.keys) {
|
||||||
|
if (k.action?.page === oldName) k.action.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.length === 0) {
|
||||||
|
this.config.devices = [{ serial: '', brightness: 75 }]
|
||||||
|
}
|
||||||
|
this.config.devices[0].brightness = parseInt(this.settingsForm.brightness)
|
||||||
|
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.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) {
|
||||||
|
// Extract date from config.YYYY-MM-DDTHH-MM-SS.json
|
||||||
|
const m = name.match(/config\.(.+)\.json/)
|
||||||
|
if (!m) return name
|
||||||
|
return m[1].replace('T', ' ')
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
440
static/index.html
Normal file
440
static/index.html
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
<!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="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/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="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-grid">
|
||||||
|
<template x-for="key in gridKeys" :key="key.index">
|
||||||
|
<button class="key-btn"
|
||||||
|
:class="{ empty: !key.configured }"
|
||||||
|
@click="editKey(key.index)">
|
||||||
|
<img x-show="key.configured && key.background"
|
||||||
|
:src="key.previewUrl" alt="">
|
||||||
|
<i x-show="key.configured && !key.background && isFAIcon(key.icon)"
|
||||||
|
:class="faClass(key.icon)" class="fa-preview"></i>
|
||||||
|
<img x-show="key.configured && !key.background && !isFAIcon(key.icon) && key.icon"
|
||||||
|
:src="key.previewUrl" alt="">
|
||||||
|
<span x-show="key.configured && !key.background && !key.icon && key.label"
|
||||||
|
style="font-size:12px;color:#fff;opacity:0.75;text-align:center;line-height:1.2;padding:4px;"
|
||||||
|
x-text="key.label"></span>
|
||||||
|
<span x-show="!key.configured" style="color:var(--text-muted);font-size:24px;opacity:0.5;">+</span>
|
||||||
|
<i x-show="key.action?.type === 'keyboard'" class="fas fa-keyboard display-indicator" title="Keyboard shortcut" style="left:4px;right:auto;"></i>
|
||||||
|
<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 style="float:right;color:var(--text-muted);font-size:11px;"
|
||||||
|
x-text="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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Advanced toggle -->
|
||||||
|
<button class="collapse-toggle" :class="{ open: showAdvanced }" @click="showAdvanced = !showAdvanced">
|
||||||
|
<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-model="editForm.bg_color"
|
||||||
|
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.background = ''" title="Clear"
|
||||||
|
x-show="editForm.background !== ''">
|
||||||
|
<i class="fas fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Action type</label>
|
||||||
|
<select x-model="editForm.action_type">
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="command">Command</option>
|
||||||
|
<option value="builtin">Builtin</option>
|
||||||
|
<option value="script">Script</option>
|
||||||
|
<option value="page">Page</option>
|
||||||
|
<option value="keyboard">Keyboard shortcut</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Command fields -->
|
||||||
|
<template x-if="editForm.action_type === 'command'">
|
||||||
|
<div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Command</label>
|
||||||
|
<input type="text" x-model="editForm.command" placeholder="firefox">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="bg-cb" x-model="editForm.bg_color">
|
||||||
|
<label for="bg-cb">Run in background</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="editForm.action_type === 'builtin'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Builtin action</label>
|
||||||
|
<select x-model="editForm.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.action_type === 'script'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Script path</label>
|
||||||
|
<input type="text" x-model="editForm.script" placeholder="/path/to/script.sh">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="editForm.action_type === 'page'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Target page</label>
|
||||||
|
<select x-model="editForm.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>
|
||||||
|
|
||||||
|
<template x-if="editForm.action_type === 'keyboard'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Key combination</label>
|
||||||
|
<div class="mod-row">
|
||||||
|
<button class="mod-btn" :class="{ active: editForm.modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
|
||||||
|
<button class="mod-btn" :class="{ active: editForm.modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
|
||||||
|
<button class="mod-btn" :class="{ active: editForm.modShift }" @click="toggleMod('shift')" type="button">Shift</button>
|
||||||
|
<button class="mod-btn" :class="{ active: editForm.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.mainKey" class="captured-key" x-text="displayKey(editForm.mainKey)"></span>
|
||||||
|
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
|
||||||
|
<span x-show="!isCapturingKey && !editForm.mainKey" class="capture-placeholder">Click to capture</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-chips" x-show="editForm.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>
|
||||||
|
|
||||||
|
<!-- Display section -->
|
||||||
|
<button class="collapse-toggle" :class="{ open: showDisplay }" @click="showDisplay = !showDisplay" style="margin-top:8px;">
|
||||||
|
<i class="fas fa-chevron-right"></i> Display (periodic output)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div x-show="showDisplay" x-transition>
|
||||||
|
<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>Shell 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>
|
||||||
697
static/styles.css
Normal file
697
static/styles.css
Normal file
@ -0,0 +1,697 @@
|
|||||||
|
: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;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-btn.empty {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Display indicator on grid keys ── */
|
||||||
|
.display-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 7px;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
}
|
||||||
529
web.go
Normal file
529
web.go
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
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})
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user