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
403 lines
12 KiB
Go
403 lines
12 KiB
Go
package config
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// Editing works at the yaml.Node level rather than by marshalling the Config
|
||
// struct back out. The struct round-trip would drop every comment in the file
|
||
// and materialise defaults the user never wrote; node-level edits touch only
|
||
// the mapping entries that actually change.
|
||
//
|
||
// Writes are atomic (temp file in the same directory, then rename). The daemon
|
||
// watches both the config file and its parent directory, so the rename lands as
|
||
// a CREATE event on the directory watch and triggers a hot reload.
|
||
|
||
// KeyEdit describes a change to a single key. Fields present in Set are written
|
||
// (creating the key block if needed); names listed in Unset are removed. Unset
|
||
// is applied first, so a field named in both ends up set. Values may be strings,
|
||
// ints, or map[string]string (params).
|
||
type KeyEdit struct {
|
||
Set map[string]any
|
||
Unset []string
|
||
}
|
||
|
||
// SetBrightness rewrites the top-level brightness value, clamped to 0–100.
|
||
func SetBrightness(path string, value int) error {
|
||
if value < 0 {
|
||
value = 0
|
||
}
|
||
if value > 100 {
|
||
value = 100
|
||
}
|
||
return editDocument(path, func(root *yaml.Node) error {
|
||
mapSet(root, "brightness", scalarNode(strconv.Itoa(value), "!!int"))
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// ApplyKeyEdit merges an edit into the keys block for the given index, creating
|
||
// the keys block or the key entry if either is missing.
|
||
func ApplyKeyEdit(path string, index int, edit KeyEdit) error {
|
||
return editDocument(path, func(root *yaml.Node) error {
|
||
keys := mapGet(root, "keys")
|
||
if keys == nil || keys.Kind != yaml.MappingNode {
|
||
keys = &yaml.Node{Kind: yaml.MappingNode}
|
||
mapSet(root, "keys", keys)
|
||
}
|
||
// A fresh config from the installer carries `keys: {}` — a flow-style
|
||
// empty mapping. Force block style or new entries render inline.
|
||
keys.Style = 0
|
||
|
||
entry := mapGet(keys, strconv.Itoa(index))
|
||
if entry == nil || entry.Kind != yaml.MappingNode {
|
||
entry = &yaml.Node{Kind: yaml.MappingNode}
|
||
// The index must be an int scalar: config.Load unmarshals keys into
|
||
// map[int]KeyConfig, and yaml.v3 refuses to coerce a !!str key ("25":)
|
||
// into an int — a string-tagged key here bricks the config on reload.
|
||
mapSetNode(keys, scalarNode(strconv.Itoa(index), "!!int"), entry)
|
||
}
|
||
entry.Style = 0
|
||
|
||
for _, name := range edit.Unset {
|
||
mapDelete(entry, name)
|
||
}
|
||
// Deterministic ordering so repeated edits don't shuffle the file, and so
|
||
// a brand-new key block reads in a sensible order.
|
||
for _, name := range sortedFieldNames(edit.Set) {
|
||
node, err := valueNode(edit.Set[name])
|
||
if err != nil {
|
||
return fmt.Errorf("field %q: %w", name, err)
|
||
}
|
||
mapSet(entry, name, node)
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// ClearKey removes a key entry entirely. Removing a key that isn't there is not
|
||
// an error — the caller's intent (that slot ends up empty) is already satisfied.
|
||
func ClearKey(path string, index int) error {
|
||
return editDocument(path, func(root *yaml.Node) error {
|
||
keys := mapGet(root, "keys")
|
||
if keys == nil || keys.Kind != yaml.MappingNode {
|
||
return nil
|
||
}
|
||
mapDelete(keys, strconv.Itoa(index))
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// fieldOrder is the order key fields are written in when a key block is built or
|
||
// extended, so generated YAML reads the way the hand-written examples do.
|
||
var fieldOrder = []string{
|
||
"icon", "icon_true", "icon_false",
|
||
"text", "text_color", "text_command", "refresh",
|
||
"module", "function", "params",
|
||
"command", "folder", "poll",
|
||
}
|
||
|
||
func sortedFieldNames(m map[string]any) []string {
|
||
rank := make(map[string]int, len(fieldOrder))
|
||
for i, name := range fieldOrder {
|
||
rank[name] = i
|
||
}
|
||
names := make([]string, 0, len(m))
|
||
for name := range m {
|
||
names = append(names, name)
|
||
}
|
||
sort.Slice(names, func(i, j int) bool {
|
||
ri, oki := rank[names[i]]
|
||
rj, okj := rank[names[j]]
|
||
if oki != okj {
|
||
return oki // known fields sort before unknown ones
|
||
}
|
||
if oki && ri != rj {
|
||
return ri < rj
|
||
}
|
||
return names[i] < names[j]
|
||
})
|
||
return names
|
||
}
|
||
|
||
// editDocument parses path, hands the root mapping to fn, and writes the result
|
||
// back atomically. The file must already exist and parse.
|
||
func editDocument(path string, fn func(root *yaml.Node) error) error {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return fmt.Errorf("read config %q: %w", path, err)
|
||
}
|
||
|
||
// yaml.v3 keeps comments but discards blank lines, so a config edited from a
|
||
// GUI would slowly lose its paragraph breaks. Standing them in as sentinel
|
||
// comments carries them through the round-trip.
|
||
data, blanksProtected := protectBlankLines(raw)
|
||
|
||
var doc yaml.Node
|
||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||
return fmt.Errorf("parse config %q: %w", path, err)
|
||
}
|
||
|
||
var root *yaml.Node
|
||
switch {
|
||
case doc.Kind == yaml.DocumentNode && len(doc.Content) > 0:
|
||
root = doc.Content[0]
|
||
case doc.Kind == 0:
|
||
// Empty (or comment-only) file — start a mapping so edits have somewhere to go.
|
||
root = &yaml.Node{Kind: yaml.MappingNode}
|
||
doc = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
|
||
default:
|
||
return fmt.Errorf("config %q: unexpected document shape", path)
|
||
}
|
||
if root.Kind != yaml.MappingNode {
|
||
return fmt.Errorf("config %q: top level is not a mapping", path)
|
||
}
|
||
|
||
if err := fn(root); err != nil {
|
||
return err
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
enc := yaml.NewEncoder(&buf)
|
||
enc.SetIndent(2)
|
||
if err := enc.Encode(&doc); err != nil {
|
||
return fmt.Errorf("encode config: %w", err)
|
||
}
|
||
if err := enc.Close(); err != nil {
|
||
return fmt.Errorf("encode config: %w", err)
|
||
}
|
||
|
||
out := buf.Bytes()
|
||
if blanksProtected {
|
||
out = restoreBlankLines(out)
|
||
}
|
||
return writeAtomic(path, out)
|
||
}
|
||
|
||
// blankSentinel stands in for a blank line across the parse/encode round-trip.
|
||
// It is deliberately obscure so it cannot collide with a real comment.
|
||
const blankSentinel = "#__streamdeck_go_blank_line__"
|
||
|
||
// protectBlankLines rewrites blank lines as sentinel comments. It reports false
|
||
// (and leaves the input untouched) when the document contains a block scalar,
|
||
// where an inserted line would become part of the string's content rather than
|
||
// structure. Losing blank-line formatting is a cosmetic regression; corrupting a
|
||
// multi-line command is not, so the ambiguous case declines to act.
|
||
func protectBlankLines(data []byte) ([]byte, bool) {
|
||
if blockScalarPattern.Match(data) {
|
||
return data, false
|
||
}
|
||
lines := strings.Split(string(data), "\n")
|
||
changed := false
|
||
for i, line := range lines {
|
||
if strings.TrimSpace(line) == "" && i != len(lines)-1 {
|
||
lines[i] = blankSentinel
|
||
changed = true
|
||
}
|
||
}
|
||
if !changed {
|
||
return data, false
|
||
}
|
||
return []byte(strings.Join(lines, "\n")), true
|
||
}
|
||
|
||
// restoreBlankLines turns sentinel comments back into blank lines. The encoder
|
||
// may have indented them along with the comment block they joined, so the match
|
||
// is on the trimmed line.
|
||
func restoreBlankLines(data []byte) []byte {
|
||
lines := strings.Split(string(data), "\n")
|
||
for i, line := range lines {
|
||
if strings.TrimSpace(line) == blankSentinel {
|
||
lines[i] = ""
|
||
}
|
||
}
|
||
return []byte(strings.Join(lines, "\n"))
|
||
}
|
||
|
||
// blockScalarPattern matches a literal or folded block scalar header, e.g.
|
||
// `command: |`, `command: >-`, or `command: |2 # note`.
|
||
var blockScalarPattern = regexp.MustCompile(`(?m):[ \t]*[|>][-+0-9]*[ \t]*(#.*)?$`)
|
||
|
||
// writeAtomic writes to a temp file in the same directory and renames it into
|
||
// place, so a reader (or the daemon's fsnotify reload) never sees a half-written
|
||
// config. The original mode is preserved when it can be read.
|
||
func writeAtomic(path string, data []byte) error {
|
||
dir := filepath.Dir(path)
|
||
mode := os.FileMode(0o644)
|
||
if info, err := os.Stat(path); err == nil {
|
||
mode = info.Mode().Perm()
|
||
}
|
||
|
||
tmp, err := os.CreateTemp(dir, ".streamdeck-config-*.yaml")
|
||
if err != nil {
|
||
return fmt.Errorf("create temp file in %q: %w", dir, err)
|
||
}
|
||
tmpName := tmp.Name()
|
||
defer os.Remove(tmpName) // no-op once the rename succeeds
|
||
|
||
if _, err := tmp.Write(data); err != nil {
|
||
tmp.Close()
|
||
return fmt.Errorf("write temp file: %w", err)
|
||
}
|
||
if err := tmp.Sync(); err != nil {
|
||
tmp.Close()
|
||
return fmt.Errorf("sync temp file: %w", err)
|
||
}
|
||
if err := tmp.Close(); err != nil {
|
||
return fmt.Errorf("close temp file: %w", err)
|
||
}
|
||
if err := os.Chmod(tmpName, mode); err != nil {
|
||
return fmt.Errorf("chmod temp file: %w", err)
|
||
}
|
||
if err := os.Rename(tmpName, path); err != nil {
|
||
return fmt.Errorf("replace %q: %w", path, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ── yaml.Node mapping helpers ────────────────────────────────────────────────
|
||
//
|
||
// A MappingNode stores Content as a flat [key, value, key, value, ...] slice.
|
||
|
||
func mapGet(m *yaml.Node, key string) *yaml.Node {
|
||
if m == nil || m.Kind != yaml.MappingNode {
|
||
return nil
|
||
}
|
||
for i := 0; i+1 < len(m.Content); i += 2 {
|
||
if m.Content[i].Value == key {
|
||
return m.Content[i+1]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// mapSet replaces the value for key, or appends the pair if it isn't present.
|
||
// Replacing keeps the existing key node so its comments stay attached.
|
||
func mapSet(m *yaml.Node, key string, value *yaml.Node) {
|
||
for i := 0; i+1 < len(m.Content); i += 2 {
|
||
if m.Content[i].Value == key {
|
||
// Carry the old value's comments onto the replacement — they describe
|
||
// the setting, not the specific value being overwritten.
|
||
old := m.Content[i+1]
|
||
if value.HeadComment == "" {
|
||
value.HeadComment = old.HeadComment
|
||
}
|
||
if value.LineComment == "" {
|
||
value.LineComment = old.LineComment
|
||
}
|
||
if value.FootComment == "" {
|
||
value.FootComment = old.FootComment
|
||
}
|
||
m.Content[i+1] = value
|
||
return
|
||
}
|
||
}
|
||
m.Content = append(m.Content, scalarNode(key, "!!str"), value)
|
||
}
|
||
|
||
// mapSetNode is mapSet with a caller-built key node, for keys that must carry a
|
||
// non-string tag (e.g. the !!int key indices under `keys:`).
|
||
func mapSetNode(m *yaml.Node, key, value *yaml.Node) {
|
||
for i := 0; i+1 < len(m.Content); i += 2 {
|
||
if m.Content[i].Value == key.Value {
|
||
m.Content[i+1] = value
|
||
return
|
||
}
|
||
}
|
||
m.Content = append(m.Content, key, value)
|
||
}
|
||
|
||
func mapDelete(m *yaml.Node, key string) {
|
||
if m == nil || m.Kind != yaml.MappingNode {
|
||
return
|
||
}
|
||
for i := 0; i+1 < len(m.Content); i += 2 {
|
||
if m.Content[i].Value == key {
|
||
m.Content = append(m.Content[:i], m.Content[i+2:]...)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func scalarNode(value, tag string) *yaml.Node {
|
||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value}
|
||
}
|
||
|
||
// valueNode converts a Go value from a KeyEdit into a yaml.Node.
|
||
func valueNode(v any) (*yaml.Node, error) {
|
||
switch typed := v.(type) {
|
||
case string:
|
||
return stringNode(typed), nil
|
||
case int:
|
||
return scalarNode(strconv.Itoa(typed), "!!int"), nil
|
||
case bool:
|
||
return scalarNode(strconv.FormatBool(typed), "!!bool"), nil
|
||
case map[string]string:
|
||
node := &yaml.Node{Kind: yaml.MappingNode}
|
||
names := make([]string, 0, len(typed))
|
||
for name := range typed {
|
||
names = append(names, name)
|
||
}
|
||
sort.Strings(names)
|
||
for _, name := range names {
|
||
node.Content = append(node.Content, scalarNode(name, "!!str"), stringNode(typed[name]))
|
||
}
|
||
return node, nil
|
||
default:
|
||
return nil, fmt.Errorf("unsupported value type %T", v)
|
||
}
|
||
}
|
||
|
||
// stringNode emits a string scalar, quoting it when leaving it bare would change
|
||
// its meaning on re-parse (empty, leading/trailing space, or a value YAML would
|
||
// read back as a bool/number/null).
|
||
func stringNode(s string) *yaml.Node {
|
||
node := scalarNode(s, "!!str")
|
||
if needsQuoting(s) {
|
||
node.Style = yaml.DoubleQuotedStyle
|
||
}
|
||
return node
|
||
}
|
||
|
||
func needsQuoting(s string) bool {
|
||
if s == "" {
|
||
return true
|
||
}
|
||
if s != trimSpace(s) {
|
||
return true
|
||
}
|
||
var probe any
|
||
if err := yaml.Unmarshal([]byte(s), &probe); err != nil {
|
||
// Not parseable bare (e.g. contains ": ") — quote it and let the encoder escape.
|
||
return true
|
||
}
|
||
if _, isString := probe.(string); !isString {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func trimSpace(s string) string {
|
||
start, end := 0, len(s)
|
||
for start < end && isSpace(s[start]) {
|
||
start++
|
||
}
|
||
for end > start && isSpace(s[end-1]) {
|
||
end--
|
||
}
|
||
return s[start:end]
|
||
}
|
||
|
||
func isSpace(b byte) bool {
|
||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||
}
|