Compare commits

...

2 Commits

Author SHA1 Message Date
Levi Woodard
2aa4c65e42 Add Omarchy shell plugin for the Stream Deck
QML bar widget + popout panel (dev.woodard.streamdeck) that drives
streamdeck-ctl: key grid shaped by the connected model, per-key editor,
brightness slider, and daemon controls. Themed via qs.Commons.

Makefile gains build-ctl, install-ctl, install-plugin, validate-plugin
and uninstall-plugin targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjuZBBfzXqZgxRhxzJvkkC
2026-09-27 18:21:33 -06:00
Levi Woodard
8cff4f8418 Add streamdeck-ctl JSON control CLI
streamdeck-ctl reports device/daemon/key status as JSON and edits the
config (brightness, key set/clear/press, daemon control) so front-ends
don't have to reimplement YAML handling or device probing.

- internal/config/edit.go: comment-preserving, node-level YAML edits with
  atomic writes; key indices written as !!int so config.Load accepts them.
- internal/device: add model names, Lookup() and Present() for probing
  without opening the HID handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjuZBBfzXqZgxRhxzJvkkC
2026-09-27 18:21:33 -06:00
17 changed files with 3496 additions and 4 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
/streamdeck-go /streamdeck-go
/streamdeck-helper /streamdeck-helper
/streamdeck-init /streamdeck-init
/streamdeck-ctl
/bin/ /bin/
*.exe *.exe

View File

