diff --git a/.gitignore b/.gitignore index 8dbb00e..d07a02f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /streamdeck-go /streamdeck-helper /streamdeck-init +/streamdeck-ctl /bin/ *.exe diff --git a/cmd/streamdeck-ctl/main.go b/cmd/streamdeck-ctl/main.go new file mode 100644 index 0000000..9e40b71 --- /dev/null +++ b/cmd/streamdeck-ctl/main.go @@ -0,0 +1,443 @@ +// Command streamdeck-ctl inspects and edits the streamdeck-go configuration +// from outside the daemon. +// +// It exists so that front-ends — principally the Omarchy shell plugin — can read +// a machine-readable snapshot of the deck and rewrite keys without reimplementing +// YAML handling or device probing. Every mutating command writes config.yaml +// atomically; the daemon's fsnotify watcher picks the change up and hot-reloads, +// so edits take effect without restarting anything. +// +// All output is JSON on stdout. Errors go to stderr and set a non-zero exit code. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + + "git.i0t.app/lwoodard/streamdeck-go/internal/config" + "git.i0t.app/lwoodard/streamdeck-go/internal/modules" +) + +func main() { + cfgPath := flag.String("config", config.DefaultConfigPath(), "path to config file") + flag.Usage = usage + flag.Parse() + + args := flag.Args() + if len(args) == 0 { + usage() + os.Exit(2) + } + + var err error + switch args[0] { + case "status": + err = cmdStatus(*cfgPath) + case "brightness": + err = cmdBrightness(*cfgPath, args[1:]) + case "key": + err = cmdKey(*cfgPath, args[1:]) + case "daemon": + err = cmdDaemon(args[1:]) + case "help", "-h", "--help": + usage() + return + default: + err = fmt.Errorf("unknown command %q", args[0]) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "streamdeck-ctl: %v\n", err) + // Emit a JSON error too, so a front-end parsing stdout gets something + // structured rather than an empty buffer. + emit(map[string]any{"ok": false, "error": err.Error()}) + os.Exit(1) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `streamdeck-ctl — inspect and edit the streamdeck-go config + +usage: streamdeck-ctl [-config PATH] [args] + +commands: + status print a JSON snapshot: device, daemon, keys, icons, modules + brightness <0-100> set deck brightness (hot-reloaded by the daemon) + key set [flags] set or merge fields on a key + key clear remove a key entirely + key press run the key's command now + daemon + +key set flags (only the flags you pass are written): + -icon NAME -icon-true NAME -icon-false NAME + -text STR -text-color STR -text-command STR -refresh DUR + -command STR -module NAME -function NAME + -param K=V (repeatable) + -poll-command STR -poll-match STR -poll-interval DUR + -unset FIELD (repeatable — removes a field) +`) +} + +func emit(v any) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +// ── brightness ─────────────────────────────────────────────────────────────── + +func cmdBrightness(cfgPath string, args []string) error { + if len(args) != 1 { + return fmt.Errorf("brightness: expected a value 0-100") + } + value, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("brightness: %q is not a number", args[0]) + } + if value < 0 || value > 100 { + return fmt.Errorf("brightness: %d is out of range (0-100)", value) + } + if err := config.SetBrightness(cfgPath, value); err != nil { + return err + } + emit(map[string]any{"ok": true, "brightness": value}) + return nil +} + +// ── key ────────────────────────────────────────────────────────────────────── + +func cmdKey(cfgPath string, args []string) error { + if len(args) < 2 { + return fmt.Errorf("key: expected ") + } + action := args[0] + index, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("key: %q is not a key index", args[1]) + } + if index < 0 { + return fmt.Errorf("key: index %d is negative", index) + } + + switch action { + case "set": + return keySet(cfgPath, index, args[2:]) + case "clear": + if err := config.ClearKey(cfgPath, index); err != nil { + return err + } + emit(map[string]any{"ok": true, "index": index, "cleared": true}) + return nil + case "press": + return keyPress(cfgPath, index) + default: + return fmt.Errorf("key: unknown action %q", action) + } +} + +// stringList collects a repeatable string flag. +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ",") } +func (s *stringList) Set(v string) error { *s = append(*s, v); return nil } + +func keySet(cfgPath string, index int, args []string) error { + fs := flag.NewFlagSet("key set", flag.ContinueOnError) + + // Flag name → YAML field name. Only flags actually passed get written, so a + // front-end can PATCH a single field without clobbering the rest of the key. + scalars := map[string]*string{ + "icon": fs.String("icon", "", "icon filename inside icons_dir"), + "icon-true": fs.String("icon-true", "", "icon shown when poll matches"), + "icon-false": fs.String("icon-false", "", "icon shown when poll does not match"), + "text": fs.String("text", "", "text overlay (\\n for line breaks)"), + "text-color": fs.String("text-color", "", "white|black|red|blue|#RRGGBB"), + "text-command": fs.String("text-command", "", "command whose stdout becomes the overlay text"), + "refresh": fs.String("refresh", "", "how often to re-run text-command, e.g. 1s"), + "command": fs.String("command", "", "shell command to run on press"), + "module": fs.String("module", "", "module name from modules.yaml"), + "function": fs.String("function", "", "function name within the module"), + "poll-command": fs.String("poll-command", "", "command used to poll toggle state"), + "poll-match": fs.String("poll-match", "", "substring in poll output meaning 'on'"), + "poll-interval": fs.String("poll-interval", "", "poll interval, e.g. 2s"), + } + yamlField := map[string]string{ + "icon": "icon", "icon-true": "icon_true", "icon-false": "icon_false", + "text": "text", "text-color": "text_color", "text-command": "text_command", + "refresh": "refresh", "command": "command", "module": "module", "function": "function", + } + + var params stringList + var unset stringList + fs.Var(¶ms, "param", "module parameter as KEY=VALUE (repeatable)") + fs.Var(&unset, "unset", "remove a field (repeatable)") + + if err := fs.Parse(args); err != nil { + return err + } + + edit := config.KeyEdit{Set: map[string]any{}} + poll := map[string]string{} + pollTouched := false + + // flag.Visit only reports flags the caller actually set — that distinction is + // what makes this a merge rather than a full overwrite. + fs.Visit(func(f *flag.Flag) { + ptr, ok := scalars[f.Name] + if !ok { + return + } + if field, isTop := yamlField[f.Name]; isTop { + edit.Set[field] = *ptr + return + } + switch f.Name { + case "poll-command": + poll["command"] = *ptr + pollTouched = true + case "poll-match": + poll["match"] = *ptr + pollTouched = true + case "poll-interval": + poll["interval"] = *ptr + pollTouched = true + } + }) + + if len(params) > 0 { + parsed := map[string]string{} + for _, kv := range params { + name, value, found := strings.Cut(kv, "=") + if !found || name == "" { + return fmt.Errorf("key set: -param %q is not KEY=VALUE", kv) + } + parsed[name] = value + } + edit.Set["params"] = parsed + } + + if pollTouched { + // The poll block is written whole: it is small, and merging into a nested + // mapping would need the existing values read back anyway. + existing := existingPoll(cfgPath, index) + for name, value := range poll { + existing[name] = value + } + for name, value := range existing { + if value == "" { + delete(existing, name) + } + } + if len(existing) == 0 { + edit.Unset = append(edit.Unset, "poll") + } else { + edit.Set["poll"] = existing + } + } + + edit.Unset = append(edit.Unset, normalizeUnset(unset)...) + + if len(edit.Set) == 0 && len(edit.Unset) == 0 { + return fmt.Errorf("key set: nothing to change — pass at least one field flag") + } + + if err := config.ApplyKeyEdit(cfgPath, index, edit); err != nil { + return err + } + emit(map[string]any{"ok": true, "index": index, "set": edit.Set, "unset": edit.Unset}) + return nil +} + +// normalizeUnset accepts either flag-style or YAML-style field names, so callers +// can pass -unset icon-true or -unset icon_true. +func normalizeUnset(names []string) []string { + out := make([]string, 0, len(names)) + for _, name := range names { + out = append(out, strings.ReplaceAll(strings.TrimSpace(name), "-", "_")) + } + return out +} + +// existingPoll reads the current poll block for a key so partial poll edits merge +// instead of dropping the fields the caller didn't mention. +func existingPoll(cfgPath string, index int) map[string]string { + out := map[string]string{} + cfg, err := config.Load(cfgPath) + if err != nil { + return out + } + key, ok := cfg.Keys[index] + if !ok || key.Poll == nil { + return out + } + if key.Poll.Command != "" { + out["command"] = key.Poll.Command + } + if key.Poll.Match != "" { + out["match"] = key.Poll.Match + } + if key.Poll.Interval != "" { + out["interval"] = key.Poll.Interval + } + return out +} + +// keyPress runs the key's command exactly as the daemon would, including module +// resolution, so a front-end can fire a key without touching the hardware. +func keyPress(cfgPath string, index int) error { + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + key, ok := cfg.Keys[index] + if !ok { + return fmt.Errorf("key %d is not configured", index) + } + + command := key.Command + if key.Module != "" && key.Function != "" { + reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath)) + if err != nil { + return err + } + command, err = reg.Resolve(key.Module, key.Function, key.Params) + if err != nil { + return err + } + } + if strings.TrimSpace(command) == "" { + return fmt.Errorf("key %d has no command to run", index) + } + + if after, found := strings.CutPrefix(command, "priv:"); found { + return fmt.Errorf("key %d runs privileged command %q — press it on the deck instead", index, after) + } + + // Mirror the daemon's press semantics: fire and forget. Waiting here would + // wedge the caller (and the panel's serialized action pipeline) on any key + // that launches a long-lived app. The exit code covers "could not start"; + // the command's own outcome is its business, as on the hardware. + cmd := exec.Command("sh", "-c", command) + cmd.Env = pressEnvironment() + if err := cmd.Start(); err != nil { + return fmt.Errorf("key %d: start command: %w", index, err) + } + go func() { _ = cmd.Wait() }() // reap if it finishes before we exit + emit(map[string]any{"ok": true, "index": index, "started": true}) + return nil +} + +// pressEnvironment builds the env a pressed key runs under. The daemon runs a +// key with the systemd user manager's environment, but this CLI is usually +// invoked from the shell (a different, often smaller env) — so a module key +// whose token was imported only into the user manager would work on hardware +// and silently no-op from the panel. Merging the manager's variables over the +// caller's closes that gap on Linux; macOS keeps the caller's env (launchd has +// no equivalent bulk query). +func pressEnvironment() []string { + env := os.Environ() + if runtime.GOOS == "darwin" { + return env + } + out, err := exec.Command("systemctl", "--user", "show-environment").Output() + if err != nil { + return env + } + + // Overlay by name rather than appending: with duplicate keys in an env + // array, which copy getenv returns is libc-dependent. + merged := make(map[string]string, len(env)) + order := make([]string, 0, len(env)) + set := func(line string) { + name, value, found := strings.Cut(line, "=") + if !found || name == "" { + return + } + if _, seen := merged[name]; !seen { + order = append(order, name) + } + merged[name] = value + } + for _, line := range env { + set(line) + } + for _, line := range strings.Split(string(out), "\n") { + // systemd shell-quotes values that need it ($'...'); those few can't be + // used verbatim, and dropping them beats injecting mangled quoting. + if strings.Contains(line, "=$'") { + continue + } + // Manager value wins over the caller's: that's what the hardware sees. + set(line) + } + + result := make([]string, 0, len(order)) + for _, name := range order { + result = append(result, name+"="+merged[name]) + } + return result +} + +// ── daemon ─────────────────────────────────────────────────────────────────── + +func cmdDaemon(args []string) error { + if len(args) != 1 { + return fmt.Errorf("daemon: expected start|stop|restart|toggle|status") + } + action := args[0] + + if action == "toggle" { + if daemonState().Active { + action = "stop" + } else { + action = "start" + } + } + + switch action { + case "status": + emit(map[string]any{"ok": true, "daemon": daemonState()}) + return nil + case "start", "stop", "restart": + if err := controlDaemon(action); err != nil { + return err + } + emit(map[string]any{"ok": true, "action": action, "daemon": daemonState()}) + return nil + default: + return fmt.Errorf("daemon: unknown action %q", action) + } +} + +func controlDaemon(action string) error { + if runtime.GOOS == "darwin" { + label := "com.woodarddigital.streamdeck-go" + plist := os.Getenv("HOME") + "/Library/LaunchAgents/" + label + ".plist" + var cmd *exec.Cmd + switch action { + case "start": + cmd = exec.Command("launchctl", "load", "-w", plist) + case "stop": + cmd = exec.Command("launchctl", "unload", "-w", plist) + case "restart": + _ = exec.Command("launchctl", "unload", "-w", plist).Run() + cmd = exec.Command("launchctl", "load", "-w", plist) + } + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("launchctl %s: %v: %s", action, err, strings.TrimSpace(string(out))) + } + return nil + } + + out, err := exec.Command("systemctl", "--user", action, daemonUnit).CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl --user %s %s: %v: %s", action, daemonUnit, err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/cmd/streamdeck-ctl/status.go b/cmd/streamdeck-ctl/status.go new file mode 100644 index 0000000..963cc51 --- /dev/null +++ b/cmd/streamdeck-ctl/status.go @@ -0,0 +1,397 @@ +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"` + 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 + 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"` +} + +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{}, + 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.Icons = iconList(cfg.IconsDir) + + emit(status) + return nil +} + +func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus { + indices := make([]int, 0, len(cfg.Keys)) + for index := range cfg.Keys { + indices = append(indices, index) + } + sort.Ints(indices) + + out := make([]KeyStatus, 0, len(indices)) + for _, index := range indices { + key := cfg.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, + } + + if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" { + entry.IconPath = resolveIcon(cfg.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.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.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 "" +} diff --git a/internal/config/edit.go b/internal/config/edit.go new file mode 100644 index 0000000..a91344b --- /dev/null +++ b/internal/config/edit.go @@ -0,0 +1,402 @@ +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", "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' +} diff --git a/internal/config/edit_test.go b/internal/config/edit_test.go new file mode 100644 index 0000000..d851bbc --- /dev/null +++ b/internal/config/edit_test.go @@ -0,0 +1,126 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// Regression: a new key's index must be written as an int scalar. A !!str key +// ("25":) makes config.Load fail — yaml.v3 refuses string keys for map[int] — +// which bricked the config on the daemon's next hot reload. +func TestApplyKeyEditNewSlotLoadsBack(t *testing.T) { + path := writeConfig(t, "icons_dir: ./icons\nbrightness: 70\nkeys: {}\n") + + err := ApplyKeyEdit(path, 25, KeyEdit{Set: map[string]any{ + "icon": "test.png", + "command": "echo hi", + }}) + if err != nil { + t.Fatalf("ApplyKeyEdit: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load after edit: %v", err) + } + key, ok := cfg.Keys[25] + if !ok { + t.Fatalf("key 25 missing after edit; keys=%v", cfg.Keys) + } + if key.Icon != "test.png" || key.Command != "echo hi" { + t.Fatalf("key 25 = %+v", key) + } + + raw, _ := os.ReadFile(path) + if strings.Contains(string(raw), `"25"`) { + t.Fatalf("index written as a quoted string:\n%s", raw) + } +} + +// Editing an existing key must merge, and comments/blank lines must survive. +func TestApplyKeyEditMergePreservesFormatting(t *testing.T) { + path := writeConfig(t, `icons_dir: ./icons + +# the deck +keys: + 0: + icon: a.png # keep me + command: run-a +`) + + if err := ApplyKeyEdit(path, 0, KeyEdit{Set: map[string]any{"text": "Hi"}}); err != nil { + t.Fatalf("ApplyKeyEdit: %v", err) + } + + raw, _ := os.ReadFile(path) + text := string(raw) + for _, want := range []string{"# the deck", "# keep me", "command: run-a", "text: Hi", "\n\n"} { + if !strings.Contains(text, want) { + t.Fatalf("output missing %q:\n%s", want, text) + } + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Keys[0].Icon != "a.png" || cfg.Keys[0].Text != "Hi" { + t.Fatalf("merge lost fields: %+v", cfg.Keys[0]) + } +} + +// Unset is applied before Set, so a field named in both ends up set. +func TestApplyKeyEditSetWinsOverUnset(t *testing.T) { + path := writeConfig(t, "keys:\n 0:\n command: old\n") + + err := ApplyKeyEdit(path, 0, KeyEdit{ + Set: map[string]any{"command": "new"}, + Unset: []string{"command"}, + }) + if err != nil { + t.Fatalf("ApplyKeyEdit: %v", err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Keys[0].Command != "new" { + t.Fatalf("command = %q, want %q", cfg.Keys[0].Command, "new") + } +} + +func TestClearKeyAndSetBrightness(t *testing.T) { + path := writeConfig(t, "brightness: 70\nkeys:\n 3:\n command: x\n") + + if err := ClearKey(path, 3); err != nil { + t.Fatalf("ClearKey: %v", err) + } + if err := ClearKey(path, 99); err != nil { // absent key is not an error + t.Fatalf("ClearKey absent: %v", err) + } + if err := SetBrightness(path, 140); err != nil { // clamped + t.Fatalf("SetBrightness: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Keys) != 0 { + t.Fatalf("keys not cleared: %v", cfg.Keys) + } + if cfg.Brightness != 100 { + t.Fatalf("brightness = %d, want 100", cfg.Brightness) + } +} diff --git a/internal/device/streamdeck.go b/internal/device/streamdeck.go index 0c401c8..841add6 100644 --- a/internal/device/streamdeck.go +++ b/internal/device/streamdeck.go @@ -19,6 +19,7 @@ const VendorID = 0x0fd9 // ModelInfo describes hardware-specific constants for a Stream Deck model. type ModelInfo struct { + Name string KeyCount int Cols int Rows int @@ -31,9 +32,35 @@ type ModelInfo struct { // models maps USB product IDs to their hardware specs. var models = map[uint16]ModelInfo{ - 0x00ba: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v2 - 0x006c: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v1 - 0x006d: {KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true}, // MK.2 + 0x00ba: {Name: "Stream Deck XL v2", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, + 0x006c: {Name: "Stream Deck XL v1", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, + 0x006d: {Name: "Stream Deck MK.2", KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true}, +} + +// Lookup returns the hardware spec for a product ID without opening the device. +// Callers that only need geometry (key count, grid shape) can use this while the +// daemon holds the HID handle. +func Lookup(productID uint16) (ModelInfo, bool) { + m, ok := models[productID] + return m, ok +} + +// Present reports whether a Stream Deck with the given USB IDs is currently +// enumerated. It does not open the device, so it is safe to call while the +// daemon has it open. +func Present(vendorID, productID uint16) (bool, error) { + if err := hid.Init(); err != nil { + return false, fmt.Errorf("hid init: %w", err) + } + found := false + err := hid.Enumerate(vendorID, productID, func(*hid.DeviceInfo) error { + found = true + return nil + }) + if err != nil { + return false, fmt.Errorf("hid enumerate: %w", err) + } + return found, nil } const (