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' }