@@ -1,10 +1,16 @@
BINARY := streamdeck-go BINARY := streamdeck-go
HELPER := streamdeck-helper HELPER := streamdeck-helper
INIT := streamdeck-init INIT := streamdeck-init
CTL := streamdeck-ctl
PREFIX ?= $(HOME)/.local PREFIX ?= $(HOME)/.local
CONFIG_DIR := $(HOME)/.config/streamdeck-go CONFIG_DIR := $(HOME)/.config/streamdeck-go
GROUP := streamdeck GROUP := streamdeck
# ── Omarchy shell plugin ──────────────────────────────────────────────────────
PLUGIN_ID := dev.woodard.streamdeck
PLUGIN_SRC := $(CURDIR)/omarchy-plugin
PLUGIN_DIR := $(HOME)/.config/omarchy/plugins/$(PLUGIN_ID)
# ── OS detection ────────────────────────────────────────────────────────────── # ── OS detection ──────────────────────────────────────────────────────────────
OS := $(shell uname -s) OS := $(shell uname -s)
@@ -24,7 +30,9 @@ else
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
endif endif
.PHONY: build build-helper build-init install install-helper install-watchdog reinstall uninstall uninstall-helper uninstall-watchdog udev .PHONY: build build-helper build-init build-ctl install install-ctl install-helper install-watchdog \
install-plugin uninstall-plugin validate-plugin reinstall uninstall uninstall-helper \
uninstall-watchdog udev
# ── Build ───────────────────────────────────────────────────────────────────── # ── Build ─────────────────────────────────────────────────────────────────────
@@ -37,6 +45,9 @@ build-helper:
build-init: build-init:
go build -o $(INIT) ./cmd/streamdeck-init/ go build -o $(INIT) ./cmd/streamdeck-init/
build-ctl:
go build -o $(CTL) ./cmd/streamdeck-ctl/
# ── Install ─────────────────────────────────────────────────────────────────── # ── Install ───────────────────────────────────────────────────────────────────
# Interactive install — prompts for dotfiles directory, installs binary + service. # Interactive install — prompts for dotfiles directory, installs binary + service.
@@ -223,6 +234,47 @@ reinstall: build
fi fi
endif endif
# ── streamdeck-ctl + Omarchy shell plugin ─────────────────────────────────────
# The CLI the Omarchy plugin drives. Also useful on its own for scripting.
install-ctl: build-ctl
mkdir -p $(BIN_DIR)
install -m 755 $(CTL) $(BIN_DIR)/$(CTL)
@echo "Installed $(BIN_DIR)/$(CTL)"
# Symlink rather than copy so edits to the QML are live after a shell rescan.
install-plugin: install-ctl
@if [ ! -d "$(HOME)/.config/omarchy" ]; then \
echo "Omarchy shell config not found at ~/.config/omarchy — is Omarchy installed?"; \
exit 1; \
fi
mkdir -p $(HOME)/.config/omarchy/plugins
@if [ -e "$(PLUGIN_DIR)" ] && [ ! -L "$(PLUGIN_DIR)" ]; then \
echo "$(PLUGIN_DIR) exists and is not a symlink — remove it first."; \
exit 1; \
fi
ln -sfn "$(PLUGIN_SRC)" "$(PLUGIN_DIR)"
# Validate the source tree, not the link: `omarchy plugin validate` refuses a
# symlinked plugin folder by design, but the shell resolves it happily.
@omarchy plugin validate "$(PLUGIN_SRC)" || true
@omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
@echo ""
@echo "Plugin linked: $(PLUGIN_DIR) -> $(PLUGIN_SRC)"
@echo "Enable it with: omarchy plugin enable $(PLUGIN_ID) right"
validate-plugin:
omarchy plugin validate "$(PLUGIN_SRC)"
@command -v qmllint >/dev/null 2>&1 && \
qmllint -I $${OMARCHY_PATH:-/usr/share/omarchy}/shell "$(PLUGIN_SRC)"/*.qml || \
echo "qmllint not installed — skipped QML lint"
uninstall-plugin:
omarchy plugin disable $(PLUGIN_ID) 2>/dev/null || true
rm -f "$(PLUGIN_DIR)"
rm -f $(BIN_DIR)/$(CTL)
@omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
@echo "Plugin unlinked and $(CTL) removed."
# ── Uninstall ───────────────────────────────────────────────────────────────── # ── Uninstall ─────────────────────────────────────────────────────────────────
ifeq ($(OS),Darwin) ifeq ($(OS),Darwin)

View File

@@ -6,6 +6,20 @@ privileged helper — see [README § Privileged commands](README.md)).
--- ---
## Shell plugin
Beyond the key commands below, streamdeck-go ships an Omarchy **bar widget** that
puts deck status, brightness, and inline key editing in the bar itself:
```bash
make install-plugin
omarchy plugin enable dev.woodard.streamdeck right
```
See [omarchy-plugin/README.md](omarchy-plugin/README.md).
---
## Terminal (Ghostty) ## Terminal (Ghostty)
```yaml ```yaml

443
cmd/streamdeck-ctl/main.go Normal file
View File

@@ -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] <command> [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 <index> [flags] set or merge fields on a key
key clear <index> remove a key entirely
key press <index> run the key's command now
daemon <start|stop|restart|toggle|status>
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 <set|clear|press> <index>")
}
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(&params, "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
}

View File

@@ -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 ""
}

402
internal/config/edit.go Normal file
View File

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

View File

@@ -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)
}
}

View File

@@ -19,6 +19,7 @@ const VendorID = 0x0fd9
// ModelInfo describes hardware-specific constants for a Stream Deck model. // ModelInfo describes hardware-specific constants for a Stream Deck model.
type ModelInfo struct { type ModelInfo struct {
Name string
KeyCount int KeyCount int
Cols int Cols int
Rows int Rows int
@@ -31,9 +32,35 @@ type ModelInfo struct {
// models maps USB product IDs to their hardware specs. // models maps USB product IDs to their hardware specs.
var models = map[uint16]ModelInfo{ var models = map[uint16]ModelInfo{
0x00ba: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v2 0x00ba: {Name: "Stream Deck XL v2", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true},
0x006c: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v1 0x006c: {Name: "Stream Deck XL v1", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true},
0x006d: {KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true}, // MK.2 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 ( const (

View File

@@ -0,0 +1,407 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Editor for one key. It edits a local draft and only writes to config.yaml when
// Save is pressed, so a half-typed command never reaches the running deck.
//
// Inputs are deliberately *not* two-way bound — neither the text fields nor the
// dropdowns. Typing into a QML TextField (or Ui/Dropdown selecting internally)
// writes the property directly and destroys any declarative binding on it, which
// would leave Revert unable to repaint the control. Instead the draft is the
// single source of truth: edit handlers push user input into it, and
// syncFields()/paramsSynced() push the draft back out whenever it changes
// underneath the user. Controls with focus (or an open popup) are skipped so a
// background poll can never move the cursor mid-keystroke.
Item {
id: root
property var service: null
property var status: null
property int keyIndex: -1
property color foreground: Color.foreground
property color accent: Color.accent
property string fontFamily: Style.font.family
signal closed()
signal saved()
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property var currentKey: {
if (!status || keyIndex < 0) return null
for (var i = 0; i < status.keys.length; i++) {
if (Number(status.keys[i].index) === keyIndex) return status.keys[i]
}
return null
}
readonly property var original: Model.editorDraft(currentKey)
readonly property bool isNew: currentKey === null
// The working copy.
property var draft: Model.editorDraft(null)
// `touched` is set by user edits rather than derived from draft-vs-original.
// A derived flag would be read by onCurrentKeyChanged while `original` was
// still evaluating, which Qt reports as a binding loop.
property bool touched: false
readonly property bool dirty: touched && Model.draftDiffers(draft, original)
readonly property bool usingModule: String(draft.module || "") !== ""
// The parameter Repeater's model. Rebuilt ONLY when the parameter name set
// changes (module/function switch), never on keystrokes or status polls —
// a model whose identity churned with `draft` or `status` would destroy and
// recreate the delegates, dropping focus after every typed character.
property var paramNames: []
// Fired when param VALUES in the draft changed underneath the delegates
// (reset, module switch). Delegates re-pull their value unless focused.
signal paramsSynced()
onKeyIndexChanged: reset()
// A background poll replaces the status object every few seconds. Adopt the
// refreshed key only when the user has nothing in flight.
//
// The adoption is deferred: `currentKey` is evaluated lazily, so the first
// read of `dirty` (via `original`) is what triggers this handler. Writing
// `draft` synchronously here would invalidate `dirty` while it is still being
// computed, which Qt reports as a binding loop. Qt.callLater moves the write
// to after the current evaluation pass.
onCurrentKeyChanged: if (!touched) Qt.callLater(adoptIfClean)
Component.onCompleted: reset()
// Escape closes the editor — it bubbles up here from whichever field has
// focus, since QQC2 TextField doesn't consume it.
Keys.onEscapePressed: closed()
// The shipped-panel idiom for inline editors: focus a real control the moment
// the editor appears (see network's password field). Landing focus on a plain
// Item would leave typed keys dead and Tab navigation stranded.
onVisibleChanged: if (visible) Qt.callLater(focusFirstField)
function focusFirstField() {
if (!visible) return
commandField.forceActiveFocus()
}
function adoptIfClean() {
if (!touched) reset()
}
function reset() {
draft = Model.editorDraft(currentKey)
touched = false
rebuildParams()
syncFields()
paramsSynced()
}
function rebuildParams() {
var rows = status ? Model.paramRows(status, draft.module, draft["function"], draft.params) : []
var names = rows.map(function(row) { return { name: row.name, placeholder: row.placeholder } })
// Identity guard: an unchanged name set must not touch the model, or the
// Repeater rebuilds delegates for nothing.
if (JSON.stringify(names) !== JSON.stringify(paramNames)) paramNames = names
}
// Push draft values into the controls. Focused fields and open popups are
// left alone so a sync landing mid-interaction cannot fight the user.
function syncFields() {
if (!commandField.activeFocus) commandField.text = draft.command
if (!labelField.activeFocus) labelField.text = draft.text
if (!colorField.activeFocus) colorField.text = draft.textColor
if (!moduleDropdown.popupOpen) moduleDropdown.value = draft.module
if (!functionDropdown.popupOpen) functionDropdown.value = draft["function"]
if (!iconDropdown.popupOpen) iconDropdown.value = draft.icon
}
function paramValueFor(name) {
var value = draft.params ? draft.params[name] : undefined
return (value === undefined || value === null) ? "" : String(value)
}
function setField(name, value) {
if (String(draft[name] || "") === String(value)) return
var next = Model.shallowCopy(draft)
next.params = Model.shallowCopy(draft.params)
next[name] = value
// Switching or clearing the module invalidates the function and its params.
if (name === "module") {
next["function"] = ""
next.params = {}
}
if (name === "function") next.params = {}
draft = next
touched = true
if (name === "module" || name === "function") {
rebuildParams()
paramsSynced()
}
syncFields()
}
function setParam(name, value) {
if (String(draft.params[name] || "") === String(value)) return
var next = Model.shallowCopy(draft)
next.params = Model.shallowCopy(draft.params)
next.params[name] = value
draft = next
touched = true
}
function save() {
if (!service) return
service.saveKey(keyIndex, Model.editArguments(draft, original))
touched = false
saved()
}
function removeKey() {
if (!service) return
service.clearKey(keyIndex)
touched = false
closed()
}
implicitHeight: column.implicitHeight
ColumnLayout {
id: column
anchors.left: parent.left
anchors.right: parent.right
spacing: Style.space(10)
// ── header ───────────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: (root.isNew ? "New key " : "Key ") + root.keyIndex
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.subtitle
}
PanelActionButton {
iconText: "󰑓"
tooltipText: "Revert changes"
foreground: root.foreground
fontFamily: root.fontFamily
enabled: root.dirty
opacity: enabled ? 1.0 : 0.35
onClicked: root.reset()
}
PanelActionButton {
iconText: "󰩹"
tooltipText: "Delete this key"
foreground: Color.urgent
fontFamily: root.fontFamily
enabled: !root.isNew
opacity: enabled ? 1.0 : 0.35
onClicked: root.removeKey()
}
PanelActionButton {
iconText: "󰅖"
tooltipText: "Close editor"
foreground: root.foreground
fontFamily: root.fontFamily
onClicked: root.closed()
}
}
// Parts of the key this editor doesn't cover are preserved on save (merge
// semantics), but say so rather than leaving them invisible.
Text {
Layout.fillWidth: true
visible: !!(root.currentKey && (root.currentKey.poll || root.currentKey.textCommand))
text: "This key also has " +
(root.currentKey && root.currentKey.poll ? "a poll block" : "live text") +
" — saved edits keep it, but edit it in config.yaml"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
// ── action: module function, or a plain shell command ────────────────────
FieldLabel {
text: "MODULE"
visible: root.status && root.status.modules.length > 0
}
Dropdown {
id: moduleDropdown
Layout.fillWidth: true
visible: root.status && root.status.modules.length > 0
showLabel: false
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
options: root.status ? Model.moduleOptions(root.status) : []
onChanged: function(v) { root.setField("module", v) }
}
FieldLabel { text: "FUNCTION"; visible: root.usingModule }
Dropdown {
id: functionDropdown
Layout.fillWidth: true
visible: root.usingModule
showLabel: false
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
options: root.status ? Model.functionOptions(root.status, root.draft.module) : []
onChanged: function(v) { root.setField("function", v) }
}
FieldLabel {
text: "PARAMETERS"
visible: root.usingModule && root.paramNames.length > 0
}
Repeater {
model: root.usingModule ? root.paramNames : []
RowLayout {
id: paramRow
required property var modelData
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.preferredWidth: Style.space(76)
text: paramRow.modelData.name
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
TextField {
id: paramField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: paramRow.modelData.placeholder
Component.onCompleted: text = root.paramValueFor(paramRow.modelData.name)
onTextEdited: root.setParam(paramRow.modelData.name, text)
}
Connections {
target: root
function onParamsSynced() {
if (!paramField.activeFocus) paramField.text = root.paramValueFor(paramRow.modelData.name)
}
}
}
}
FieldLabel { text: root.usingModule ? "COMMAND — OVERRIDES THE MODULE" : "COMMAND" }
TextField {
id: commandField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: "shell command to run on press"
onTextEdited: root.setField("command", text)
}
PanelSeparator { Layout.fillWidth: true; foreground: root.foreground }
// ── appearance ───────────────────────────────────────────────────────────
FieldLabel { text: "ICON" }
Dropdown {
id: iconDropdown
Layout.fillWidth: true
showLabel: false
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
options: root.status ? Model.iconOptions(root.status) : []
onChanged: function(v) { root.setField("icon", v) }
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
ColumnLayout {
Layout.fillWidth: true
spacing: Style.spacing.labelGap
FieldLabel { text: "LABEL" }
TextField {
id: labelField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: "text drawn on the key"
onTextEdited: root.setField("text", text)
}
}
ColumnLayout {
Layout.preferredWidth: Style.space(104)
spacing: Style.spacing.labelGap
FieldLabel { text: "COLOUR" }
TextField {
id: colorField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: "white"
onTextEdited: root.setField("textColor", text)
}
}
}
// ── footer ───────────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: root.dirty ? "Unsaved changes" : "Saved edits reload the deck immediately"
color: root.dirty ? root.foreground : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
Button {
text: "Save"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
enabled: root.dirty
opacity: enabled ? 1.0 : 0.4
onClicked: root.save()
}
}
}
// FieldLabel is the small-caps heading above each control.
component FieldLabel: PanelSectionHeader {
Layout.fillWidth: true
foreground: root.foreground
fontFamily: root.fontFamily
}
}

200
omarchy-plugin/KeyGrid.qml Normal file
View File

@@ -0,0 +1,200 @@
import QtQuick
import qs.Commons
import "Model.js" as Model
// The deck laid out as it sits on the desk. Grid shape comes from the device
// report (8x4 on an XL, 5x3 on an MK.2), so this is not hardcoded to one model.
Item {
id: root
property var slots: []
property int cols: 8
property int rows: 4
property int cursorIndex: -1
property bool cursorActive: false
property color foreground: Color.foreground
property color accent: Color.accent
property string fontFamily: Style.font.family
// Icons are previewed from disk when the file is a raster format; SVG and GIF
// fall back to the kind glyph rather than risking a slow or failed decode in
// the panel's paint path.
property bool showIconPreviews: true
signal keyActivated(int index)
signal keyFocused(int index)
signal keySecondary(int index)
// Themes are not tagged light/dark, so derive it from the panel background's
// perceived luminance (Rec. 601 weights).
readonly property color panelBackground: Color.popups.background
readonly property bool lightTheme: (0.299 * panelBackground.r
+ 0.587 * panelBackground.g
+ 0.114 * panelBackground.b) > 0.5
// Stands in for the deck's physical key face.
readonly property color keyFace: Qt.rgba(0.08, 0.08, 0.08, 1.0)
readonly property real spacing: Style.space(4)
readonly property real cellWidth: cols > 0 ? (width - spacing * (cols - 1)) / cols : 0
// Stream Deck keys are square; keeping the cells square makes the panel read
// as the physical device rather than an abstract table.
readonly property real cellHeight: cellWidth
implicitHeight: rows > 0 ? cellHeight * rows + spacing * (rows - 1) : 0
Repeater {
// Modelled on the slot COUNT, not the slots array: `slots` gets a fresh
// identity on every status poll, and a Repeater over a JS array rebuilds
// every delegate on identity change — 32 cells of object churn and a frame
// of Image flicker per poll. With an int model the delegates persist and
// only the bindings that actually changed re-evaluate.
model: root.cols * root.rows
Item {
id: cell
required property int index
readonly property var slot: (index < root.slots.length)
? root.slots[index]
: ({ index: index, configured: false, kind: "empty", label: "" })
readonly property int keyIndex: Number(slot.index)
readonly property bool configured: slot.configured === true
readonly property bool hasCursor: root.cursorActive && root.cursorIndex === keyIndex
readonly property bool hot: mouse.containsMouse || hasCursor
x: (index % root.cols) * (root.cellWidth + root.spacing)
y: Math.floor(index / root.cols) * (root.cellHeight + root.spacing)
width: root.cellWidth
height: root.cellHeight
Rectangle {
id: face
anchors.fill: parent
radius: Style.cornerRadius > 0 ? Style.space(3) : 0
// Every colour here resolves through Style/Color, so the grid repaints
// itself when the Omarchy theme changes.
color: cell.hot ? Style.hoverFillFor(root.foreground, root.accent)
: cell.configured ? Style.normalFillFor(root.foreground, root.accent)
: "transparent"
border.width: cell.hot ? Style.hoverBorderWidth : Style.normalBorderWidth
border.color: cell.hot ? Style.hoverBorderFor(root.foreground, root.accent)
: Util.alpha(root.foreground, cell.configured ? 0.28 : 0.12)
Behavior on color { ColorAnimation { duration: 90 } }
}
// Deck icons are usually light artwork on transparency, drawn for the
// hardware's black key faces — on a light theme they would wash out. Under
// a light theme only, back the preview with a dark plate standing in for
// the physical key. Dark themes need no plate and get none.
Rectangle {
anchors.centerIn: preview
width: preview.width * 1.12
height: preview.height * 1.12
radius: Style.cornerRadius > 0 ? Style.space(2) : 0
visible: preview.visible && root.lightTheme
color: root.keyFace
opacity: 0.9
}
// Icon preview, when the key has one and it is a format Image decodes
// cheaply. Falls back to the kind glyph below.
Image {
id: preview
anchors.centerIn: parent
// Lifted off centre so the artwork clears the label strip along the
// bottom edge of the cell.
anchors.verticalCenterOffset: -Style.space(4)
width: parent.width * 0.46
height: width
visible: root.showIconPreviews && status === Image.Ready
fillMode: Image.PreserveAspectFit
smooth: true
asynchronous: true
cache: true
sourceSize.width: Math.max(24, Math.round(width))
sourceSize.height: Math.max(24, Math.round(width))
source: {
if (!root.showIconPreviews || !cell.configured) return ""
var path = String(cell.slot.iconPath || "")
if (path === "") return ""
var lower = path.toLowerCase()
if (!lower.endsWith(".png") && !lower.endsWith(".jpg") && !lower.endsWith(".jpeg")) return ""
return Util.fileUrl(path)
}
opacity: cell.configured ? 1.0 : 0.4
}
Text {
anchors.centerIn: parent
anchors.verticalCenterOffset: -Style.space(4)
visible: cell.configured && !preview.visible
text: Model.kindGlyph(String(cell.slot.kind || ""))
color: root.foreground
opacity: 0.75
font.family: root.fontFamily
font.pixelSize: Math.max(Style.font.caption, cell.height * 0.34)
}
// Slot number, always visible so the grid maps onto config.yaml indices.
Text {
anchors.top: parent.top
anchors.left: parent.left
anchors.topMargin: Style.space(2)
anchors.leftMargin: Style.space(3)
text: cell.keyIndex
color: root.foreground
opacity: cell.configured ? 0.45 : 0.28
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Text {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottomMargin: Style.space(2)
anchors.leftMargin: Style.space(2)
anchors.rightMargin: Style.space(2)
visible: cell.configured && Model.gridLabel(cell.slot) !== ""
text: Model.gridLabel(cell.slot)
color: root.foreground
opacity: 0.8
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
// A privileged key is worth flagging: pressing it from here is refused by
// the CLI, and it behaves differently on the deck itself.
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Style.space(3)
anchors.rightMargin: Style.space(3)
visible: cell.slot.privileged === true
width: Style.space(4)
height: width
radius: width / 2
color: Color.urgent
opacity: 0.85
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onEntered: root.keyFocused(cell.keyIndex)
onClicked: function(event) {
if (event.button === Qt.RightButton) root.keySecondary(cell.keyIndex)
else root.keyActivated(cell.keyIndex)
}
}
}
}
}

21
omarchy-plugin/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Levi Woodard
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

310
omarchy-plugin/Model.js Normal file
View File

@@ -0,0 +1,310 @@
.pragma library
// Pure helpers for the Stream Deck panel. Kept out of the QML so the parsing and
// formatting rules can be reasoned about (and changed) without touching layout.
var EMPTY_STATUS = {
ok: false,
configPath: "",
modulesPath: "",
iconsDir: "",
brightness: 0,
configError: "",
daemon: { unit: "", active: false, enabled: false, state: "unknown", sub: "", sinceSec: 0 },
device: { connected: false, known: false, model: "", keyCount: 0, cols: 0, rows: 0 },
keys: [],
icons: [],
modules: [],
warnings: []
}
// parseStatus turns streamdeck-ctl's stdout into a status object, filling in
// anything the CLI omitted so bindings never dereference undefined.
function parseStatus(raw) {
var parsed
try {
parsed = JSON.parse(String(raw || ""))
} catch (e) {
return { ok: false, error: "Could not parse streamdeck-ctl output" }
}
if (!parsed || typeof parsed !== "object") {
return { ok: false, error: "Unexpected streamdeck-ctl output" }
}
var status = {}
for (var field in EMPTY_STATUS) status[field] = EMPTY_STATUS[field]
for (var key in parsed) status[key] = parsed[key]
status.daemon = Object.assign({}, EMPTY_STATUS.daemon, parsed.daemon || {})
status.device = Object.assign({}, EMPTY_STATUS.device, parsed.device || {})
status.keys = parsed.keys || []
status.icons = parsed.icons || []
status.modules = parsed.modules || []
status.warnings = parsed.warnings || []
return status
}
// keySlots expands the sparse key list from the CLI into one entry per physical
// slot, so the grid can render empty positions without the view doing lookups.
function keySlots(status) {
var cols = Number(status.device.cols) || 8
var rows = Number(status.device.rows) || 4
var count = Number(status.device.keyCount) || (cols * rows)
var configured = {}
for (var i = 0; i < status.keys.length; i++) {
configured[Number(status.keys[i].index)] = status.keys[i]
}
var slots = []
for (var index = 0; index < count; index++) {
if (configured[index]) {
// Copy rather than annotate — this library's helpers must not mutate the
// status object they were handed.
slots.push(Object.assign({ configured: true }, configured[index]))
} else {
slots.push({ index: index, configured: false, kind: "empty", label: "" })
}
}
return slots
}
// statusLine is the one-line summary under the panel title.
function statusLine(status) {
if (status.configError) return "Config error"
if (!status.device.connected) return status.daemon.active ? "Waiting for deck" : "Deck disconnected"
if (!status.daemon.active) return "Daemon stopped"
var used = countConfigured(status)
var total = Number(status.device.keyCount) || 0
return used + " of " + total + " keys in use"
}
function countConfigured(status) {
// Count only keys the connected device can actually show, so an XL config
// against an MK.2 can't read "17 of 15 keys in use".
var limit = Number(status.device.keyCount) || Infinity
var used = 0
for (var i = 0; i < status.keys.length; i++) {
if (status.keys[i].kind !== "empty" && Number(status.keys[i].index) < limit) used++
}
return used
}
// deviceLine names the hardware, falling back to the raw USB IDs when the
// product ID isn't in the daemon's supported-models table.
function deviceLine(status) {
if (status.device.known && status.device.model) return status.device.model
if (status.device.productId) return "Unknown (" + status.device.productId + ")"
return "Unknown"
}
function daemonLine(status) {
var daemon = status.daemon
if (daemon.active) {
var uptime = formatDuration(Number(daemon.sinceSec) || 0)
return uptime ? "Running · " + uptime : "Running"
}
if (daemon.state === "failed") return "Failed"
if (daemon.state === "unknown") return "Not installed"
return daemon.enabled ? "Stopped" : "Stopped (disabled)"
}
function formatDuration(seconds) {
if (!seconds || seconds < 60) return seconds > 0 ? seconds + "s" : ""
var minutes = Math.floor(seconds / 60)
if (minutes < 60) return minutes + "m"
var hours = Math.floor(minutes / 60)
if (hours < 24) return hours + "h " + (minutes % 60) + "m"
var days = Math.floor(hours / 24)
return days + "d " + (hours % 24) + "h"
}
// kindGlyph maps a key kind to a Nerd Font glyph for the grid cell. Kinds come
// from streamdeck-ctl, so this list tracks KeyStatus.Kind in status.go.
function kindGlyph(kind) {
switch (kind) {
case "toggle": return "󰄣" // toggle-switch
case "module": return "󰅵" // puzzle piece
case "text": return "󰉼" // text
case "static": return "󰃐" // image
default: return ""
}
}
// describeKey is the detail line shown when a grid cell is focused. Module keys
// show only module · function — their rendered command can inline secrets, so
// streamdeck-ctl no longer emits it at all.
function describeKey(key) {
if (!key || !key.configured) return "Empty slot — click to configure"
var parts = []
if (key.module && key.function) parts.push(key.module + " · " + key.function)
else if (key.command) parts.push(truncate(key.command, 70))
if (key.poll) parts.push("polls every " + (key.poll.interval || "2s"))
if (key.privileged) parts.push("privileged")
return parts.length ? parts.join(" · ") : "No command"
}
function truncate(text, limit) {
var value = String(text || "").replace(/\s+/g, " ").trim()
return value.length > limit ? value.substring(0, limit - 1) + "…" : value
}
// gridLabel keeps cell text to something that fits an 8-column grid.
function gridLabel(key) {
if (!key || !key.configured) return ""
return truncate(key.label || "", 10)
}
// iconOptions builds the dropdown model for icon selection: "(none)" plus every
// image file the CLI found in icons_dir.
function iconOptions(status) {
var options = [{ value: "", label: "(none)" }]
for (var i = 0; i < status.icons.length; i++) {
options.push({ value: status.icons[i], label: status.icons[i] })
}
return options
}
function moduleOptions(status) {
var options = [{ value: "", label: "(no module)" }]
for (var i = 0; i < status.modules.length; i++) {
options.push({ value: status.modules[i].name, label: status.modules[i].name })
}
return options
}
function functionOptions(status, moduleName) {
var options = [{ value: "", label: "(no function)" }]
if (!moduleName) return options
for (var i = 0; i < status.modules.length; i++) {
if (status.modules[i].name !== moduleName) continue
var functions = status.modules[i].functions || []
for (var j = 0; j < functions.length; j++) {
options.push({ value: functions[j].name, label: functions[j].name })
}
}
return options
}
// functionParams returns the declared default params for a module function, used
// to seed the editor's parameter rows.
function functionParams(status, moduleName, functionName) {
for (var i = 0; i < status.modules.length; i++) {
if (status.modules[i].name !== moduleName) continue
var functions = status.modules[i].functions || []
for (var j = 0; j < functions.length; j++) {
if (functions[j].name === functionName) return functions[j].params || {}
}
}
return {}
}
// paramRows merges a function's declared defaults with the values already set on
// the key, so the editor shows every parameter the function accepts.
function paramRows(status, moduleName, functionName, current) {
var defaults = functionParams(status, moduleName, functionName)
var names = {}
var name
for (name in defaults) names[name] = true
for (name in (current || {})) names[name] = true
var ordered = Object.keys(names).sort()
var rows = []
for (var i = 0; i < ordered.length; i++) {
name = ordered[i]
var value = (current && current[name] !== undefined) ? current[name] : defaults[name]
rows.push({
name: name,
value: value === undefined || value === null ? "" : String(value),
placeholder: defaults[name] === undefined ? "" : String(defaults[name])
})
}
return rows
}
// editorDraft snapshots a key into the flat shape the editor binds to.
function editorDraft(key) {
return {
index: key ? Number(key.index) : 0,
icon: key && key.icon ? key.icon : "",
text: key && key.text ? key.text : "",
textColor: key && key.textColor ? key.textColor : "",
command: key && key.command ? key.command : "",
module: key && key.module ? key.module : "",
function: key && key.function ? key.function : "",
params: key && key.params ? shallowCopy(key.params) : {}
}
}
function shallowCopy(source) {
var copy = {}
for (var name in source) copy[name] = source[name]
return copy
}
// editArguments turns an editor draft into streamdeck-ctl `key set` flags.
// Fields the user emptied are passed as -unset so they leave the YAML entirely
// rather than lingering as empty strings.
function editArguments(draft, original) {
var args = []
function apply(flag, field, value) {
var text = String(value || "").trim()
var had = String((original && original[field]) || "").trim() !== ""
if (text !== "") args.push(flag, text)
else if (had) args.push("-unset", yamlField(field))
}
apply("-icon", "icon", draft.icon)
apply("-text", "text", draft.text)
apply("-text-color", "textColor", draft.textColor)
apply("-command", "command", draft.command)
apply("-module", "module", draft.module)
apply("-function", "function", draft.function)
// Params only travel with a module function; without one they have nothing to
// substitute into, and the daemon would ignore them.
if (String(draft.module || "").trim() !== "" && String(draft.function || "").trim() !== "") {
var wrote = false
for (var name in draft.params) {
var value = draft.params[name]
if (value === undefined || value === null) continue
args.push("-param", name + "=" + value)
wrote = true
}
if (!wrote && original && original.params && Object.keys(original.params).length > 0) {
args.push("-unset", "params")
}
} else if (original && original.params && Object.keys(original.params).length > 0) {
args.push("-unset", "params")
}
return args
}
// yamlField maps the editor's camelCase field names to the YAML keys the CLI's
// -unset flag expects.
function yamlField(field) {
switch (field) {
case "textColor": return "text_color"
case "function": return "function"
default: return field
}
}
// draftDiffers reports whether the editor has unsaved changes.
function draftDiffers(draft, original) {
var fields = ["icon", "text", "textColor", "command", "module", "function"]
for (var i = 0; i < fields.length; i++) {
var field = fields[i]
if (String(draft[field] || "") !== String(original[field] || "")) return true
}
var name
for (name in draft.params) {
if (String(draft.params[name] || "") !== String((original.params || {})[name] || "")) return true
}
for (name in (original.params || {})) {
if (draft.params[name] === undefined) return true
}
return false
}

501
omarchy-plugin/Panel.qml Normal file
View File

@@ -0,0 +1,501 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Bar widget and popout panel for streamdeck-go.
//
// Every colour and metric resolves through qs.Commons (Color/Style), so the
// widget repaints itself to match whatever Omarchy theme is active rather than
// carrying a palette of its own.
Panel {
id: root
moduleName: "dev.woodard.streamdeck"
ipcTarget: "dev.woodard.streamdeck"
manageIpc: false
// ── theme-derived palette ──────────────────────────────────────────────────
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color accent: Color.accent
readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
// The bar icon dims when the deck is unreachable, the same convention the
// built-in network and bluetooth widgets use.
readonly property bool live: deck.connected && deck.daemonActive
readonly property color barIconColor: live ? barForeground : Qt.darker(barForeground, 1.55)
readonly property var slots: Model.keySlots(deck.status)
readonly property int cols: Number(deck.status.device.cols) || 8
readonly property int rows: Number(deck.status.device.rows) || 4
// ── panel navigation state ─────────────────────────────────────────────────
property int cursorIndex: 0
property bool cursorActive: false
property int editingIndex: -1
readonly property bool editing: editingIndex >= 0
// A device-shape change (XL -> MK.2, or the first real poll shrinking the
// fallback grid) can strand the cursor or the editor beyond the last slot.
onSlotsChanged: {
if (cursorIndex >= slots.length) cursorIndex = Math.max(0, slots.length - 1)
if (editing && editingIndex >= slots.length) closeEditor()
}
function moveCursor(dx, dy) {
cursorActive = true
if (editing) return
var next = cursorIndex + dx + dy * cols
if (next < 0 || next >= slots.length) return
cursorIndex = next
}
function activateCursor() {
if (editing) return
editKey(cursorIndex)
}
function focusKey(index) {
cursorActive = true
cursorIndex = index
}
function editKey(index) {
cursorIndex = index
editingIndex = index
// Focus lands inside the editor via its own onVisibleChanged handler —
// the key catcher is blocked while editing.
}
function closeEditor() {
editingIndex = -1
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
readonly property var focusedKey: {
for (var i = 0; i < slots.length; i++) {
if (Number(slots[i].index) === cursorIndex) return slots[i]
}
return null
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
visible: !(deck.setting("hideWhenDisconnected", false) === true && deck.loaded && !deck.connected)
onOpenedChanged: if (opened) {
cursorActive = false
editingIndex = -1
if (panelFlick) panelFlick.contentY = 0
deck.refresh()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
Service {
id: deck
settings: root.settings
panelOpen: root.opened
}
IpcHandler {
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function refresh(): string { deck.refresh(); return "ok" }
function status(): string { return Model.statusLine(deck.status) }
function brightness(level: string): string {
var value = parseInt(level, 10)
if (!isFinite(value)) return "expected a number 0-100"
deck.setBrightness(value)
return "ok"
}
function press(index: string): string {
var value = parseInt(index, 10)
if (!isFinite(value)) return "expected a key index"
deck.pressKey(value)
return "ok"
}
}
// ── bar icon ───────────────────────────────────────────────────────────────
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
iconComponent: Component {
Item {
StreamDeckIcon {
anchors.centerIn: parent
iconSize: Style.space(13)
color: root.barIconColor
// The icon fills in proportionally to how much of the deck is mapped,
// so a glance at the bar says whether the deck is live and loaded.
litKeys: {
if (!deck.connected) return 0
var total = Number(deck.status.device.keyCount) || 0
if (total === 0) return 6
var used = Model.countConfigured(deck.status)
return Math.max(1, Math.round((used / total) * 6))
}
opacity: root.live ? 1.0 : 0.6
}
}
}
onPressed: function(buttonCode) {
if (buttonCode === Qt.RightButton) deck.refresh()
else if (buttonCode === Qt.MiddleButton) deck.toggleDaemon()
else root.toggle()
}
}
// ── panel ──────────────────────────────────────────────────────────────────
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
// Wide enough that an 8-column grid still gives each key a legible face.
contentWidth: panel.fittedContentWidth(Style.space(520))
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(640))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
// While the editor is up its fields own the keyboard — the shipped-panel
// idiom for inline editors. Escape then bubbles to the editor's own
// handler instead of tearing down the whole panel mid-edit.
blocked: root.editing
onMoveRequested: function(dx, dy) {
if (!root.cursorActive) { root.cursorActive = true; return }
root.moveCursor(dx, dy)
}
onActivateRequested: if (root.cursorActive) root.activateCursor()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(t) {
if (t === "r" || t === "R") deck.refresh()
else if (t === "p" || t === "P") deck.toggleDaemon()
else if (t === "e" || t === "E") root.editKey(root.cursorIndex)
else if (t === " ") deck.pressKey(root.cursorIndex)
}
Flickable {
id: panelFlick
anchors.fill: parent
contentWidth: width
contentHeight: column.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
interactive: contentHeight > height
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
ColumnLayout {
id: column
width: panelFlick.width
spacing: Style.space(12)
// ── hero: title, status line, daemon switch ────────────────────────
Item {
id: header
Layout.fillWidth: true
implicitHeight: hero.implicitHeight
// Exposed for the hero's trailingControl, whose `root` resolves to
// PanelHero rather than this Panel.
readonly property bool ringVisible: false
PanelHero {
id: hero
width: parent.width
title: "Stream Deck"
meta: Model.statusLine(deck.status)
foreground: root.foreground
fontFamily: root.fontFamily
iconOpacity: root.live ? 1.0 : 0.5
iconComponent: Component {
StreamDeckIcon {
iconSize: Style.font.display
color: root.live ? root.foreground : root.dim
litKeys: deck.connected ? 6 : 0
}
}
trailingControl: Component {
ToggleSwitch {
id: powerSwitch
visible: !deck.ctlMissing && deck.status.daemon.state !== "unknown"
checked: deck.daemonActive
busy: deck.busy
foreground: hero.foreground
onToggled: deck.toggleDaemon()
PanelToolTip {
visible: powerSwitch.containsMouse
text: deck.daemonActive ? "Stop the daemon" : "Start the daemon"
fontFamily: hero.fontFamily
}
}
}
}
}
// ── messages ───────────────────────────────────────────────────────
Text {
Layout.fillWidth: true
visible: deck.actionStatus !== "" || deck.lastError !== ""
text: deck.actionStatus !== "" ? deck.actionStatus : deck.lastError
color: deck.lastError !== "" && deck.actionStatus === "" ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
// streamdeck-ctl is what makes this widget work at all; if it isn't
// installed, say so plainly instead of showing dead controls.
ColumnLayout {
Layout.fillWidth: true
visible: deck.ctlMissing
spacing: Style.spacing.labelGap
Text {
Layout.fillWidth: true
text: "streamdeck-ctl was not found"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Text {
Layout.fillWidth: true
text: "Build and install it from the streamdeck-go repo:\nmake build-ctl && make install-ctl"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
// ── brightness ─────────────────────────────────────────────────────
ColumnLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.spacing.labelGap
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
PanelSectionHeader {
Layout.fillWidth: true
text: "BRIGHTNESS"
foreground: root.foreground
fontFamily: root.fontFamily
}
Text {
text: deck.brightness + "%"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
PanelSlider {
Layout.fillWidth: true
bar: root.bar
minimum: 0
maximum: 100
step: 5
integer: true
value: deck.brightness
enabled: deck.connected
opacity: deck.connected ? 1.0 : 0.45
onMoved: function(v) { deck.setBrightness(v) }
}
}
// ── device / daemon facts ──────────────────────────────────────────
ColumnLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.spacing.labelGap
InfoPair { label: "Device"; value: Model.deviceLine(deck.status) }
InfoPair {
label: "Connection"
value: deck.connected ? "Connected" : "Disconnected"
emphasised: !deck.connected
}
InfoPair { label: "Daemon"; value: Model.daemonLine(deck.status) }
}
PanelSeparator {
Layout.fillWidth: true
visible: !deck.ctlMissing
foreground: root.foreground
}
// ── key grid ───────────────────────────────────────────────────────
ColumnLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.space(8)
PanelSectionHeader {
Layout.fillWidth: true
text: "KEYS"
foreground: root.foreground
fontFamily: root.fontFamily
}
KeyGrid {
Layout.fillWidth: true
slots: root.slots
cols: root.cols
rows: root.rows
cursorIndex: root.cursorIndex
cursorActive: root.cursorActive
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onKeyFocused: function(index) { root.focusKey(index) }
// Left-click mirrors a physical press; editing is the secondary action.
onKeyActivated: function(index) { deck.pressKey(index) }
// Right-click fires the key, mirroring a physical press.
onKeySecondary: function(index) { root.editKey(index) }
}
Text {
Layout.fillWidth: true
text: root.focusedKey
? "Key " + root.cursorIndex + " — " + Model.describeKey(root.focusedKey)
: ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: "Click a key to run it · right-click to edit"
color: root.dim
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
// ── editor ─────────────────────────────────────────────────────────
KeyEditor {
id: editorPane
Layout.fillWidth: true
visible: root.editing
service: deck
status: deck.status
keyIndex: root.editingIndex
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClosed: root.closeEditor()
onSaved: root.closeEditor()
}
PanelSeparator {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
foreground: root.foreground
}
// ── actions ────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.space(6)
Button {
text: "Restart"
iconText: "󰑓"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClicked: deck.restartDaemon()
}
Button {
text: "Config"
iconText: "󰈔"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClicked: deck.openConfig()
}
Button {
text: "Icons"
iconText: "󰉋"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClicked: deck.openIcons()
}
Item { Layout.fillWidth: true }
PanelActionButton {
iconText: "󰌱"
tooltipText: "Follow the daemon log"
foreground: root.foreground
fontFamily: root.fontFamily
onClicked: deck.openLogs()
}
}
}
}
}
}
// InfoPair is a label on the left, value on the right, with the gap between
// them absorbing the leftover width.
component InfoPair: RowLayout {
id: pair
property string label: ""
property string value: ""
property bool emphasised: false
Layout.fillWidth: true
spacing: Style.space(8)
Text {
text: pair.label
color: root.foreground
opacity: 0.6
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
Item { Layout.fillWidth: true }
Text {
text: pair.value
color: pair.emphasised ? root.urgent : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
}
}

169
omarchy-plugin/README.md Normal file
View File

@@ -0,0 +1,169 @@
# Stream Deck — Omarchy shell plugin
A bar widget for [Omarchy](https://omarchy.org) that controls the
[streamdeck-go](https://git.i0t.app/lwoodard/streamdeck-go) daemon: device and
daemon status, brightness, and a live 8×4 key grid you can edit in place.
Every colour and metric resolves through Omarchy's `qs.Commons` theme tokens
(`Color.*`, `Style.*`), so the widget follows the active theme rather than
carrying a palette of its own.
---
## What it does
**Bar icon** — a miniature deck that fills in proportionally to how much of the
deck is mapped, and dims when the deck is unplugged or the daemon is stopped.
**Panel**
| Section | Behaviour |
|---|---|
| Header | Title, status line, and an on/off switch for the `streamdeck-go` user service |
| Brightness | Slider writing `brightness:` into `config.yaml`; the daemon hot-reloads, so the deck dims as you drag |
| Facts | Device model, connection state, daemon state and uptime |
| Key grid | One cell per physical key, with icon previews, slot indices, and labels. Empty slots are shown so you can fill them |
| Editor | Right-click a key to edit its module/function, parameters, shell command, icon, label, and label colour |
| Actions | Restart the daemon, open `config.yaml`, open the icons folder, follow the log |
A red dot on a cell marks a `priv:` key. Those run through the privileged helper
and are deliberately **not** runnable from the panel — press them on the deck.
### Mouse and keyboard
| Input | Action |
|---|---|
| Left-click bar icon | Open/close the panel |
| Right-click bar icon | Refresh |
| Middle-click bar icon | Start/stop the daemon |
| Left-click a key | Run that key's command (fire-and-forget, like a physical press) |
| Right-click a key | Open the editor for that key |
| Arrow keys | Move the grid cursor |
| `Enter` | Edit the focused key |
| `Space` | Run the focused key |
| `e` | Edit the focused key |
| `r` | Refresh |
| `p` | Start/stop the daemon |
| `Esc` | Leave the field → close the editor → close the panel |
---
## Requirements
- Omarchy with `omarchy-shell` running
- `streamdeck-go` installed and its user service present
- **`streamdeck-ctl`** — the helper CLI the plugin drives. The panel refuses to
show controls without it and tells you how to install it.
The plugin never edits YAML or probes USB itself; it shells out to
`streamdeck-ctl`, which reuses the daemon's own config and device code. That
keeps one implementation of the config format rather than two.
---
## Install
From a checkout of the streamdeck-go repo:
```bash
make install-plugin # builds + installs streamdeck-ctl, links the plugin
omarchy plugin enable dev.woodard.streamdeck right
```
`make install-plugin` symlinks this directory into
`~/.config/omarchy/plugins/dev.woodard.streamdeck`, so edits to the QML are live
after a rescan:
```bash
omarchy-shell shell rescanPlugins
```
> Qt caches compiled QML in `~/.cache/quickshell/qmlcache`, and a running shell
> can keep serving a stale compiled unit across a rescan. If an edit doesn't seem
> to take effect, `omarchy restart shell`.
To validate before committing:
```bash
make validate-plugin
```
`omarchy plugin validate` refuses a symlinked plugin folder by design, so this
target validates the source directory rather than the installed link.
### Remove
```bash
make uninstall-plugin
```
---
## Settings
Configurable from the Omarchy bar widget settings:
| Setting | Default | Meaning |
|---|---|---|
| `refreshIntervalSec` | 10 | Poll cadence while the panel is open; closed, it relaxes to at most once a minute |
| `ctlPath` | *(empty)* | Explicit path to `streamdeck-ctl`; empty searches `~/.local/bin`, `~/go/bin`, `/usr/local/bin`, then `$PATH` |
| `configPath` | *(empty)* | Explicit `config.yaml`; empty uses `~/.config/streamdeck-go/config.yaml` |
| `hideWhenDisconnected` | false | Hide the bar icon entirely when no deck is connected |
---
## IPC
```bash
omarchy-shell dev.woodard.streamdeck open
omarchy-shell dev.woodard.streamdeck toggle
omarchy-shell dev.woodard.streamdeck status # "20 of 32 keys in use"
omarchy-shell dev.woodard.streamdeck refresh
omarchy-shell dev.woodard.streamdeck brightness 60
omarchy-shell dev.woodard.streamdeck press 3
```
Useful for Hyprland binds — e.g. a key that dims the deck without opening the bar.
---
## Security note
Module keys can reference secrets via `{{env "TOKEN"}}` in their exec templates.
`streamdeck-ctl status` never emits the rendered command — the panel shows only
`module · function` for module keys — so tokens can't leak into the UI or into
anything scraping the CLI's JSON. On Linux, `key press` merges the systemd user
manager's environment over the caller's before running, so a token imported with
`systemctl --user import-environment` reaches panel presses exactly as it reaches
hardware presses. (macOS keeps the caller's environment — launchd has no
equivalent bulk query.)
## A note on config formatting
Saving from the editor rewrites `config.yaml` through a YAML node-level editor.
Comments and blank lines are preserved, but two normalisations happen on any
save:
- trailing whitespace on values is trimmed
- a comment block dangling at the end of a key entry may be re-indented to match
the block it attaches to
Both are cosmetic. Values, ordering, and comment text are preserved.
---
## Files
```
manifest.json plugin metadata + settings schema
Panel.qml bar icon and popout panel (entry point)
Service.qml owns every streamdeck-ctl invocation
KeyGrid.qml the 8x4 (or 5x3) key grid
KeyEditor.qml per-key editor
StreamDeckIcon.qml drawn deck icon, takes its colour from the caller
Model.js parsing and formatting helpers
```
## License
MIT — see [LICENSE](LICENSE).

311
omarchy-plugin/Service.qml Normal file
View File

@@ -0,0 +1,311 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "Model.js" as Model
// Service owns every conversation with streamdeck-ctl. The panel binds to the
// properties here and calls the functions; it never spawns a process itself.
Item {
id: root
property var settings: ({})
// Set by the panel. While the popout is closed only the bar icon consumes
// status, so polling relaxes to at most once a minute.
property bool panelOpen: false
// Full snapshot from `streamdeck-ctl status`, normalised by Model.parseStatus.
property var status: Model.EMPTY_STATUS
property bool loaded: false
property bool refreshing: false
property string lastError: ""
property string actionStatus: ""
// True once we have looked for the binary and failed — the panel shows install
// guidance instead of controls in that state.
property bool ctlMissing: false
property string ctlResolved: ""
readonly property bool busy: statusProcess.running || actionProcess.running
readonly property bool connected: status.device.connected === true
readonly property bool daemonActive: status.daemon.active === true
// Brightness is echoed optimistically so the slider tracks the drag instead of
// snapping back on the next poll, which lands up to refreshIntervalSec later.
property int pendingBrightness: -1
readonly property int brightness: pendingBrightness >= 0 ? pendingBrightness : (Number(status.brightness) || 0)
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 10, 2, 600)
readonly property string configPath: String(setting("configPath", "") || "")
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null || value === "" ? fallback : value
}
function intSetting(name, fallback, min, max) {
var n = parseInt(String(setting(name, fallback)), 10)
if (!isFinite(n)) n = fallback
return Math.max(min, Math.min(max, n))
}
// ── locating streamdeck-ctl ────────────────────────────────────────────────
//
// The shell inherits a minimal PATH under systemd, so ~/.local/bin (Linux) and
// ~/go/bin (macOS, and Go's default install target) are checked explicitly
// before falling back to a PATH lookup.
readonly property string home: Quickshell.env("HOME")
readonly property var candidatePaths: [
String(setting("ctlPath", "") || ""),
home + "/.local/bin/streamdeck-ctl",
home + "/go/bin/streamdeck-ctl",
"/usr/local/bin/streamdeck-ctl"
]
function resolveCtl() {
if (locateProcess.running) return
locateProcess.command = ["sh", "-c", locateScript()]
locateProcess.running = true
}
// A changed ctlPath setting (or HOME, in theory) must invalidate the cached
// location — without this, ctlResolved is sticky for the life of the shell.
onCandidatePathsChanged: {
ctlResolved = ""
ctlMissing = false
resolveCtl()
}
function locateScript() {
var checks = []
for (var i = 0; i < candidatePaths.length; i++) {
var path = candidatePaths[i]
if (!path) continue
checks.push('[ -x ' + shellQuote(path) + ' ] && { printf %s ' + shellQuote(path) + '; exit 0; }')
}
checks.push('command -v streamdeck-ctl 2>/dev/null || true')
return checks.join("\n")
}
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'"
}
// baseArgs prefixes every invocation, carrying the -config override when the
// widget was pointed at a non-default config.
function baseArgs() {
var args = []
if (configPath !== "") args.push("-config", configPath)
return args
}
// ── reading ────────────────────────────────────────────────────────────────
function refresh() {
if (ctlResolved === "") {
if (!locateProcess.running) resolveCtl()
return
}
if (statusProcess.running) return
refreshing = true
statusProcess.command = [ctlResolved].concat(baseArgs()).concat(["status"])
statusProcess.running = true
}
// ── writing ────────────────────────────────────────────────────────────────
function setBrightness(value) {
var clamped = Math.max(0, Math.min(100, Math.round(value)))
pendingBrightness = clamped
brightnessDebounce.restart()
}
function toggleDaemon() {
runAction(["daemon", "toggle"], daemonActive ? "Stopping daemon…" : "Starting daemon…")
}
function restartDaemon() {
runAction(["daemon", "restart"], "Restarting daemon…")
}
function pressKey(index) {
runAction(["key", "press", String(index)], "")
}
function clearKey(index) {
runAction(["key", "clear", String(index)], "Cleared key " + index)
}
// saveKey takes the flag list Model.editArguments built. Returning early on an
// empty list keeps a no-op "Save" from rewriting the config file.
function saveKey(index, flags) {
if (!flags || flags.length === 0) {
actionStatus = "No changes"
actionStatusTimer.restart()
return
}
runAction(["key", "set", String(index)].concat(flags), "Saved key " + index)
}
// Writes are queued, not dropped: a save clicked while another action is in
// flight must still land. Brightness entries coalesce to the newest value so
// a slider drag queues one write, not a backlog of intermediate positions.
property var actionQueue: []
function runAction(args, message) {
if (ctlResolved === "") { lastError = "streamdeck-ctl not found"; return }
if (args[0] === "brightness") {
actionQueue = actionQueue.filter(function(entry) { return entry.args[0] !== "brightness" })
}
actionQueue.push({ args: args, message: message })
pumpActions()
}
function pumpActions() {
if (actionProcess.running || actionQueue.length === 0) return
var next = actionQueue.shift()
actionProcess.pendingMessage = next.message
actionProcess.command = [ctlResolved].concat(baseArgs()).concat(next.args)
actionProcess.running = true
}
function openConfig() {
var path = status.configPath || (home + "/.config/streamdeck-go/config.yaml")
Quickshell.execDetached(["uwsm-app", "--", "xdg-open", path])
}
function openLogs() {
Quickshell.execDetached(["uwsm-app", "--", "omarchy-launch-floating-terminal-with-presentation",
"journalctl", "--user", "-u", "streamdeck-go.service", "-f", "-n", "200"])
}
function openIcons() {
var dir = status.iconsDir || (home + "/.config/streamdeck-go/icons")
Quickshell.execDetached(["uwsm-app", "--", "xdg-open", dir])
}
// ── processes ──────────────────────────────────────────────────────────────
Process {
id: locateProcess
running: false
command: []
stdout: StdioCollector { id: locateStdout; waitForEnd: true }
onExited: function(exitCode) {
var found = String(locateStdout.text || "").trim().split("\n")[0].trim()
if (found !== "") {
root.ctlResolved = found
root.ctlMissing = false
root.refresh()
} else {
root.ctlMissing = true
root.loaded = true
}
}
}
Process {
id: statusProcess
running: false
command: []
stdout: StdioCollector { id: statusStdout; waitForEnd: true }
stderr: StdioCollector { id: statusStderr; waitForEnd: true }
onExited: function(exitCode) {
root.refreshing = false
root.loaded = true
// A failure with empty stdout is spawn-shaped (the CLI always emits JSON,
// even on error) — the binary may have been moved or removed. Drop the
// cached location so the next tick re-probes.
if (exitCode !== 0 && String(statusStdout.text || "").trim() === "") {
root.ctlResolved = ""
root.lastError = "streamdeck-ctl failed to run"
return
}
var parsed = Model.parseStatus(statusStdout.text)
if (parsed.ok === false && parsed.error) {
root.lastError = parsed.error
return
}
root.status = parsed
root.lastError = parsed.configError ? parsed.configError
: (exitCode !== 0 ? String(statusStderr.text || "").trim() : "")
// The poll caught up with the optimistic value — stop overriding it.
if (root.pendingBrightness >= 0 && Number(parsed.brightness) === root.pendingBrightness) {
root.pendingBrightness = -1
}
}
}
Process {
id: actionProcess
property string pendingMessage: ""
running: false
command: []
stdout: StdioCollector { id: actionStdout; waitForEnd: true }
stderr: StdioCollector { id: actionStderr; waitForEnd: true }
onExited: function(exitCode) {
if (exitCode === 0) {
root.lastError = ""
if (actionProcess.pendingMessage !== "") {
root.actionStatus = actionProcess.pendingMessage
actionStatusTimer.restart()
}
} else {
var message = String(actionStderr.text || actionStdout.text || "").trim()
root.lastError = Model.truncate(message || "streamdeck-ctl failed", 160)
root.actionStatus = ""
// A failed brightness write must release the optimistic value, or the
// slider shows a level the deck never reached — forever, since no poll
// will ever confirm it.
root.pendingBrightness = -1
}
root.pumpActions()
// Daemon transitions take a moment to settle; re-poll shortly after rather
// than waiting for the next scheduled refresh.
settleTimer.restart()
root.refresh()
}
}
// ── timers ─────────────────────────────────────────────────────────────────
Timer {
id: refreshTimer
// The bar icon doesn't need panel-grade freshness; the panel refreshes
// explicitly on open, so a slow background cadence costs nothing visible.
interval: (root.panelOpen ? root.refreshIntervalSec
: Math.max(root.refreshIntervalSec, 60)) * 1000
repeat: true
running: true
triggeredOnStart: true
onTriggered: root.refresh()
}
Timer {
// Dragging the slider would otherwise rewrite config.yaml on every pixel,
// and each write triggers a daemon reload. Coalesce to the last value.
id: brightnessDebounce
interval: 180
repeat: false
onTriggered: {
if (root.pendingBrightness < 0) return
root.runAction(["brightness", String(root.pendingBrightness)], "")
}
}
Timer {
id: settleTimer
interval: 1200
repeat: false
onTriggered: root.refresh()
}
Timer {
id: actionStatusTimer
interval: 2400
repeat: false
onTriggered: root.actionStatus = ""
}
Component.onCompleted: resolveCtl()
}

View File

@@ -0,0 +1,52 @@
import QtQuick
import qs.Commons
// A 3x2 grid of keys, drawn rather than glyphed so it scales cleanly and takes
// its colour straight from the caller (and therefore from the active theme).
Item {
id: root
property real iconSize: Style.space(14)
property color color: Color.foreground
// Lit keys are drawn filled; the rest are outlined. The panel uses this to
// show the deck's state at a glance without a second icon.
property int litKeys: 6
implicitWidth: iconSize
implicitHeight: iconSize
readonly property int cols: 3
readonly property int rows: 2
readonly property real gap: Math.max(1, iconSize * 0.1)
readonly property real cell: (iconSize - gap * (cols - 1)) / cols
readonly property real cellHeight: (iconSize * 0.72 - gap * (rows - 1)) / rows
Item {
anchors.centerIn: parent
width: root.iconSize
height: root.cellHeight * root.rows + root.gap * (root.rows - 1)
Repeater {
model: root.cols * root.rows
Rectangle {
required property int index
readonly property int column: index % root.cols
readonly property int row: Math.floor(index / root.cols)
readonly property bool lit: index < root.litKeys
x: column * (root.cell + root.gap)
y: row * (root.cellHeight + root.gap)
width: root.cell
height: root.cellHeight
radius: Style.cornerRadius > 0 ? Math.max(1, root.cell * 0.22) : 0
color: lit ? root.color : "transparent"
border.width: lit ? 0 : Math.max(1, Math.round(root.iconSize * 0.07))
border.color: root.color
opacity: lit ? 1.0 : 0.45
}
}
}
}

View File

@@ -0,0 +1,59 @@
{
"schemaVersion": 1,
"id": "dev.woodard.streamdeck",
"name": "Stream Deck",
"version": "1.0.0",
"author": "Levi Woodard",
"license": "MIT",
"description": "Control the streamdeck-go daemon from the Omarchy bar: device status, brightness, and a live key grid you can edit in place.",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Stream Deck",
"description": "Deck and daemon status, brightness, and inline key editing for streamdeck-go.",
"category": "Hardware",
"allowMultiple": false,
"defaultSection": "right",
"defaults": {
"refreshIntervalSec": 10,
"ctlPath": "",
"configPath": "",
"hideWhenDisconnected": false
},
"schema": [
{
"key": "refreshIntervalSec",
"type": "integer",
"label": "Refresh interval (seconds)",
"min": 2,
"max": 600,
"step": 1,
"defaultValue": 10
},
{
"key": "ctlPath",
"type": "string",
"label": "Path to streamdeck-ctl",
"description": "Leave empty to search ~/.local/bin, ~/go/bin, then $PATH.",
"defaultValue": ""
},
{
"key": "configPath",
"type": "string",
"label": "Path to config.yaml",
"description": "Leave empty to use ~/.config/streamdeck-go/config.yaml.",
"defaultValue": ""
},
{
"key": "hideWhenDisconnected",
"type": "boolean",
"label": "Hide the bar icon when no deck is connected",
"defaultValue": false
}
]
}
}