A key with `folder: NAME` swaps the deck to the keys defined under `folders.NAME`. Every folder shows a back key (slot 0 by default, drawn with a built-in chevron) that pops back to the parent; folders nest. The daemon now runs one page at a time: per-key setup moved into setupKey(), openPage() draws a page under its own context, and page.stop() tears it down before the next page is drawn (and on exit, so device death no longer waits on uncancelled goroutines). streamdeck-ctl gains `key set -folder`, reports kind "folder", lists every folder in status, and refuses to `key press` a navigation-only key. The Omarchy grid shows a folder glyph. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ARLa4moGb154fzc9TevgV
444 lines
13 KiB
Go
444 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.i0t.app/lwoodard/streamdeck-go/internal/config"
|
|
"git.i0t.app/lwoodard/streamdeck-go/internal/device"
|
|
"git.i0t.app/lwoodard/streamdeck-go/internal/modules"
|
|
)
|
|
|
|
const daemonUnit = "streamdeck-go.service"
|
|
|
|
// Status is the full snapshot consumed by front-ends. Everything a panel needs
|
|
// to render in one call — polling several small commands from QML would be both
|
|
// slower and racier.
|
|
type Status struct {
|
|
OK bool `json:"ok"`
|
|
ConfigPath string `json:"configPath"`
|
|
ModulesPath string `json:"modulesPath"`
|
|
IconsDir string `json:"iconsDir"`
|
|
Brightness int `json:"brightness"`
|
|
ConfigError string `json:"configError,omitempty"`
|
|
Daemon DaemonStatus `json:"daemon"`
|
|
Device DeviceStatus `json:"device"`
|
|
Keys []KeyStatus `json:"keys"`
|
|
Folders []FolderStatus `json:"folders"`
|
|
Icons []string `json:"icons"`
|
|
Modules []ModuleStatus `json:"modules"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
}
|
|
|
|
type DaemonStatus struct {
|
|
Unit string `json:"unit"`
|
|
Active bool `json:"active"`
|
|
Enabled bool `json:"enabled"`
|
|
State string `json:"state"` // systemd ActiveState, e.g. "active", "failed"
|
|
Sub string `json:"sub"` // systemd SubState, e.g. "running"
|
|
SinceSec int64 `json:"sinceSec"` // seconds since it entered the active state, 0 if unknown
|
|
}
|
|
|
|
type DeviceStatus struct {
|
|
Connected bool `json:"connected"`
|
|
Known bool `json:"known"` // product ID is in the supported-models table
|
|
Model string `json:"model"`
|
|
VendorID string `json:"vendorId"`
|
|
ProductID string `json:"productId"`
|
|
KeyCount int `json:"keyCount"`
|
|
Cols int `json:"cols"`
|
|
Rows int `json:"rows"`
|
|
ImageWidth int `json:"imageWidth"`
|
|
ImageHeight int `json:"imageHeight"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// KeyStatus describes one configured key. Only populated slots appear; the grid
|
|
// shape comes from Device.Cols/Rows so a front-end can lay out empty slots.
|
|
type KeyStatus struct {
|
|
Index int `json:"index"`
|
|
Kind string `json:"kind"` // empty|static|text|toggle|module|folder
|
|
Label string `json:"label"` // short human label for a grid cell
|
|
Icon string `json:"icon,omitempty"`
|
|
IconPath string `json:"iconPath,omitempty"` // absolute, for previews
|
|
IconTrue string `json:"iconTrue,omitempty"`
|
|
IconFalse string `json:"iconFalse,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
TextColor string `json:"textColor,omitempty"`
|
|
TextCommand string `json:"textCommand,omitempty"`
|
|
Refresh string `json:"refresh,omitempty"`
|
|
Command string `json:"command,omitempty"`
|
|
Module string `json:"module,omitempty"`
|
|
Function string `json:"function,omitempty"`
|
|
Params map[string]string `json:"params,omitempty"`
|
|
Privileged bool `json:"privileged"`
|
|
Poll *PollStatus `json:"poll,omitempty"`
|
|
Folder string `json:"folder,omitempty"` // folder the key opens on press
|
|
}
|
|
|
|
// FolderStatus is one sub-page from the folders: block, with the same key
|
|
// shape as the root page so a front-end can render it with the same grid.
|
|
type FolderStatus struct {
|
|
Name string `json:"name"`
|
|
BackKey int `json:"backKey"`
|
|
BackIcon string `json:"backIcon,omitempty"`
|
|
BackText string `json:"backText,omitempty"`
|
|
Keys []KeyStatus `json:"keys"`
|
|
}
|
|
|
|
type PollStatus struct {
|
|
Command string `json:"command,omitempty"`
|
|
Match string `json:"match,omitempty"`
|
|
Interval string `json:"interval,omitempty"`
|
|
Module string `json:"module,omitempty"`
|
|
Function string `json:"function,omitempty"`
|
|
}
|
|
|
|
type ModuleStatus struct {
|
|
Name string `json:"name"`
|
|
Functions []FunctionStatus `json:"functions"`
|
|
}
|
|
|
|
type FunctionStatus struct {
|
|
Name string `json:"name"`
|
|
Params map[string]string `json:"params,omitempty"`
|
|
}
|
|
|
|
func cmdStatus(cfgPath string) error {
|
|
status := Status{
|
|
OK: true,
|
|
ConfigPath: cfgPath,
|
|
ModulesPath: config.ModulesPath(cfgPath),
|
|
Keys: []KeyStatus{},
|
|
Folders: []FolderStatus{},
|
|
Icons: []string{},
|
|
Modules: []ModuleStatus{},
|
|
}
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
// A missing or malformed config is worth reporting in the panel rather
|
|
// than failing the whole call — daemon and device state are still useful.
|
|
status.ConfigError = err.Error()
|
|
status.Daemon = daemonState()
|
|
status.Device = deviceState(device.VendorID, 0x00ba)
|
|
emit(status)
|
|
return nil
|
|
}
|
|
|
|
status.IconsDir = cfg.IconsDir
|
|
status.Brightness = cfg.Brightness
|
|
status.Daemon = daemonState()
|
|
status.Device = deviceState(cfg.Device.VendorID, cfg.Device.ProductID)
|
|
|
|
reg, err := modules.LoadRegistry(status.ModulesPath)
|
|
if err != nil {
|
|
status.Warnings = append(status.Warnings, fmt.Sprintf("modules.yaml: %v", err))
|
|
reg = &modules.Registry{}
|
|
}
|
|
status.Modules = moduleList(reg)
|
|
status.Keys = keyList(cfg, reg)
|
|
status.Folders = folderList(cfg, reg)
|
|
status.Icons = iconList(cfg.IconsDir)
|
|
|
|
emit(status)
|
|
return nil
|
|
}
|
|
|
|
func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
|
|
return keyEntries(cfg.Keys, cfg.IconsDir, reg)
|
|
}
|
|
|
|
// folderList renders every folder in name order.
|
|
func folderList(cfg *config.Config, reg *modules.Registry) []FolderStatus {
|
|
names := make([]string, 0, len(cfg.Folders))
|
|
for name := range cfg.Folders {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
out := make([]FolderStatus, 0, len(names))
|
|
for _, name := range names {
|
|
f := cfg.Folders[name]
|
|
out = append(out, FolderStatus{
|
|
Name: name,
|
|
BackKey: f.BackKey(),
|
|
BackIcon: f.Back.Icon,
|
|
BackText: f.Back.Text,
|
|
Keys: keyEntries(f.Keys, cfg.IconsDir, reg),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func keyEntries(keys map[int]config.KeyConfig, iconsDir string, reg *modules.Registry) []KeyStatus {
|
|
indices := make([]int, 0, len(keys))
|
|
for index := range keys {
|
|
indices = append(indices, index)
|
|
}
|
|
sort.Ints(indices)
|
|
|
|
out := make([]KeyStatus, 0, len(indices))
|
|
for _, index := range indices {
|
|
key := keys[index]
|
|
entry := KeyStatus{
|
|
Index: index,
|
|
Icon: key.Icon,
|
|
IconTrue: key.IconTrue,
|
|
IconFalse: key.IconFalse,
|
|
Text: key.Text,
|
|
TextColor: key.TextColor,
|
|
TextCommand: key.TextCommand,
|
|
Refresh: key.Refresh,
|
|
Command: key.Command,
|
|
Module: key.Module,
|
|
Function: key.Function,
|
|
Params: key.Params,
|
|
Folder: key.Folder,
|
|
}
|
|
|
|
if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" {
|
|
entry.IconPath = resolveIcon(iconsDir, icon)
|
|
}
|
|
|
|
// The rendered template can inline secrets ({{env "TOKEN"}}), so it never
|
|
// leaves this process — it is only inspected for the priv: prefix.
|
|
entry.Privileged = strings.HasPrefix(entry.Command, "priv:")
|
|
if !entry.Privileged && key.Module != "" && key.Function != "" {
|
|
if resolved, err := reg.Resolve(key.Module, key.Function, key.Params); err == nil {
|
|
entry.Privileged = strings.HasPrefix(resolved, "priv:")
|
|
}
|
|
}
|
|
|
|
if key.Poll != nil {
|
|
entry.Poll = &PollStatus{
|
|
Command: key.Poll.Command,
|
|
Match: key.Poll.Match,
|
|
Interval: key.Poll.Interval,
|
|
Module: key.Poll.Module,
|
|
Function: key.Poll.Function,
|
|
}
|
|
}
|
|
|
|
entry.Kind = keyKind(key)
|
|
entry.Label = keyLabel(key)
|
|
out = append(out, entry)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func keyKind(key config.KeyConfig) string {
|
|
switch {
|
|
case key.Folder != "":
|
|
return "folder"
|
|
case key.Poll != nil:
|
|
return "toggle"
|
|
case key.Module != "":
|
|
return "module"
|
|
case key.Icon == "" && (key.Text != "" || key.TextCommand != ""):
|
|
return "text"
|
|
case key.Icon != "":
|
|
return "static"
|
|
default:
|
|
return "empty"
|
|
}
|
|
}
|
|
|
|
// keyLabel produces a short label for a grid cell: whatever identifies the key
|
|
// most directly to the person who wrote it.
|
|
func keyLabel(key config.KeyConfig) string {
|
|
if key.Text != "" {
|
|
if line, _, _ := strings.Cut(key.Text, "\n"); strings.TrimSpace(line) != "" {
|
|
return strings.TrimSpace(line)
|
|
}
|
|
}
|
|
if key.Folder != "" {
|
|
return key.Folder
|
|
}
|
|
if key.Function != "" {
|
|
return strings.ReplaceAll(key.Function, "_", " ")
|
|
}
|
|
if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" {
|
|
base := filepath.Base(icon)
|
|
return strings.TrimSuffix(base, filepath.Ext(base))
|
|
}
|
|
if key.Command != "" {
|
|
command := strings.TrimPrefix(key.Command, "priv:")
|
|
if field := strings.Fields(command); len(field) > 0 {
|
|
return filepath.Base(field[0])
|
|
}
|
|
}
|
|
if key.TextCommand != "" {
|
|
return "live text"
|
|
}
|
|
return "key"
|
|
}
|
|
|
|
func moduleList(reg *modules.Registry) []ModuleStatus {
|
|
if reg == nil || reg.Modules == nil {
|
|
return []ModuleStatus{}
|
|
}
|
|
names := make([]string, 0, len(reg.Modules))
|
|
for name := range reg.Modules {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
out := make([]ModuleStatus, 0, len(names))
|
|
for _, name := range names {
|
|
def := reg.Modules[name]
|
|
fnNames := make([]string, 0, len(def))
|
|
for fnName := range def {
|
|
fnNames = append(fnNames, fnName)
|
|
}
|
|
sort.Strings(fnNames)
|
|
|
|
functions := make([]FunctionStatus, 0, len(fnNames))
|
|
for _, fnName := range fnNames {
|
|
functions = append(functions, FunctionStatus{Name: fnName, Params: def[fnName].Params})
|
|
}
|
|
out = append(out, ModuleStatus{Name: name, Functions: functions})
|
|
}
|
|
return out
|
|
}
|
|
|
|
var iconExtensions = map[string]bool{
|
|
".png": true, ".jpg": true, ".jpeg": true, ".svg": true, ".gif": true,
|
|
}
|
|
|
|
func iconList(dir string) []string {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return []string{}
|
|
}
|
|
out := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
if iconExtensions[strings.ToLower(filepath.Ext(entry.Name()))] {
|
|
out = append(out, entry.Name())
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// resolveIcon mirrors the daemon's lookup: names are relative to icons_dir
|
|
// unless they are already absolute.
|
|
func resolveIcon(iconsDir, name string) string {
|
|
if filepath.IsAbs(name) {
|
|
return name
|
|
}
|
|
return filepath.Join(iconsDir, name)
|
|
}
|
|
|
|
func deviceState(vendorID, productID uint16) DeviceStatus {
|
|
status := DeviceStatus{
|
|
VendorID: fmt.Sprintf("0x%04x", vendorID),
|
|
ProductID: fmt.Sprintf("0x%04x", productID),
|
|
}
|
|
|
|
if model, ok := device.Lookup(productID); ok {
|
|
status.Known = true
|
|
status.Model = model.Name
|
|
status.KeyCount = model.KeyCount
|
|
status.Cols = model.Cols
|
|
status.Rows = model.Rows
|
|
status.ImageWidth = model.ImageWidth
|
|
status.ImageHeight = model.ImageHeight
|
|
} else {
|
|
status.Model = "Unknown model"
|
|
}
|
|
|
|
present, err := device.Present(vendorID, productID)
|
|
if err != nil {
|
|
status.Error = err.Error()
|
|
return status
|
|
}
|
|
status.Connected = present
|
|
return status
|
|
}
|
|
|
|
func daemonState() DaemonStatus {
|
|
status := DaemonStatus{Unit: daemonUnit}
|
|
|
|
if runtime.GOOS == "darwin" {
|
|
// launchd has no equivalent of ActiveState; `launchctl list` printing the
|
|
// label at all means it is loaded, and a PID in column one means running.
|
|
label := "com.woodarddigital.streamdeck-go"
|
|
out, err := exec.Command("launchctl", "list").Output()
|
|
if err != nil {
|
|
status.State = "unknown"
|
|
return status
|
|
}
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
if !strings.HasSuffix(strings.TrimSpace(line), label) {
|
|
continue
|
|
}
|
|
status.Enabled = true
|
|
fields := strings.Fields(line)
|
|
if len(fields) > 0 && fields[0] != "-" {
|
|
status.Active = true
|
|
status.State = "active"
|
|
status.Sub = "running"
|
|
} else {
|
|
status.State = "inactive"
|
|
}
|
|
return status
|
|
}
|
|
status.State = "inactive"
|
|
return status
|
|
}
|
|
|
|
out, err := exec.Command("systemctl", "--user", "show", daemonUnit,
|
|
"-p", "ActiveState", "-p", "SubState", "-p", "UnitFileState",
|
|
"-p", "ActiveEnterTimestamp").Output()
|
|
if err != nil {
|
|
status.State = "unknown"
|
|
return status
|
|
}
|
|
|
|
fields := map[string]string{}
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
name, value, found := strings.Cut(strings.TrimSpace(line), "=")
|
|
if found {
|
|
fields[name] = value
|
|
}
|
|
}
|
|
|
|
status.State = fields["ActiveState"]
|
|
status.Sub = fields["SubState"]
|
|
status.Active = status.State == "active"
|
|
status.Enabled = strings.HasPrefix(fields["UnitFileState"], "enabled")
|
|
|
|
// Realtime rather than the *Monotonic pair: systemd's monotonic clock
|
|
// excludes suspend while /proc/uptime includes it, so mixing them overstated
|
|
// uptime by the suspended duration after every sleep. The timestamp is
|
|
// rendered in the local zone ("Sat 2026-08-16 10:26:11 MDT"), where the
|
|
// abbreviation parses unambiguously.
|
|
if ts := fields["ActiveEnterTimestamp"]; ts != "" && ts != "n/a" {
|
|
if entered, err := time.ParseInLocation("Mon 2006-01-02 15:04:05 MST", ts, time.Local); err == nil {
|
|
if since := time.Since(entered); since > 0 {
|
|
status.SinceSec = int64(since.Seconds())
|
|
}
|
|
}
|
|
}
|
|
return status
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|