Compare commits
1 Commits
2aa4c65e42
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69d2926a1a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,7 +3,6 @@
|
|||||||
/streamdeck-go
|
/streamdeck-go
|
||||||
/streamdeck-helper
|
/streamdeck-helper
|
||||||
/streamdeck-init
|
/streamdeck-init
|
||||||
/streamdeck-ctl
|
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
|
|||||||
54
Makefile
54
Makefile
@@ -1,16 +1,10 @@
|
|||||||
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)
|
||||||
|
|
||||||
@@ -30,9 +24,7 @@ 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 build-ctl install install-ctl install-helper install-watchdog \
|
.PHONY: build build-helper build-init install install-helper install-watchdog reinstall uninstall uninstall-helper uninstall-watchdog udev
|
||||||
install-plugin uninstall-plugin validate-plugin reinstall uninstall uninstall-helper \
|
|
||||||
uninstall-watchdog udev
|
|
||||||
|
|
||||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -45,9 +37,6 @@ 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.
|
||||||
@@ -234,47 +223,6 @@ 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)
|
||||||
|
|||||||
14
OMARCHY.md
14
OMARCHY.md
@@ -6,20 +6,6 @@ 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
|
||||||
|
|||||||
@@ -1,443 +0,0 @@
|
|||||||
// 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(¶ms, "param", "module parameter as KEY=VALUE (repeatable)")
|
|
||||||
fs.Var(&unset, "unset", "remove a field (repeatable)")
|
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
edit := config.KeyEdit{Set: map[string]any{}}
|
|
||||||
poll := map[string]string{}
|
|
||||||
pollTouched := false
|
|
||||||
|
|
||||||
// flag.Visit only reports flags the caller actually set — that distinction is
|
|
||||||
// what makes this a merge rather than a full overwrite.
|
|
||||||
fs.Visit(func(f *flag.Flag) {
|
|
||||||
ptr, ok := scalars[f.Name]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if field, isTop := yamlField[f.Name]; isTop {
|
|
||||||
edit.Set[field] = *ptr
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch f.Name {
|
|
||||||
case "poll-command":
|
|
||||||
poll["command"] = *ptr
|
|
||||||
pollTouched = true
|
|
||||||
case "poll-match":
|
|
||||||
poll["match"] = *ptr
|
|
||||||
pollTouched = true
|
|
||||||
case "poll-interval":
|
|
||||||
poll["interval"] = *ptr
|
|
||||||
pollTouched = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(params) > 0 {
|
|
||||||
parsed := map[string]string{}
|
|
||||||
for _, kv := range params {
|
|
||||||
name, value, found := strings.Cut(kv, "=")
|
|
||||||
if !found || name == "" {
|
|
||||||
return fmt.Errorf("key set: -param %q is not KEY=VALUE", kv)
|
|
||||||
}
|
|
||||||
parsed[name] = value
|
|
||||||
}
|
|
||||||
edit.Set["params"] = parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
if pollTouched {
|
|
||||||
// The poll block is written whole: it is small, and merging into a nested
|
|
||||||
// mapping would need the existing values read back anyway.
|
|
||||||
existing := existingPoll(cfgPath, index)
|
|
||||||
for name, value := range poll {
|
|
||||||
existing[name] = value
|
|
||||||
}
|
|
||||||
for name, value := range existing {
|
|
||||||
if value == "" {
|
|
||||||
delete(existing, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(existing) == 0 {
|
|
||||||
edit.Unset = append(edit.Unset, "poll")
|
|
||||||
} else {
|
|
||||||
edit.Set["poll"] = existing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
edit.Unset = append(edit.Unset, normalizeUnset(unset)...)
|
|
||||||
|
|
||||||
if len(edit.Set) == 0 && len(edit.Unset) == 0 {
|
|
||||||
return fmt.Errorf("key set: nothing to change — pass at least one field flag")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.ApplyKeyEdit(cfgPath, index, edit); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
emit(map[string]any{"ok": true, "index": index, "set": edit.Set, "unset": edit.Unset})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeUnset accepts either flag-style or YAML-style field names, so callers
|
|
||||||
// can pass -unset icon-true or -unset icon_true.
|
|
||||||
func normalizeUnset(names []string) []string {
|
|
||||||
out := make([]string, 0, len(names))
|
|
||||||
for _, name := range names {
|
|
||||||
out = append(out, strings.ReplaceAll(strings.TrimSpace(name), "-", "_"))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// existingPoll reads the current poll block for a key so partial poll edits merge
|
|
||||||
// instead of dropping the fields the caller didn't mention.
|
|
||||||
func existingPoll(cfgPath string, index int) map[string]string {
|
|
||||||
out := map[string]string{}
|
|
||||||
cfg, err := config.Load(cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
key, ok := cfg.Keys[index]
|
|
||||||
if !ok || key.Poll == nil {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
if key.Poll.Command != "" {
|
|
||||||
out["command"] = key.Poll.Command
|
|
||||||
}
|
|
||||||
if key.Poll.Match != "" {
|
|
||||||
out["match"] = key.Poll.Match
|
|
||||||
}
|
|
||||||
if key.Poll.Interval != "" {
|
|
||||||
out["interval"] = key.Poll.Interval
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// keyPress runs the key's command exactly as the daemon would, including module
|
|
||||||
// resolution, so a front-end can fire a key without touching the hardware.
|
|
||||||
func keyPress(cfgPath string, index int) error {
|
|
||||||
cfg, err := config.Load(cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
key, ok := cfg.Keys[index]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("key %d is not configured", index)
|
|
||||||
}
|
|
||||||
|
|
||||||
command := key.Command
|
|
||||||
if key.Module != "" && key.Function != "" {
|
|
||||||
reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
command, err = reg.Resolve(key.Module, key.Function, key.Params)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(command) == "" {
|
|
||||||
return fmt.Errorf("key %d has no command to run", index)
|
|
||||||
}
|
|
||||||
|
|
||||||
if after, found := strings.CutPrefix(command, "priv:"); found {
|
|
||||||
return fmt.Errorf("key %d runs privileged command %q — press it on the deck instead", index, after)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirror the daemon's press semantics: fire and forget. Waiting here would
|
|
||||||
// wedge the caller (and the panel's serialized action pipeline) on any key
|
|
||||||
// that launches a long-lived app. The exit code covers "could not start";
|
|
||||||
// the command's own outcome is its business, as on the hardware.
|
|
||||||
cmd := exec.Command("sh", "-c", command)
|
|
||||||
cmd.Env = pressEnvironment()
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return fmt.Errorf("key %d: start command: %w", index, err)
|
|
||||||
}
|
|
||||||
go func() { _ = cmd.Wait() }() // reap if it finishes before we exit
|
|
||||||
emit(map[string]any{"ok": true, "index": index, "started": true})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pressEnvironment builds the env a pressed key runs under. The daemon runs a
|
|
||||||
// key with the systemd user manager's environment, but this CLI is usually
|
|
||||||
// invoked from the shell (a different, often smaller env) — so a module key
|
|
||||||
// whose token was imported only into the user manager would work on hardware
|
|
||||||
// and silently no-op from the panel. Merging the manager's variables over the
|
|
||||||
// caller's closes that gap on Linux; macOS keeps the caller's env (launchd has
|
|
||||||
// no equivalent bulk query).
|
|
||||||
func pressEnvironment() []string {
|
|
||||||
env := os.Environ()
|
|
||||||
if runtime.GOOS == "darwin" {
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
out, err := exec.Command("systemctl", "--user", "show-environment").Output()
|
|
||||||
if err != nil {
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overlay by name rather than appending: with duplicate keys in an env
|
|
||||||
// array, which copy getenv returns is libc-dependent.
|
|
||||||
merged := make(map[string]string, len(env))
|
|
||||||
order := make([]string, 0, len(env))
|
|
||||||
set := func(line string) {
|
|
||||||
name, value, found := strings.Cut(line, "=")
|
|
||||||
if !found || name == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, seen := merged[name]; !seen {
|
|
||||||
order = append(order, name)
|
|
||||||
}
|
|
||||||
merged[name] = value
|
|
||||||
}
|
|
||||||
for _, line := range env {
|
|
||||||
set(line)
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(string(out), "\n") {
|
|
||||||
// systemd shell-quotes values that need it ($'...'); those few can't be
|
|
||||||
// used verbatim, and dropping them beats injecting mangled quoting.
|
|
||||||
if strings.Contains(line, "=$'") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Manager value wins over the caller's: that's what the hardware sees.
|
|
||||||
set(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]string, 0, len(order))
|
|
||||||
for _, name := range order {
|
|
||||||
result = append(result, name+"="+merged[name])
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── daemon ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func cmdDaemon(args []string) error {
|
|
||||||
if len(args) != 1 {
|
|
||||||
return fmt.Errorf("daemon: expected start|stop|restart|toggle|status")
|
|
||||||
}
|
|
||||||
action := args[0]
|
|
||||||
|
|
||||||
if action == "toggle" {
|
|
||||||
if daemonState().Active {
|
|
||||||
action = "stop"
|
|
||||||
} else {
|
|
||||||
action = "start"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch action {
|
|
||||||
case "status":
|
|
||||||
emit(map[string]any{"ok": true, "daemon": daemonState()})
|
|
||||||
return nil
|
|
||||||
case "start", "stop", "restart":
|
|
||||||
if err := controlDaemon(action); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
emit(map[string]any{"ok": true, "action": action, "daemon": daemonState()})
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("daemon: unknown action %q", action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func controlDaemon(action string) error {
|
|
||||||
if runtime.GOOS == "darwin" {
|
|
||||||
label := "com.woodarddigital.streamdeck-go"
|
|
||||||
plist := os.Getenv("HOME") + "/Library/LaunchAgents/" + label + ".plist"
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
switch action {
|
|
||||||
case "start":
|
|
||||||
cmd = exec.Command("launchctl", "load", "-w", plist)
|
|
||||||
case "stop":
|
|
||||||
cmd = exec.Command("launchctl", "unload", "-w", plist)
|
|
||||||
case "restart":
|
|
||||||
_ = exec.Command("launchctl", "unload", "-w", plist).Run()
|
|
||||||
cmd = exec.Command("launchctl", "load", "-w", plist)
|
|
||||||
}
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("launchctl %s: %v: %s", action, err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := exec.Command("systemctl", "--user", action, daemonUnit).CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("systemctl --user %s %s: %v: %s", action, daemonUnit, err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
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 ""
|
|
||||||
}
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ package device
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sstallion/go-hid"
|
"github.com/sstallion/go-hid"
|
||||||
"golang.org/x/image/draw"
|
"golang.org/x/image/draw"
|
||||||
@@ -19,7 +21,6 @@ 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
|
||||||
@@ -32,35 +33,9 @@ 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: {Name: "Stream Deck XL v2", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true},
|
0x00ba: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v2
|
||||||
0x006c: {Name: "Stream Deck XL v1", 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
|
||||||
0x006d: {Name: "Stream Deck MK.2", KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true},
|
0x006d: {KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true}, // MK.2
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
||||||
@@ -194,8 +169,13 @@ func (sd *StreamDeck) ClearKey(keyIndex int) error {
|
|||||||
// Returns (nil, nil) on timeout — callers should check context and retry.
|
// Returns (nil, nil) on timeout — callers should check context and retry.
|
||||||
func (sd *StreamDeck) ReadButtons() ([]bool, error) {
|
func (sd *StreamDeck) ReadButtons() ([]bool, error) {
|
||||||
data := make([]byte, readReportSize)
|
data := make([]byte, readReportSize)
|
||||||
n, err := sd.dev.ReadWithTimeout(data, 250)
|
// ReadWithTimeout takes a time.Duration: a bare 250 would be 250ns, which
|
||||||
|
// truncates to a 0 ms (non-blocking) hid_read_timeout and busy-spins the loop.
|
||||||
|
n, err := sd.dev.ReadWithTimeout(data, 250*time.Millisecond)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, hid.ErrTimeout) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
// Linux hidraw returns errors (not (0,nil)) for non-fatal conditions:
|
// Linux hidraw returns errors (not (0,nil)) for non-fatal conditions:
|
||||||
// timeout waiting for data, or EINTR (signal interrupted).
|
// timeout waiting for data, or EINTR (signal interrupted).
|
||||||
// macOS IOHIDManager usually returns (0, nil) on timeout, but may also
|
// macOS IOHIDManager usually returns (0, nil) on timeout, but may also
|
||||||
|
|||||||
@@ -96,3 +96,4 @@ The biggest architectural difference is privileged commands:
|
|||||||
| Log location | `journalctl --user -u streamdeck-go` | `~/Library/Logs/streamdeck-go.log` |
|
| Log location | `journalctl --user -u streamdeck-go` | `~/Library/Logs/streamdeck-go.log` |
|
||||||
| Config path | `~/.config/streamdeck-go/` (XDG) | `~/.config/streamdeck-go/` (XDG — works fine on macOS for CLI tools) |
|
| Config path | `~/.config/streamdeck-go/` (XDG) | `~/.config/streamdeck-go/` (XDG — works fine on macOS for CLI tools) |
|
||||||
| Sleep/wake | Handled by reconnect loop | Handled by reconnect loop (same code) |
|
| Sleep/wake | Handled by reconnect loop | Handled by reconnect loop (same code) |
|
||||||
|
| HID read timeout | `hid_read_timeout` → `poll()`; 0 ms = non-blocking | `hid_read_timeout` → `pthread_cond_timedwait`; 0 ms = non-blocking. Same fix applies: pass `250*time.Millisecond` |
|
||||||
|
|||||||
@@ -1,407 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
.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
|
|
||||||
}
|
|
||||||
@@ -1,501 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user