Compare commits
9 Commits
8b6b4d582d
...
Omarchy-Pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d0748e4c5 | ||
|
|
4158165410 | ||
|
|
c143db6b68 | ||
|
|
2aa4c65e42 | ||
|
|
8cff4f8418 | ||
|
|
cef6f72712 | ||
|
|
d1d5d88508 | ||
|
|
a542ad60cd | ||
|
|
a51fd2beff |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
/streamdeck-go
|
/streamdeck-go
|
||||||
/streamdeck-helper
|
/streamdeck-helper
|
||||||
/streamdeck-init
|
/streamdeck-init
|
||||||
|
/streamdeck-ctl
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
|
|||||||
54
Makefile
54
Makefile
@@ -1,10 +1,16 @@
|
|||||||
BINARY := streamdeck-go
|
BINARY := streamdeck-go
|
||||||
HELPER := streamdeck-helper
|
HELPER := streamdeck-helper
|
||||||
INIT := streamdeck-init
|
INIT := streamdeck-init
|
||||||
|
CTL := streamdeck-ctl
|
||||||
PREFIX ?= $(HOME)/.local
|
PREFIX ?= $(HOME)/.local
|
||||||
CONFIG_DIR := $(HOME)/.config/streamdeck-go
|
CONFIG_DIR := $(HOME)/.config/streamdeck-go
|
||||||
GROUP := streamdeck
|
GROUP := streamdeck
|
||||||
|
|
||||||
|
# ── Omarchy shell plugin ──────────────────────────────────────────────────────
|
||||||
|
PLUGIN_ID := dev.woodard.streamdeck
|
||||||
|
PLUGIN_SRC := $(CURDIR)/omarchy-plugin
|
||||||
|
PLUGIN_DIR := $(HOME)/.config/omarchy/plugins/$(PLUGIN_ID)
|
||||||
|
|
||||||
# ── OS detection ──────────────────────────────────────────────────────────────
|
# ── OS detection ──────────────────────────────────────────────────────────────
|
||||||
OS := $(shell uname -s)
|
OS := $(shell uname -s)
|
||||||
|
|
||||||
@@ -24,7 +30,9 @@ else
|
|||||||
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
|
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: build build-helper build-init install install-helper install-watchdog reinstall uninstall uninstall-helper uninstall-watchdog udev
|
.PHONY: build build-helper build-init build-ctl install install-ctl install-helper install-watchdog \
|
||||||
|
install-plugin uninstall-plugin validate-plugin reinstall uninstall uninstall-helper \
|
||||||
|
uninstall-watchdog udev
|
||||||
|
|
||||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -37,6 +45,9 @@ build-helper:
|
|||||||
build-init:
|
build-init:
|
||||||
go build -o $(INIT) ./cmd/streamdeck-init/
|
go build -o $(INIT) ./cmd/streamdeck-init/
|
||||||
|
|
||||||
|
build-ctl:
|
||||||
|
go build -o $(CTL) ./cmd/streamdeck-ctl/
|
||||||
|
|
||||||
# ── Install ───────────────────────────────────────────────────────────────────
|
# ── Install ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Interactive install — prompts for dotfiles directory, installs binary + service.
|
# Interactive install — prompts for dotfiles directory, installs binary + service.
|
||||||
@@ -223,6 +234,47 @@ reinstall: build
|
|||||||
fi
|
fi
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
# ── streamdeck-ctl + Omarchy shell plugin ─────────────────────────────────────
|
||||||
|
|
||||||
|
# The CLI the Omarchy plugin drives. Also useful on its own for scripting.
|
||||||
|
install-ctl: build-ctl
|
||||||
|
mkdir -p $(BIN_DIR)
|
||||||
|
install -m 755 $(CTL) $(BIN_DIR)/$(CTL)
|
||||||
|
@echo "Installed $(BIN_DIR)/$(CTL)"
|
||||||
|
|
||||||
|
# Symlink rather than copy so edits to the QML are live after a shell rescan.
|
||||||
|
install-plugin: install-ctl
|
||||||
|
@if [ ! -d "$(HOME)/.config/omarchy" ]; then \
|
||||||
|
echo "Omarchy shell config not found at ~/.config/omarchy — is Omarchy installed?"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
mkdir -p $(HOME)/.config/omarchy/plugins
|
||||||
|
@if [ -e "$(PLUGIN_DIR)" ] && [ ! -L "$(PLUGIN_DIR)" ]; then \
|
||||||
|
echo "$(PLUGIN_DIR) exists and is not a symlink — remove it first."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
ln -sfn "$(PLUGIN_SRC)" "$(PLUGIN_DIR)"
|
||||||
|
# Validate the source tree, not the link: `omarchy plugin validate` refuses a
|
||||||
|
# symlinked plugin folder by design, but the shell resolves it happily.
|
||||||
|
@omarchy plugin validate "$(PLUGIN_SRC)" || true
|
||||||
|
@omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
|
||||||
|
@echo ""
|
||||||
|
@echo "Plugin linked: $(PLUGIN_DIR) -> $(PLUGIN_SRC)"
|
||||||
|
@echo "Enable it with: omarchy plugin enable $(PLUGIN_ID) right"
|
||||||
|
|
||||||
|
validate-plugin:
|
||||||
|
omarchy plugin validate "$(PLUGIN_SRC)"
|
||||||
|
@command -v qmllint >/dev/null 2>&1 && \
|
||||||
|
qmllint -I $${OMARCHY_PATH:-/usr/share/omarchy}/shell "$(PLUGIN_SRC)"/*.qml || \
|
||||||
|
echo "qmllint not installed — skipped QML lint"
|
||||||
|
|
||||||
|
uninstall-plugin:
|
||||||
|
omarchy plugin disable $(PLUGIN_ID) 2>/dev/null || true
|
||||||
|
rm -f "$(PLUGIN_DIR)"
|
||||||
|
rm -f $(BIN_DIR)/$(CTL)
|
||||||
|
@omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
|
||||||
|
@echo "Plugin unlinked and $(CTL) removed."
|
||||||
|
|
||||||
# ── Uninstall ─────────────────────────────────────────────────────────────────
|
# ── Uninstall ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ifeq ($(OS),Darwin)
|
ifeq ($(OS),Darwin)
|
||||||
|
|||||||
14
OMARCHY.md
14
OMARCHY.md
@@ -6,6 +6,20 @@ privileged helper — see [README § Privileged commands](README.md)).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Shell plugin
|
||||||
|
|
||||||
|
Beyond the key commands below, streamdeck-go ships an Omarchy **bar widget** that
|
||||||
|
puts deck status, brightness, and inline key editing in the bar itself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install-plugin
|
||||||
|
omarchy plugin enable dev.woodard.streamdeck right
|
||||||
|
```
|
||||||
|
|
||||||
|
See [omarchy-plugin/README.md](omarchy-plugin/README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Terminal (Ghostty)
|
## Terminal (Ghostty)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
117
README.md
117
README.md
@@ -352,6 +352,45 @@ keys:
|
|||||||
command: ""
|
command: ""
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Folders
|
||||||
|
|
||||||
|
A key can open a **folder** — a second page of keys that replaces the deck until
|
||||||
|
you go back. Folders nest to any depth, and every folder gets a back key (slot 0
|
||||||
|
by default, drawn with a built-in arrow). Pressing the folder key's `command`, if
|
||||||
|
it has one, still runs.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
12:
|
||||||
|
icon: folder.png
|
||||||
|
text: Media
|
||||||
|
folder: media # name of an entry under folders:
|
||||||
|
|
||||||
|
folders:
|
||||||
|
media:
|
||||||
|
back: # optional — defaults to key 0, built-in arrow, "Back"
|
||||||
|
key: 0
|
||||||
|
icon: back.png # omit to use the built-in arrow
|
||||||
|
text: Back
|
||||||
|
keys: # same key types as the root page: icons, GIFs,
|
||||||
|
1: # text, toggles, modules, live text, priv: commands
|
||||||
|
icon: play.png
|
||||||
|
command: playerctl play-pause
|
||||||
|
2:
|
||||||
|
icon: more.png
|
||||||
|
folder: media-extra # folders can open other folders
|
||||||
|
media-extra:
|
||||||
|
keys:
|
||||||
|
1:
|
||||||
|
text: Next
|
||||||
|
command: playerctl next
|
||||||
|
```
|
||||||
|
|
||||||
|
Each page's animations, polls, and live-text refreshes are stopped when you leave
|
||||||
|
it and restarted when you return. A config reload always lands on the root page.
|
||||||
|
Folder keys show up in `streamdeck-ctl status` with kind `folder`, and every
|
||||||
|
folder's keys are listed under `folders`.
|
||||||
|
|
||||||
### Status / toggle keys
|
### Status / toggle keys
|
||||||
|
|
||||||
A key can poll any shell command on an interval and show one of two icons based on the result. Pressing the button runs `command` as usual, and the icon re-checks ~400 ms later so it reflects the new state immediately.
|
A key can poll any shell command on an interval and show one of two icons based on the result. Pressing the button runs `command` as usual, and the icon re-checks ~400 ms later so it reflects the new state immediately.
|
||||||
@@ -823,6 +862,84 @@ keys:
|
|||||||
|
|
||||||
**Note — absolute paths in modules:** The example templates call `/usr/local/bin/obs-cmd` rather than just `obs-cmd`. This is because launchd (macOS) and systemd (Linux) give the service a minimal `PATH` that doesn't include `/usr/local/bin` or Homebrew. Use the absolute path returned by `which obs-cmd` in your own module templates, or set `PATH` in the launchd plist / systemd unit.
|
**Note — absolute paths in modules:** The example templates call `/usr/local/bin/obs-cmd` rather than just `obs-cmd`. This is because launchd (macOS) and systemd (Linux) give the service a minimal `PATH` that doesn't include `/usr/local/bin` or Homebrew. Use the absolute path returned by `which obs-cmd` in your own module templates, or set `PATH` in the launchd plist / systemd unit.
|
||||||
|
|
||||||
|
#### Camera module (Linux)
|
||||||
|
|
||||||
|
The example `modules.yaml` includes a `camera` module that drives any UVC webcam's V4L2 controls — zoom, pan/tilt, focus, exposure, white balance, brightness/contrast and so on — through [`cameractrls`](https://github.com/soyersoyer/cameractrls).
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
1. Install `cameractrls` (Arch: `pacman -S cameractrls`; other distros: see the project README). The module calls `/usr/bin/cameractrls`.
|
||||||
|
2. Point the module at your camera's stable device path (it defaults to `/dev/video0`, which renumbers across reboots):
|
||||||
|
```bash
|
||||||
|
ls -l /dev/v4l/by-id/ # pick the *-video-index0 entry for your camera
|
||||||
|
echo 'CAMERA_DEVICE=/dev/v4l/by-id/usb-046d_Logitech_BRIO_XXXX-video-index0' >> ~/.config/streamdeck-go/.env
|
||||||
|
```
|
||||||
|
Any function also accepts a per-key `device` param for multi-camera setups.
|
||||||
|
3. List the control names and ranges your camera exposes:
|
||||||
|
```bash
|
||||||
|
cameractrls -d /dev/video0 -l
|
||||||
|
```
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
|
||||||
|
| Function | Purpose | Params |
|
||||||
|
|---|---|---|
|
||||||
|
| `set` | Set one or more controls (comma-separated). Also fires button controls such as `preset=save_1` / `preset=load_1` or `color_preset=vivid` | `controls` |
|
||||||
|
| `toggle` | Flip a control between two values — 0/1 switches or menu controls | `control`, `a`, `b` |
|
||||||
|
| `adjust` | Step a numeric control by `delta` (negative to decrease), clamped to the min/max the camera reports. `extra` is applied first on the same call, e.g. `auto_exposure=manual_mode` so stepping exposure also leaves auto | `control`, `delta`, `extra` |
|
||||||
|
| `is` | Poll helper: exits 0 when `control == value` | `control`, `value` |
|
||||||
|
|
||||||
|
**Example — a camera folder with FOV presets, a stepper pair and a status toggle:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
18:
|
||||||
|
icon: camera.svg
|
||||||
|
text: "Camera"
|
||||||
|
folder: camera
|
||||||
|
|
||||||
|
folders:
|
||||||
|
camera:
|
||||||
|
keys:
|
||||||
|
1:
|
||||||
|
icon: fov-wide.svg
|
||||||
|
text: "Wide"
|
||||||
|
module: camera
|
||||||
|
function: set
|
||||||
|
params: { controls: "logitech_brio_fov=90" }
|
||||||
|
3:
|
||||||
|
icon: zoom-out.svg
|
||||||
|
text: "Zoom −"
|
||||||
|
module: camera
|
||||||
|
function: adjust
|
||||||
|
params: { control: zoom_absolute, delta: "-25" }
|
||||||
|
4:
|
||||||
|
icon: zoom-in.svg
|
||||||
|
text: "Zoom +"
|
||||||
|
module: camera
|
||||||
|
function: adjust
|
||||||
|
params: { control: zoom_absolute, delta: "25" }
|
||||||
|
14:
|
||||||
|
icon_true: af-on.svg # green = autofocus on
|
||||||
|
icon_false: af-off.svg # grey = manual focus
|
||||||
|
text: "Autofocus"
|
||||||
|
module: camera
|
||||||
|
function: toggle
|
||||||
|
params: { control: focus_automatic_continuous, a: "1", b: "0" }
|
||||||
|
poll:
|
||||||
|
module: camera
|
||||||
|
function: is
|
||||||
|
params: { control: focus_automatic_continuous, value: "1" }
|
||||||
|
interval: 3s
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- `cameractrls` exits 0 even for an unknown control name, so poll blocks must use the `is` function — never rely on a bare exit code.
|
||||||
|
- Each call starts a Python process (~100 ms). A 3 s poll interval is comfortable; polls only run while the folder is open.
|
||||||
|
- `cameractrls -l` prints Python deprecation warnings on stderr; the module templates discard stderr so the output stays clean.
|
||||||
|
- SVG icons are rasterised by `oksvg`, which does **not** honour a viewBox x/y offset for centring (it applies the offset in pixels before scaling). To centre a glyph and leave room for a text label, keep `viewBox="0 0 S S"` and wrap the paths in `<g transform="translate(x y)">`.
|
||||||
|
|
||||||
#### Using modules in config.yaml
|
#### Using modules in config.yaml
|
||||||
|
|
||||||
Reference a module function instead of writing inline commands:
|
Reference a module function instead of writing inline commands:
|
||||||
|
|||||||
450
cmd/streamdeck-ctl/main.go
Normal file
450
cmd/streamdeck-ctl/main.go
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
// 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
|
||||||
|
-folder NAME (pressing the key opens that folder)
|
||||||
|
-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"),
|
||||||
|
"folder": fs.String("folder", "", "folder (from the folders: block) the key opens"),
|
||||||
|
"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",
|
||||||
|
"folder": "folder",
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.Folder != "" && key.Command == "" && key.Module == "" {
|
||||||
|
return fmt.Errorf("key %d opens folder %q — navigation only happens on the deck", index, key.Folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
443
cmd/streamdeck-ctl/status.go
Normal file
443
cmd/streamdeck-ctl/status.go
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
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"`
|
||||||
|
Folders []FolderStatus `json:"folders"`
|
||||||
|
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|folder
|
||||||
|
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"`
|
||||||
|
Folder string `json:"folder,omitempty"` // folder the key opens on press
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderStatus is one sub-page from the folders: block, with the same key
|
||||||
|
// shape as the root page so a front-end can render it with the same grid.
|
||||||
|
type FolderStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BackKey int `json:"backKey"`
|
||||||
|
BackIcon string `json:"backIcon,omitempty"`
|
||||||
|
BackText string `json:"backText,omitempty"`
|
||||||
|
Keys []KeyStatus `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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{},
|
||||||
|
Folders: []FolderStatus{},
|
||||||
|
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.Folders = folderList(cfg, reg)
|
||||||
|
status.Icons = iconList(cfg.IconsDir)
|
||||||
|
|
||||||
|
emit(status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
|
||||||
|
return keyEntries(cfg.Keys, cfg.IconsDir, reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderList renders every folder in name order.
|
||||||
|
func folderList(cfg *config.Config, reg *modules.Registry) []FolderStatus {
|
||||||
|
names := make([]string, 0, len(cfg.Folders))
|
||||||
|
for name := range cfg.Folders {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
out := make([]FolderStatus, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
f := cfg.Folders[name]
|
||||||
|
out = append(out, FolderStatus{
|
||||||
|
Name: name,
|
||||||
|
BackKey: f.BackKey(),
|
||||||
|
BackIcon: f.Back.Icon,
|
||||||
|
BackText: f.Back.Text,
|
||||||
|
Keys: keyEntries(f.Keys, cfg.IconsDir, reg),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyEntries(keys map[int]config.KeyConfig, iconsDir string, reg *modules.Registry) []KeyStatus {
|
||||||
|
indices := make([]int, 0, len(keys))
|
||||||
|
for index := range keys {
|
||||||
|
indices = append(indices, index)
|
||||||
|
}
|
||||||
|
sort.Ints(indices)
|
||||||
|
|
||||||
|
out := make([]KeyStatus, 0, len(indices))
|
||||||
|
for _, index := range indices {
|
||||||
|
key := 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,
|
||||||
|
Folder: key.Folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" {
|
||||||
|
entry.IconPath = resolveIcon(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.Folder != "":
|
||||||
|
return "folder"
|
||||||
|
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.Folder != "" {
|
||||||
|
return key.Folder
|
||||||
|
}
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
30
cmd/streamdeck/folder_test.go
Normal file
30
cmd/streamdeck/folder_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The built-in back arrow must leave the label band (bottom third) untouched
|
||||||
|
// and actually draw something above it.
|
||||||
|
func TestBackArrowLayout(t *testing.T) {
|
||||||
|
img := backArrow(96, 96, color.White)
|
||||||
|
lit := 0
|
||||||
|
for y := 0; y < 64; y++ {
|
||||||
|
for x := 0; x < 96; x++ {
|
||||||
|
if r, _, _, _ := img.At(x, y).RGBA(); r > 0 {
|
||||||
|
lit++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lit < 200 {
|
||||||
|
t.Errorf("arrow region has only %d lit pixels", lit)
|
||||||
|
}
|
||||||
|
for y := 72; y < 96; y++ {
|
||||||
|
for x := 0; x < 96; x++ {
|
||||||
|
if r, _, _, _ := img.At(x, y).RGBA(); r > 0 {
|
||||||
|
t.Fatalf("label band pixel (%d,%d) is lit", x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -158,6 +159,12 @@ func main() {
|
|||||||
|
|
||||||
// run initialises the deck and handles button presses until ctx is cancelled
|
// run initialises the deck and handles button presses until ctx is cancelled
|
||||||
// or the device dies. Returns true if the device died, false if ctx was cancelled.
|
// or the device dies. Returns true if the device died, false if ctx was cancelled.
|
||||||
|
//
|
||||||
|
// The deck shows one page at a time: the root page (cfg.Keys) or a folder
|
||||||
|
// (cfg.Folders[name]). Pressing a key with a `folder:` field pushes that folder;
|
||||||
|
// pressing a folder's back key pops it. Each page owns its own goroutines
|
||||||
|
// (animations, polls, live text) and is torn down completely before the next
|
||||||
|
// page is drawn, so a folder's keys never fight the root page's for a slot.
|
||||||
func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry) (deviceDied bool) {
|
func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry) (deviceDied bool) {
|
||||||
// Recover from any panic inside this goroutine tree.
|
// Recover from any panic inside this goroutine tree.
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -173,104 +180,15 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
if err := sd.SetBrightness(cfg.Brightness); err != nil {
|
if err := sd.SetBrightness(cfg.Brightness); err != nil {
|
||||||
log.Printf("warn: brightness: %v", err)
|
log.Printf("warn: brightness: %v", err)
|
||||||
}
|
}
|
||||||
for i := 0; i < sd.KeyCount(); i++ {
|
|
||||||
if err := sd.ClearKey(i); err != nil {
|
|
||||||
log.Printf("warn: clear key %d: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track animation/poll goroutines so we can wait for them on exit.
|
// Navigation stack of folder names; empty means the root page is showing.
|
||||||
var wg sync.WaitGroup
|
var stack []string
|
||||||
|
current := openPage(ctx, sd, cfg, reg, "")
|
||||||
|
defer func() { current.stop() }()
|
||||||
|
|
||||||
// triggers lets button presses immediately re-poll toggle keys.
|
showPage := func(name string) {
|
||||||
triggers := make(map[int]chan struct{})
|
current.stop()
|
||||||
|
current = openPage(ctx, sd, cfg, reg, name)
|
||||||
for keyIdx, keyCfg := range cfg.Keys {
|
|
||||||
// Resolve module-based commands to shell strings before any other logic.
|
|
||||||
// keyCfg is a copy from the map, so we must write it back after modification.
|
|
||||||
if keyCfg.Module != "" {
|
|
||||||
resolved, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("key %d: module: %v", keyIdx, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keyCfg.Command = resolved
|
|
||||||
cfg.Keys[keyIdx] = keyCfg
|
|
||||||
}
|
|
||||||
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
|
|
||||||
resolved, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("key %d: poll module: %v", keyIdx, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keyCfg.Poll.Command = resolved
|
|
||||||
cfg.Keys[keyIdx] = keyCfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle/status key: managed by a polling goroutine.
|
|
||||||
if keyCfg.Poll != nil {
|
|
||||||
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
|
|
||||||
log.Printf("key %d: toggle key requires icon_on and icon_off", keyIdx)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
trigger := make(chan struct{}, 1)
|
|
||||||
triggers[keyIdx] = trigger
|
|
||||||
wg.Add(1)
|
|
||||||
go func(idx int, kCfg config.KeyConfig, trig chan struct{}) {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("panic in pollKey %d: %v", idx, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
pollKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig)
|
|
||||||
}(keyIdx, keyCfg, trigger)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular key: load icon once.
|
|
||||||
if keyCfg.Icon == "" {
|
|
||||||
// No icon — render text-only key on a black background.
|
|
||||||
if keyCfg.Text != "" {
|
|
||||||
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
|
|
||||||
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
|
||||||
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
|
||||||
log.Printf("key %d: set image: %v", keyIdx, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
iconPath := filepath.Join(cfg.IconsDir, keyCfg.Icon)
|
|
||||||
|
|
||||||
if strings.ToLower(filepath.Ext(keyCfg.Icon)) == ".gif" {
|
|
||||||
frames, delays, err := loadGIF(sd, iconPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("key %d: load gif %q: %v", keyIdx, keyCfg.Icon, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wg.Add(1)
|
|
||||||
go func(idx int, f [][]byte, d []time.Duration) {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("panic in animateKey %d: %v", idx, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
animateKey(ctx, sd, idx, f, d)
|
|
||||||
}(keyIdx, frames, delays)
|
|
||||||
} else {
|
|
||||||
img, err := loadImage(iconPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if keyCfg.Text != "" {
|
|
||||||
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
|
||||||
}
|
|
||||||
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
|
||||||
log.Printf("key %d: set image: %v", keyIdx, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("stream deck ready (%d keys)", sd.KeyCount())
|
log.Printf("stream deck ready (%d keys)", sd.KeyCount())
|
||||||
@@ -281,7 +199,6 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
wg.Wait()
|
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
@@ -291,8 +208,6 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
consecutiveErrors++
|
consecutiveErrors++
|
||||||
if consecutiveErrors >= 3 {
|
if consecutiveErrors >= 3 {
|
||||||
log.Printf("device error (gave up after %d attempts): %v", consecutiveErrors, err)
|
log.Printf("device error (gave up after %d attempts): %v", consecutiveErrors, err)
|
||||||
// Cancel animations, wait for them to stop, then signal device death.
|
|
||||||
wg.Wait()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
log.Printf("read error (%d/3): %v", consecutiveErrors, err)
|
log.Printf("read error (%d/3): %v", consecutiveErrors, err)
|
||||||
@@ -306,11 +221,26 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, pressed := range buttons {
|
for i, pressed := range buttons {
|
||||||
if pressed && !prev[i] {
|
if !pressed || prev[i] {
|
||||||
keyCfg, ok := cfg.Keys[i]
|
|
||||||
if !ok || keyCfg.Command == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if i == current.backKey {
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
parent := ""
|
||||||
|
if len(stack) > 0 {
|
||||||
|
parent = stack[len(stack)-1]
|
||||||
|
}
|
||||||
|
log.Printf("key %d pressed → back to %s", i, pageName(parent))
|
||||||
|
showPage(parent)
|
||||||
|
break // this report belongs to the old page
|
||||||
|
}
|
||||||
|
|
||||||
|
keyCfg, ok := current.keys[i]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if keyCfg.Command != "" {
|
||||||
log.Printf("key %d pressed → %s", i, keyCfg.Command)
|
log.Printf("key %d pressed → %s", i, keyCfg.Command)
|
||||||
go func(cmd string) {
|
go func(cmd string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -320,21 +250,289 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
}()
|
}()
|
||||||
runCommand(cmd)
|
runCommand(cmd)
|
||||||
}(keyCfg.Command)
|
}(keyCfg.Command)
|
||||||
|
}
|
||||||
|
|
||||||
// For toggle keys, signal the poll goroutine to re-check state
|
// For toggle keys, signal the poll goroutine to re-check state
|
||||||
// shortly after the command runs (gives the system time to update).
|
// shortly after the command runs (gives the system time to update).
|
||||||
if ch, ok := triggers[i]; ok {
|
if ch, ok := current.triggers[i]; ok {
|
||||||
select {
|
select {
|
||||||
case ch <- struct{}{}:
|
case ch <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if keyCfg.Folder != "" {
|
||||||
|
if _, defined := cfg.Folders[keyCfg.Folder]; !defined {
|
||||||
|
log.Printf("key %d: folder %q is not defined under folders:", i, keyCfg.Folder)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("key %d pressed → open folder %q", i, keyCfg.Folder)
|
||||||
|
stack = append(stack, keyCfg.Folder)
|
||||||
|
showPage(keyCfg.Folder)
|
||||||
|
break // this report belongs to the old page
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Keys still held across a page swap stay "pressed" here, so the new
|
||||||
|
// page sees no edge for them until they are physically released.
|
||||||
prev = buttons
|
prev = buttons
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pageName names a page for log lines.
|
||||||
|
func pageName(folder string) string {
|
||||||
|
if folder == "" {
|
||||||
|
return "root page"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("folder %q", folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// page is one screenful of keys and the goroutines keeping them drawn.
|
||||||
|
type page struct {
|
||||||
|
keys map[int]config.KeyConfig // module templates already resolved
|
||||||
|
triggers map[int]chan struct{} // per-key re-poll channels
|
||||||
|
backKey int // slot that navigates up, -1 on the root page
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop tears the page down: cancels its goroutines, waits for them, and blanks
|
||||||
|
// every key so the next page starts from an empty deck.
|
||||||
|
func (p *page) stop() {
|
||||||
|
p.cancel()
|
||||||
|
p.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// openPage clears the deck and draws the root page (folder == "") or the named
|
||||||
|
// folder, starting whatever goroutines its keys need. Keys that fail to set up
|
||||||
|
// are logged and skipped — one bad icon must not blank the whole page.
|
||||||
|
func openPage(parent context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry, folder string) *page {
|
||||||
|
ctx, cancel := context.WithCancel(parent)
|
||||||
|
p := &page{
|
||||||
|
keys: map[int]config.KeyConfig{},
|
||||||
|
triggers: map[int]chan struct{}{},
|
||||||
|
backKey: -1,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < sd.KeyCount(); i++ {
|
||||||
|
if err := sd.ClearKey(i); err != nil {
|
||||||
|
log.Printf("warn: clear key %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source := cfg.Keys
|
||||||
|
if folder != "" {
|
||||||
|
f := cfg.Folders[folder]
|
||||||
|
source = f.Keys
|
||||||
|
p.backKey = f.BackKey()
|
||||||
|
if _, shadowed := source[p.backKey]; shadowed {
|
||||||
|
log.Printf("folder %q: key %d is the back key — its own definition is ignored", folder, p.backKey)
|
||||||
|
}
|
||||||
|
drawBackKey(sd, p.backKey, f.Back, cfg.IconsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
for keyIdx, keyCfg := range source {
|
||||||
|
if keyIdx == p.backKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if keyCfg.Folder != "" {
|
||||||
|
if _, defined := cfg.Folders[keyCfg.Folder]; !defined {
|
||||||
|
log.Printf("key %d: folder %q is not defined under folders:", keyIdx, keyCfg.Folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved, ok := setupKey(ctx, sd, p, keyIdx, keyCfg, cfg.IconsDir, reg)
|
||||||
|
if ok {
|
||||||
|
p.keys[keyIdx] = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupKey resolves module templates, draws the key's initial image, and starts
|
||||||
|
// any goroutine the key needs (animation, poll, live text). It returns the key
|
||||||
|
// config with commands resolved — the button loop dispatches from that copy.
|
||||||
|
// ok is false when the key could not be set up at all.
|
||||||
|
func setupKey(ctx context.Context, sd *device.StreamDeck, p *page, keyIdx int, keyCfg config.KeyConfig, iconsDir string, reg *modules.Registry) (resolved config.KeyConfig, ok bool) {
|
||||||
|
// Resolve module-based commands to shell strings before any other logic.
|
||||||
|
if keyCfg.Module != "" {
|
||||||
|
cmd, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("key %d: module: %v", keyIdx, err)
|
||||||
|
return keyCfg, false
|
||||||
|
}
|
||||||
|
keyCfg.Command = cmd
|
||||||
|
}
|
||||||
|
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
|
||||||
|
cmd, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("key %d: poll module: %v", keyIdx, err)
|
||||||
|
return keyCfg, false
|
||||||
|
}
|
||||||
|
poll := *keyCfg.Poll // don't mutate the shared config
|
||||||
|
poll.Command = cmd
|
||||||
|
keyCfg.Poll = &poll
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional $(...) sugar → text_command.
|
||||||
|
if keyCfg.TextCommand == "" {
|
||||||
|
if inner, ok := unwrapCmdSubst(keyCfg.Text); ok {
|
||||||
|
keyCfg.TextCommand = inner
|
||||||
|
keyCfg.Text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn := func(name string, fn func()) {
|
||||||
|
p.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer p.wg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("panic in %s %d: %v", name, keyIdx, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic text key: a refresh goroutine owns this key's image. It also
|
||||||
|
// handles an optional poll block (choosing icon_true/icon_false per tick),
|
||||||
|
// so it must take precedence over the plain toggle path below.
|
||||||
|
if keyCfg.TextCommand != "" {
|
||||||
|
baseIcon := keyCfg.Icon
|
||||||
|
if keyCfg.Poll != nil {
|
||||||
|
baseIcon = keyCfg.IconTrue
|
||||||
|
}
|
||||||
|
if strings.ToLower(filepath.Ext(baseIcon)) == ".gif" ||
|
||||||
|
strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif" {
|
||||||
|
log.Printf("key %d: text_command not supported with GIF icon — skipping refresh", keyIdx)
|
||||||
|
return keyCfg, true
|
||||||
|
}
|
||||||
|
trigger := make(chan struct{}, 1)
|
||||||
|
p.triggers[keyIdx] = trigger
|
||||||
|
kCfg := keyCfg
|
||||||
|
spawn("refreshTextKey", func() { refreshTextKey(ctx, sd, keyIdx, kCfg, iconsDir, trigger) })
|
||||||
|
return keyCfg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle/status key: managed by a polling goroutine.
|
||||||
|
if keyCfg.Poll != nil {
|
||||||
|
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
|
||||||
|
log.Printf("key %d: toggle key requires icon_true and icon_false", keyIdx)
|
||||||
|
return keyCfg, true
|
||||||
|
}
|
||||||
|
trigger := make(chan struct{}, 1)
|
||||||
|
p.triggers[keyIdx] = trigger
|
||||||
|
kCfg := keyCfg
|
||||||
|
spawn("pollKey", func() { pollKey(ctx, sd, keyIdx, kCfg, iconsDir, trigger) })
|
||||||
|
return keyCfg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular key: load icon once.
|
||||||
|
if keyCfg.Icon == "" {
|
||||||
|
// No icon — render text-only key on a black background.
|
||||||
|
if keyCfg.Text != "" {
|
||||||
|
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
|
||||||
|
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
||||||
|
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
||||||
|
log.Printf("key %d: set image: %v", keyIdx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keyCfg, true
|
||||||
|
}
|
||||||
|
iconPath := filepath.Join(iconsDir, keyCfg.Icon)
|
||||||
|
|
||||||
|
if strings.ToLower(filepath.Ext(keyCfg.Icon)) == ".gif" {
|
||||||
|
frames, delays, err := loadGIF(sd, iconPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("key %d: load gif %q: %v", keyIdx, keyCfg.Icon, err)
|
||||||
|
return keyCfg, true
|
||||||
|
}
|
||||||
|
spawn("animateKey", func() { animateKey(ctx, sd, keyIdx, frames, delays) })
|
||||||
|
return keyCfg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := loadImage(iconPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
|
||||||
|
return keyCfg, true
|
||||||
|
}
|
||||||
|
if keyCfg.Text != "" {
|
||||||
|
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
||||||
|
}
|
||||||
|
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
||||||
|
log.Printf("key %d: set image: %v", keyIdx, err)
|
||||||
|
}
|
||||||
|
return keyCfg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawBackKey renders a folder's back key: the configured icon if there is
|
||||||
|
// one, otherwise a built-in chevron on black, with a "Back" label by default.
|
||||||
|
func drawBackKey(sd *device.StreamDeck, keyIdx int, back config.BackConfig, iconsDir string) {
|
||||||
|
if keyIdx < 0 || keyIdx >= sd.KeyCount() {
|
||||||
|
log.Printf("back key %d is outside the deck (%d keys)", keyIdx, sd.KeyCount())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var img image.Image
|
||||||
|
if back.Icon != "" {
|
||||||
|
loaded, err := loadImage(filepath.Join(iconsDir, back.Icon))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("back key %d: load icon %q: %v — using built-in arrow", keyIdx, back.Icon, err)
|
||||||
|
} else {
|
||||||
|
img = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if img == nil {
|
||||||
|
img = backArrow(sd.ImageWidth(), sd.ImageHeight(), parseTextColor(back.TextColor))
|
||||||
|
}
|
||||||
|
text := back.Text
|
||||||
|
if text == "" && back.Icon == "" {
|
||||||
|
text = "Back"
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
img = overlayText(img, text, back.TextColor, sd.ImageWidth())
|
||||||
|
}
|
||||||
|
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
||||||
|
log.Printf("back key %d: set image: %v", keyIdx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backArrow draws a left-pointing chevron in the upper part of a black key,
|
||||||
|
// leaving the bottom clear for the label.
|
||||||
|
func backArrow(w, h int, c color.Color) image.Image {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
draw.Draw(img, img.Bounds(), image.Black, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
fw, fh := float64(w), float64(h)
|
||||||
|
tip := [2]float64{fw * 0.36, fh * 0.40}
|
||||||
|
top := [2]float64{fw * 0.62, fh * 0.16}
|
||||||
|
bottom := [2]float64{fw * 0.62, fh * 0.64}
|
||||||
|
thickness := fw * 0.075
|
||||||
|
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
px, py := float64(x)+0.5, float64(y)+0.5
|
||||||
|
d := math.Min(distToSegment(px, py, top, tip), distToSegment(px, py, tip, bottom))
|
||||||
|
if d <= thickness {
|
||||||
|
img.Set(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
// distToSegment is the distance from point p to the segment a→b.
|
||||||
|
func distToSegment(px, py float64, a, b [2]float64) float64 {
|
||||||
|
dx, dy := b[0]-a[0], b[1]-a[1]
|
||||||
|
lenSq := dx*dx + dy*dy
|
||||||
|
t := 0.0
|
||||||
|
if lenSq > 0 {
|
||||||
|
t = ((px-a[0])*dx + (py-a[1])*dy) / lenSq
|
||||||
|
t = math.Max(0, math.Min(1, t))
|
||||||
|
}
|
||||||
|
cx, cy := a[0]+t*dx, a[1]+t*dy
|
||||||
|
return math.Hypot(px-cx, py-cy)
|
||||||
|
}
|
||||||
|
|
||||||
// pollKey watches the state of a toggle key and keeps its icon up to date.
|
// pollKey watches the state of a toggle key and keeps its icon up to date.
|
||||||
// It polls on an interval and also re-polls when triggered (e.g. after a button press).
|
// It polls on an interval and also re-polls when triggered (e.g. after a button press).
|
||||||
func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
|
func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
|
||||||
@@ -488,6 +686,119 @@ func queryPollState(poll *config.PollConfig) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshTextKey periodically runs keyCfg.TextCommand and renders its stdout as
|
||||||
|
// the key's text overlay, compositing over the key's base icon each tick (never
|
||||||
|
// accumulating — overlayText always renders onto a fresh image from the base).
|
||||||
|
// If the key also has a Poll block, the base icon is chosen from the polled
|
||||||
|
// state (icon_true/icon_false) so a single goroutine owns the key's image.
|
||||||
|
func refreshTextKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
|
||||||
|
// Interval: default 1s, floor 250ms.
|
||||||
|
interval := time.Second
|
||||||
|
if keyCfg.Refresh != "" {
|
||||||
|
if d, err := time.ParseDuration(keyCfg.Refresh); err == nil && d > 0 {
|
||||||
|
interval = d
|
||||||
|
} else if err != nil {
|
||||||
|
log.Printf("key %d: invalid refresh %q, using 1s", keyIdx, keyCfg.Refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if interval < 250*time.Millisecond {
|
||||||
|
interval = 250 * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload immutable base image(s). An empty name yields a black background.
|
||||||
|
load := func(name string) (image.Image, bool) {
|
||||||
|
if name == "" {
|
||||||
|
return image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight())), true
|
||||||
|
}
|
||||||
|
img, err := loadImage(filepath.Join(iconsDir, name))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("key %d: load icon %q: %v", keyIdx, name, err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return img, true
|
||||||
|
}
|
||||||
|
|
||||||
|
pollMode := keyCfg.Poll != nil && keyCfg.IconTrue != "" && keyCfg.IconFalse != ""
|
||||||
|
var baseImg, imgTrue, imgFalse image.Image
|
||||||
|
if pollMode {
|
||||||
|
var ok1, ok2 bool
|
||||||
|
imgTrue, ok1 = load(keyCfg.IconTrue)
|
||||||
|
imgFalse, ok2 = load(keyCfg.IconFalse)
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var ok bool
|
||||||
|
baseImg, ok = load(keyCfg.Icon) // "" → black background
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render := func() {
|
||||||
|
text := runTextCommand(keyCfg.TextCommand)
|
||||||
|
if text == "" {
|
||||||
|
text = keyCfg.Text // static fallback
|
||||||
|
}
|
||||||
|
base := baseImg
|
||||||
|
if pollMode {
|
||||||
|
if queryPollState(keyCfg.Poll) == 1 {
|
||||||
|
base = imgTrue
|
||||||
|
} else {
|
||||||
|
base = imgFalse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img := overlayText(base, text, keyCfg.TextColor, sd.ImageWidth())
|
||||||
|
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
||||||
|
log.Printf("key %d: set dynamic text image: %v", keyIdx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() // set immediately on startup
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
render()
|
||||||
|
case <-trigger:
|
||||||
|
// Wait briefly for a press-triggered command to take effect, then re-render.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTextCommand runs cmd via sh -c and returns its stdout with trailing
|
||||||
|
// whitespace/newlines trimmed. Stderr is dropped so it can't pollute the label.
|
||||||
|
func runTextCommand(cmd string) string {
|
||||||
|
out, err := exec.Command("sh", "-c", cmd).Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("text_command %q: %v", cmd, err)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(string(out), " \t\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// unwrapCmdSubst returns the inner command if s is exactly "$( ... )".
|
||||||
|
func unwrapCmdSubst(s string) (string, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if strings.HasPrefix(s, "$(") && strings.HasSuffix(s, ")") {
|
||||||
|
inner := strings.TrimSpace(s[2 : len(s)-1])
|
||||||
|
if inner != "" {
|
||||||
|
return inner, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
// mustConnect blocks until the device opens successfully.
|
// mustConnect blocks until the device opens successfully.
|
||||||
//
|
//
|
||||||
// Strategy: try quickly at first (device may just be enumerating), then settle
|
// Strategy: try quickly at first (device may just be enumerating), then settle
|
||||||
@@ -707,9 +1018,14 @@ func overlayText(img image.Image, text, textColorStr string, keySize int) image.
|
|||||||
|
|
||||||
// Outline offsets for readability on any background.
|
// Outline offsets for readability on any background.
|
||||||
offsets := [8]image.Point{
|
offsets := [8]image.Point{
|
||||||
{-1, -1}, {0, -1}, {1, -1},
|
{-1, -1},
|
||||||
{-1, 0}, {1, 0},
|
{0, -1},
|
||||||
{-1, 1}, {0, 1}, {1, 1},
|
{1, -1},
|
||||||
|
{-1, 0},
|
||||||
|
{1, 0},
|
||||||
|
{-1, 1},
|
||||||
|
{0, 1},
|
||||||
|
{1, 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
@@ -910,11 +1226,11 @@ func defaultConfigPath() string {
|
|||||||
func ensureConfigDir(cfgPath string) error {
|
func ensureConfigDir(cfgPath string) error {
|
||||||
dir := filepath.Dir(cfgPath)
|
dir := filepath.Dir(cfgPath)
|
||||||
iconsDir := filepath.Join(dir, "icons")
|
iconsDir := filepath.Join(dir, "icons")
|
||||||
if err := os.MkdirAll(iconsDir, 0755); err != nil {
|
if err := os.MkdirAll(iconsDir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||||
return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0644)
|
return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0o644)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -946,4 +1262,3 @@ device:
|
|||||||
keys: {}
|
keys: {}
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,3 +61,66 @@ keys:
|
|||||||
# command: pactl get-source-mute @DEFAULT_SOURCE@
|
# command: pactl get-source-mute @DEFAULT_SOURCE@
|
||||||
# interval: 2s
|
# interval: 2s
|
||||||
# match: "yes"
|
# match: "yes"
|
||||||
|
|
||||||
|
# --- Dynamic text from command output ---
|
||||||
|
#
|
||||||
|
# text_command: a shell command whose stdout is rendered as the key's label,
|
||||||
|
# re-run every `refresh` interval (default 1s; floored to 250ms). Newlines
|
||||||
|
# in the output become label line breaks; trailing whitespace is trimmed.
|
||||||
|
# `text` (if set) is the static fallback shown before the first run or when
|
||||||
|
# the command errors / outputs nothing. Composited over `icon` each tick.
|
||||||
|
#
|
||||||
|
# NOTE: use the ABSOLUTE binary path — the service runs with a minimal PATH.
|
||||||
|
# Apple Silicon Homebrew installs may live at /opt/homebrew/bin/flow instead.
|
||||||
|
#
|
||||||
|
# Pomodoro (`flow`) live countdown — press toggles start/pause:
|
||||||
|
# 8:
|
||||||
|
# icon: pomodoro.png # base icon; text on top
|
||||||
|
# text_command: "/usr/local/bin/flow status --short" # prints e.g. WORK\n24:12 (real newline)
|
||||||
|
# refresh: 1s
|
||||||
|
# text_color: "#FFFFFF"
|
||||||
|
# command: "/usr/local/bin/flow toggle"
|
||||||
|
#
|
||||||
|
# Separate icon-swap indicator (running vs paused):
|
||||||
|
# 9:
|
||||||
|
# icon_true: running.png
|
||||||
|
# icon_false: paused.png
|
||||||
|
# command: "/usr/local/bin/flow toggle"
|
||||||
|
# poll:
|
||||||
|
# command: "/usr/local/bin/flow status" # prints: state=running phase=work remaining=24:12
|
||||||
|
# interval: 1s
|
||||||
|
# match: "state=running"
|
||||||
|
#
|
||||||
|
# Control keys:
|
||||||
|
# 10:
|
||||||
|
# icon: skip.png
|
||||||
|
# command: "/usr/local/bin/flow skip"
|
||||||
|
# 11:
|
||||||
|
# icon: stop.png
|
||||||
|
# command: "/usr/local/bin/flow stop"
|
||||||
|
# 12:
|
||||||
|
# icon: gui.png
|
||||||
|
# command: "/usr/local/bin/flow gui"
|
||||||
|
#
|
||||||
|
# A text-only live clock (no icon → white text on black), refreshed each second:
|
||||||
|
# 13:
|
||||||
|
# text_command: "date +%H:%M:%S"
|
||||||
|
# refresh: 1s
|
||||||
|
|
||||||
|
# Folders: a key with `folder:` opens a second page of keys. Every folder shows
|
||||||
|
# a back key (slot 0 by default, built-in arrow) that returns to the parent.
|
||||||
|
# Folders can open other folders.
|
||||||
|
#
|
||||||
|
# 7:
|
||||||
|
# icon: folder.png
|
||||||
|
# text: Media
|
||||||
|
# folder: media
|
||||||
|
#
|
||||||
|
# folders:
|
||||||
|
# media:
|
||||||
|
# back: # optional: key, icon, text, text_color
|
||||||
|
# key: 0
|
||||||
|
# keys:
|
||||||
|
# 1:
|
||||||
|
# icon: play.png
|
||||||
|
# command: playerctl play-pause
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ type KeyConfig struct {
|
|||||||
TextColor string `yaml:"text_color"` // text color: "white" (default), "black", "red", "blue", or hex "#RRGGBB"
|
TextColor string `yaml:"text_color"` // text color: "white" (default), "black", "red", "blue", or hex "#RRGGBB"
|
||||||
Command string `yaml:"command"` // shell command to run on press
|
Command string `yaml:"command"` // shell command to run on press
|
||||||
|
|
||||||
|
// Dynamic text: periodically run TextCommand and render its stdout as the
|
||||||
|
// key's text overlay. Refresh is the interval (default 1s, floor 250ms).
|
||||||
|
// Text (above) is the static fallback / initial value.
|
||||||
|
TextCommand string `yaml:"text_command"` // shell command whose stdout becomes the overlay text
|
||||||
|
Refresh string `yaml:"refresh"` // how often to re-run text_command, e.g. "1s" (default: "1s")
|
||||||
|
|
||||||
// Toggle/status keys: show different icons based on polled state.
|
// Toggle/status keys: show different icons based on polled state.
|
||||||
IconTrue string `yaml:"icon_true"` // icon when poll match is true
|
IconTrue string `yaml:"icon_true"` // icon when poll match is true
|
||||||
IconFalse string `yaml:"icon_false"` // icon when poll match is false
|
IconFalse string `yaml:"icon_false"` // icon when poll match is false
|
||||||
@@ -36,6 +42,34 @@ type KeyConfig struct {
|
|||||||
Module string `yaml:"module"`
|
Module string `yaml:"module"`
|
||||||
Function string `yaml:"function"`
|
Function string `yaml:"function"`
|
||||||
Params map[string]string `yaml:"params"`
|
Params map[string]string `yaml:"params"`
|
||||||
|
|
||||||
|
// Folder key: pressing it swaps the deck to the named folder's keys
|
||||||
|
// (defined under the top-level `folders:` block). Folders nest — a folder's
|
||||||
|
// key may itself have a folder — and every folder shows a back key.
|
||||||
|
Folder string `yaml:"folder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackConfig customises the back key every folder shows. All fields are
|
||||||
|
// optional: the default is key 0 drawn with a built-in arrow and "Back" label.
|
||||||
|
type BackConfig struct {
|
||||||
|
Key *int `yaml:"key"` // slot index for the back key (default 0)
|
||||||
|
Icon string `yaml:"icon"` // icon filename relative to icons_dir (default: built-in arrow)
|
||||||
|
Text string `yaml:"text"` // label (default "Back")
|
||||||
|
TextColor string `yaml:"text_color"` // same values as KeyConfig.TextColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderConfig is a sub-page of keys shown while a folder is open.
|
||||||
|
type FolderConfig struct {
|
||||||
|
Back BackConfig `yaml:"back"`
|
||||||
|
Keys map[int]KeyConfig `yaml:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackKey returns the slot index the folder's back key occupies.
|
||||||
|
func (f FolderConfig) BackKey() int {
|
||||||
|
if f.Back.Key != nil {
|
||||||
|
return *f.Back.Key
|
||||||
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfigPath returns the default config file path, respecting XDG_CONFIG_HOME.
|
// DefaultConfigPath returns the default config file path, respecting XDG_CONFIG_HOME.
|
||||||
@@ -59,6 +93,9 @@ type Config struct {
|
|||||||
Brightness int `yaml:"brightness"`
|
Brightness int `yaml:"brightness"`
|
||||||
Device DeviceConfig `yaml:"device"`
|
Device DeviceConfig `yaml:"device"`
|
||||||
Keys map[int]KeyConfig `yaml:"keys"`
|
Keys map[int]KeyConfig `yaml:"keys"`
|
||||||
|
// Folders are sub-pages opened by keys with a `folder:` field. The root
|
||||||
|
// page is Keys; a folder's keys replace it until its back key is pressed.
|
||||||
|
Folders map[string]FolderConfig `yaml:"folders"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviceConfig allows overriding USB IDs (defaults work for Stream Deck XL v2).
|
// DeviceConfig allows overriding USB IDs (defaults work for Stream Deck XL v2).
|
||||||
|
|||||||
402
internal/config/edit.go
Normal file
402
internal/config/edit.go
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Editing works at the yaml.Node level rather than by marshalling the Config
|
||||||
|
// struct back out. The struct round-trip would drop every comment in the file
|
||||||
|
// and materialise defaults the user never wrote; node-level edits touch only
|
||||||
|
// the mapping entries that actually change.
|
||||||
|
//
|
||||||
|
// Writes are atomic (temp file in the same directory, then rename). The daemon
|
||||||
|
// watches both the config file and its parent directory, so the rename lands as
|
||||||
|
// a CREATE event on the directory watch and triggers a hot reload.
|
||||||
|
|
||||||
|
// KeyEdit describes a change to a single key. Fields present in Set are written
|
||||||
|
// (creating the key block if needed); names listed in Unset are removed. Unset
|
||||||
|
// is applied first, so a field named in both ends up set. Values may be strings,
|
||||||
|
// ints, or map[string]string (params).
|
||||||
|
type KeyEdit struct {
|
||||||
|
Set map[string]any
|
||||||
|
Unset []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBrightness rewrites the top-level brightness value, clamped to 0–100.
|
||||||
|
func SetBrightness(path string, value int) error {
|
||||||
|
if value < 0 {
|
||||||
|
value = 0
|
||||||
|
}
|
||||||
|
if value > 100 {
|
||||||
|
value = 100
|
||||||
|
}
|
||||||
|
return editDocument(path, func(root *yaml.Node) error {
|
||||||
|
mapSet(root, "brightness", scalarNode(strconv.Itoa(value), "!!int"))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyKeyEdit merges an edit into the keys block for the given index, creating
|
||||||
|
// the keys block or the key entry if either is missing.
|
||||||
|
func ApplyKeyEdit(path string, index int, edit KeyEdit) error {
|
||||||
|
return editDocument(path, func(root *yaml.Node) error {
|
||||||
|
keys := mapGet(root, "keys")
|
||||||
|
if keys == nil || keys.Kind != yaml.MappingNode {
|
||||||
|
keys = &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
mapSet(root, "keys", keys)
|
||||||
|
}
|
||||||
|
// A fresh config from the installer carries `keys: {}` — a flow-style
|
||||||
|
// empty mapping. Force block style or new entries render inline.
|
||||||
|
keys.Style = 0
|
||||||
|
|
||||||
|
entry := mapGet(keys, strconv.Itoa(index))
|
||||||
|
if entry == nil || entry.Kind != yaml.MappingNode {
|
||||||
|
entry = &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
// The index must be an int scalar: config.Load unmarshals keys into
|
||||||
|
// map[int]KeyConfig, and yaml.v3 refuses to coerce a !!str key ("25":)
|
||||||
|
// into an int — a string-tagged key here bricks the config on reload.
|
||||||
|
mapSetNode(keys, scalarNode(strconv.Itoa(index), "!!int"), entry)
|
||||||
|
}
|
||||||
|
entry.Style = 0
|
||||||
|
|
||||||
|
for _, name := range edit.Unset {
|
||||||
|
mapDelete(entry, name)
|
||||||
|
}
|
||||||
|
// Deterministic ordering so repeated edits don't shuffle the file, and so
|
||||||
|
// a brand-new key block reads in a sensible order.
|
||||||
|
for _, name := range sortedFieldNames(edit.Set) {
|
||||||
|
node, err := valueNode(edit.Set[name])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("field %q: %w", name, err)
|
||||||
|
}
|
||||||
|
mapSet(entry, name, node)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearKey removes a key entry entirely. Removing a key that isn't there is not
|
||||||
|
// an error — the caller's intent (that slot ends up empty) is already satisfied.
|
||||||
|
func ClearKey(path string, index int) error {
|
||||||
|
return editDocument(path, func(root *yaml.Node) error {
|
||||||
|
keys := mapGet(root, "keys")
|
||||||
|
if keys == nil || keys.Kind != yaml.MappingNode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mapDelete(keys, strconv.Itoa(index))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldOrder is the order key fields are written in when a key block is built or
|
||||||
|
// extended, so generated YAML reads the way the hand-written examples do.
|
||||||
|
var fieldOrder = []string{
|
||||||
|
"icon", "icon_true", "icon_false",
|
||||||
|
"text", "text_color", "text_command", "refresh",
|
||||||
|
"module", "function", "params",
|
||||||
|
"command", "folder", "poll",
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedFieldNames(m map[string]any) []string {
|
||||||
|
rank := make(map[string]int, len(fieldOrder))
|
||||||
|
for i, name := range fieldOrder {
|
||||||
|
rank[name] = i
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(m))
|
||||||
|
for name := range m {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Slice(names, func(i, j int) bool {
|
||||||
|
ri, oki := rank[names[i]]
|
||||||
|
rj, okj := rank[names[j]]
|
||||||
|
if oki != okj {
|
||||||
|
return oki // known fields sort before unknown ones
|
||||||
|
}
|
||||||
|
if oki && ri != rj {
|
||||||
|
return ri < rj
|
||||||
|
}
|
||||||
|
return names[i] < names[j]
|
||||||
|
})
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// editDocument parses path, hands the root mapping to fn, and writes the result
|
||||||
|
// back atomically. The file must already exist and parse.
|
||||||
|
func editDocument(path string, fn func(root *yaml.Node) error) error {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read config %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// yaml.v3 keeps comments but discards blank lines, so a config edited from a
|
||||||
|
// GUI would slowly lose its paragraph breaks. Standing them in as sentinel
|
||||||
|
// comments carries them through the round-trip.
|
||||||
|
data, blanksProtected := protectBlankLines(raw)
|
||||||
|
|
||||||
|
var doc yaml.Node
|
||||||
|
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||||
|
return fmt.Errorf("parse config %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var root *yaml.Node
|
||||||
|
switch {
|
||||||
|
case doc.Kind == yaml.DocumentNode && len(doc.Content) > 0:
|
||||||
|
root = doc.Content[0]
|
||||||
|
case doc.Kind == 0:
|
||||||
|
// Empty (or comment-only) file — start a mapping so edits have somewhere to go.
|
||||||
|
root = &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
doc = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("config %q: unexpected document shape", path)
|
||||||
|
}
|
||||||
|
if root.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("config %q: top level is not a mapping", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := yaml.NewEncoder(&buf)
|
||||||
|
enc.SetIndent(2)
|
||||||
|
if err := enc.Encode(&doc); err != nil {
|
||||||
|
return fmt.Errorf("encode config: %w", err)
|
||||||
|
}
|
||||||
|
if err := enc.Close(); err != nil {
|
||||||
|
return fmt.Errorf("encode config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := buf.Bytes()
|
||||||
|
if blanksProtected {
|
||||||
|
out = restoreBlankLines(out)
|
||||||
|
}
|
||||||
|
return writeAtomic(path, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// blankSentinel stands in for a blank line across the parse/encode round-trip.
|
||||||
|
// It is deliberately obscure so it cannot collide with a real comment.
|
||||||
|
const blankSentinel = "#__streamdeck_go_blank_line__"
|
||||||
|
|
||||||
|
// protectBlankLines rewrites blank lines as sentinel comments. It reports false
|
||||||
|
// (and leaves the input untouched) when the document contains a block scalar,
|
||||||
|
// where an inserted line would become part of the string's content rather than
|
||||||
|
// structure. Losing blank-line formatting is a cosmetic regression; corrupting a
|
||||||
|
// multi-line command is not, so the ambiguous case declines to act.
|
||||||
|
func protectBlankLines(data []byte) ([]byte, bool) {
|
||||||
|
if blockScalarPattern.Match(data) {
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
changed := false
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == "" && i != len(lines)-1 {
|
||||||
|
lines[i] = blankSentinel
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
return []byte(strings.Join(lines, "\n")), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreBlankLines turns sentinel comments back into blank lines. The encoder
|
||||||
|
// may have indented them along with the comment block they joined, so the match
|
||||||
|
// is on the trimmed line.
|
||||||
|
func restoreBlankLines(data []byte) []byte {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.TrimSpace(line) == blankSentinel {
|
||||||
|
lines[i] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []byte(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockScalarPattern matches a literal or folded block scalar header, e.g.
|
||||||
|
// `command: |`, `command: >-`, or `command: |2 # note`.
|
||||||
|
var blockScalarPattern = regexp.MustCompile(`(?m):[ \t]*[|>][-+0-9]*[ \t]*(#.*)?$`)
|
||||||
|
|
||||||
|
// writeAtomic writes to a temp file in the same directory and renames it into
|
||||||
|
// place, so a reader (or the daemon's fsnotify reload) never sees a half-written
|
||||||
|
// config. The original mode is preserved when it can be read.
|
||||||
|
func writeAtomic(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
mode := os.FileMode(0o644)
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
mode = info.Mode().Perm()
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp(dir, ".streamdeck-config-*.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp file in %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName) // no-op once the rename succeeds
|
||||||
|
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("write temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("sync temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmpName, mode); err != nil {
|
||||||
|
return fmt.Errorf("chmod temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
return fmt.Errorf("replace %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── yaml.Node mapping helpers ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A MappingNode stores Content as a flat [key, value, key, value, ...] slice.
|
||||||
|
|
||||||
|
func mapGet(m *yaml.Node, key string) *yaml.Node {
|
||||||
|
if m == nil || m.Kind != yaml.MappingNode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := 0; i+1 < len(m.Content); i += 2 {
|
||||||
|
if m.Content[i].Value == key {
|
||||||
|
return m.Content[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapSet replaces the value for key, or appends the pair if it isn't present.
|
||||||
|
// Replacing keeps the existing key node so its comments stay attached.
|
||||||
|
func mapSet(m *yaml.Node, key string, value *yaml.Node) {
|
||||||
|
for i := 0; i+1 < len(m.Content); i += 2 {
|
||||||
|
if m.Content[i].Value == key {
|
||||||
|
// Carry the old value's comments onto the replacement — they describe
|
||||||
|
// the setting, not the specific value being overwritten.
|
||||||
|
old := m.Content[i+1]
|
||||||
|
if value.HeadComment == "" {
|
||||||
|
value.HeadComment = old.HeadComment
|
||||||
|
}
|
||||||
|
if value.LineComment == "" {
|
||||||
|
value.LineComment = old.LineComment
|
||||||
|
}
|
||||||
|
if value.FootComment == "" {
|
||||||
|
value.FootComment = old.FootComment
|
||||||
|
}
|
||||||
|
m.Content[i+1] = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.Content = append(m.Content, scalarNode(key, "!!str"), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapSetNode is mapSet with a caller-built key node, for keys that must carry a
|
||||||
|
// non-string tag (e.g. the !!int key indices under `keys:`).
|
||||||
|
func mapSetNode(m *yaml.Node, key, value *yaml.Node) {
|
||||||
|
for i := 0; i+1 < len(m.Content); i += 2 {
|
||||||
|
if m.Content[i].Value == key.Value {
|
||||||
|
m.Content[i+1] = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.Content = append(m.Content, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDelete(m *yaml.Node, key string) {
|
||||||
|
if m == nil || m.Kind != yaml.MappingNode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := 0; i+1 < len(m.Content); i += 2 {
|
||||||
|
if m.Content[i].Value == key {
|
||||||
|
m.Content = append(m.Content[:i], m.Content[i+2:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scalarNode(value, tag string) *yaml.Node {
|
||||||
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueNode converts a Go value from a KeyEdit into a yaml.Node.
|
||||||
|
func valueNode(v any) (*yaml.Node, error) {
|
||||||
|
switch typed := v.(type) {
|
||||||
|
case string:
|
||||||
|
return stringNode(typed), nil
|
||||||
|
case int:
|
||||||
|
return scalarNode(strconv.Itoa(typed), "!!int"), nil
|
||||||
|
case bool:
|
||||||
|
return scalarNode(strconv.FormatBool(typed), "!!bool"), nil
|
||||||
|
case map[string]string:
|
||||||
|
node := &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
names := make([]string, 0, len(typed))
|
||||||
|
for name := range typed {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
node.Content = append(node.Content, scalarNode(name, "!!str"), stringNode(typed[name]))
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported value type %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringNode emits a string scalar, quoting it when leaving it bare would change
|
||||||
|
// its meaning on re-parse (empty, leading/trailing space, or a value YAML would
|
||||||
|
// read back as a bool/number/null).
|
||||||
|
func stringNode(s string) *yaml.Node {
|
||||||
|
node := scalarNode(s, "!!str")
|
||||||
|
if needsQuoting(s) {
|
||||||
|
node.Style = yaml.DoubleQuotedStyle
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsQuoting(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s != trimSpace(s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var probe any
|
||||||
|
if err := yaml.Unmarshal([]byte(s), &probe); err != nil {
|
||||||
|
// Not parseable bare (e.g. contains ": ") — quote it and let the encoder escape.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, isString := probe.(string); !isString {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSpace(s string) string {
|
||||||
|
start, end := 0, len(s)
|
||||||
|
for start < end && isSpace(s[start]) {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
for end > start && isSpace(s[end-1]) {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
return s[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSpace(b byte) bool {
|
||||||
|
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||||
|
}
|
||||||
126
internal/config/edit_test.go
Normal file
126
internal/config/edit_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: a new key's index must be written as an int scalar. A !!str key
|
||||||
|
// ("25":) makes config.Load fail — yaml.v3 refuses string keys for map[int] —
|
||||||
|
// which bricked the config on the daemon's next hot reload.
|
||||||
|
func TestApplyKeyEditNewSlotLoadsBack(t *testing.T) {
|
||||||
|
path := writeConfig(t, "icons_dir: ./icons\nbrightness: 70\nkeys: {}\n")
|
||||||
|
|
||||||
|
err := ApplyKeyEdit(path, 25, KeyEdit{Set: map[string]any{
|
||||||
|
"icon": "test.png",
|
||||||
|
"command": "echo hi",
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyKeyEdit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load after edit: %v", err)
|
||||||
|
}
|
||||||
|
key, ok := cfg.Keys[25]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("key 25 missing after edit; keys=%v", cfg.Keys)
|
||||||
|
}
|
||||||
|
if key.Icon != "test.png" || key.Command != "echo hi" {
|
||||||
|
t.Fatalf("key 25 = %+v", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := os.ReadFile(path)
|
||||||
|
if strings.Contains(string(raw), `"25"`) {
|
||||||
|
t.Fatalf("index written as a quoted string:\n%s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing an existing key must merge, and comments/blank lines must survive.
|
||||||
|
func TestApplyKeyEditMergePreservesFormatting(t *testing.T) {
|
||||||
|
path := writeConfig(t, `icons_dir: ./icons
|
||||||
|
|
||||||
|
# the deck
|
||||||
|
keys:
|
||||||
|
0:
|
||||||
|
icon: a.png # keep me
|
||||||
|
command: run-a
|
||||||
|
`)
|
||||||
|
|
||||||
|
if err := ApplyKeyEdit(path, 0, KeyEdit{Set: map[string]any{"text": "Hi"}}); err != nil {
|
||||||
|
t.Fatalf("ApplyKeyEdit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := os.ReadFile(path)
|
||||||
|
text := string(raw)
|
||||||
|
for _, want := range []string{"# the deck", "# keep me", "command: run-a", "text: Hi", "\n\n"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("output missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Keys[0].Icon != "a.png" || cfg.Keys[0].Text != "Hi" {
|
||||||
|
t.Fatalf("merge lost fields: %+v", cfg.Keys[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unset is applied before Set, so a field named in both ends up set.
|
||||||
|
func TestApplyKeyEditSetWinsOverUnset(t *testing.T) {
|
||||||
|
path := writeConfig(t, "keys:\n 0:\n command: old\n")
|
||||||
|
|
||||||
|
err := ApplyKeyEdit(path, 0, KeyEdit{
|
||||||
|
Set: map[string]any{"command": "new"},
|
||||||
|
Unset: []string{"command"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyKeyEdit: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Keys[0].Command != "new" {
|
||||||
|
t.Fatalf("command = %q, want %q", cfg.Keys[0].Command, "new")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearKeyAndSetBrightness(t *testing.T) {
|
||||||
|
path := writeConfig(t, "brightness: 70\nkeys:\n 3:\n command: x\n")
|
||||||
|
|
||||||
|
if err := ClearKey(path, 3); err != nil {
|
||||||
|
t.Fatalf("ClearKey: %v", err)
|
||||||
|
}
|
||||||
|
if err := ClearKey(path, 99); err != nil { // absent key is not an error
|
||||||
|
t.Fatalf("ClearKey absent: %v", err)
|
||||||
|
}
|
||||||
|
if err := SetBrightness(path, 140); err != nil { // clamped
|
||||||
|
t.Fatalf("SetBrightness: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Keys) != 0 {
|
||||||
|
t.Fatalf("keys not cleared: %v", cfg.Keys)
|
||||||
|
}
|
||||||
|
if cfg.Brightness != 100 {
|
||||||
|
t.Fatalf("brightness = %d, want 100", cfg.Brightness)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
internal/config/folders_test.go
Normal file
82
internal/config/folders_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadFolders(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
src := `
|
||||||
|
keys:
|
||||||
|
3:
|
||||||
|
icon: folder.png
|
||||||
|
folder: media
|
||||||
|
folders:
|
||||||
|
media:
|
||||||
|
back:
|
||||||
|
key: 7
|
||||||
|
icon: back.png
|
||||||
|
text: Up
|
||||||
|
keys:
|
||||||
|
0:
|
||||||
|
icon: play.png
|
||||||
|
command: play
|
||||||
|
1:
|
||||||
|
icon: more.png
|
||||||
|
folder: nested
|
||||||
|
nested:
|
||||||
|
keys:
|
||||||
|
1:
|
||||||
|
text: Deep
|
||||||
|
command: echo deep
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got := cfg.Keys[3].Folder; got != "media" {
|
||||||
|
t.Errorf("key 3 folder = %q, want media", got)
|
||||||
|
}
|
||||||
|
media, ok := cfg.Folders["media"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("folder media missing")
|
||||||
|
}
|
||||||
|
if media.BackKey() != 7 || media.Back.Icon != "back.png" || media.Back.Text != "Up" {
|
||||||
|
t.Errorf("media back = %+v (key %d)", media.Back, media.BackKey())
|
||||||
|
}
|
||||||
|
if media.Keys[0].Command != "play" || media.Keys[1].Folder != "nested" {
|
||||||
|
t.Errorf("media keys = %+v", media.Keys)
|
||||||
|
}
|
||||||
|
nested := cfg.Folders["nested"]
|
||||||
|
if nested.BackKey() != 0 {
|
||||||
|
t.Errorf("nested back key = %d, want default 0", nested.BackKey())
|
||||||
|
}
|
||||||
|
if nested.Keys[1].Text != "Deep" {
|
||||||
|
t.Errorf("nested keys = %+v", nested.Keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyKeyEditFolder(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("keys: {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := ApplyKeyEdit(path, 5, KeyEdit{Set: map[string]any{"icon": "f.png", "folder": "media"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyKeyEdit: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Keys[5].Folder != "media" || cfg.Keys[5].Icon != "f.png" {
|
||||||
|
t.Errorf("key 5 = %+v", cfg.Keys[5])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,14 @@ modules:
|
|||||||
exec: |
|
exec: |
|
||||||
curl -s -X POST https://slack.com/api/users.profile.set \
|
curl -s -X POST https://slack.com/api/users.profile.set \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}'
|
-d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}'
|
||||||
|
|
||||||
clear_status:
|
clear_status:
|
||||||
exec: |
|
exec: |
|
||||||
curl -s -X POST https://slack.com/api/users.profile.set \
|
curl -s -X POST https://slack.com/api/users.profile.set \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
|
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
|
||||||
|
|
||||||
set_presence:
|
set_presence:
|
||||||
@@ -29,7 +29,7 @@ modules:
|
|||||||
exec: |
|
exec: |
|
||||||
curl -s -X POST https://slack.com/api/users.setPresence \
|
curl -s -X POST https://slack.com/api/users.setPresence \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"presence":"{{.presence}}"}'
|
-d '{"presence":"{{.presence}}"}'
|
||||||
|
|
||||||
snooze:
|
snooze:
|
||||||
@@ -38,18 +38,18 @@ modules:
|
|||||||
exec: |
|
exec: |
|
||||||
curl -s -X POST https://slack.com/api/dnd.setSnooze \
|
curl -s -X POST https://slack.com/api/dnd.setSnooze \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"num_minutes":{{.minutes}}}'
|
-d '{"num_minutes":{{.minutes}}}'
|
||||||
|
|
||||||
go_offline:
|
go_offline:
|
||||||
exec: |
|
exec: |
|
||||||
curl -s -X POST https://slack.com/api/users.setPresence \
|
curl -s -X POST https://slack.com/api/users.setPresence \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"presence":"away"}' && \
|
-d '{"presence":"away"}' && \
|
||||||
curl -s -X POST https://slack.com/api/users.profile.set \
|
curl -s -X POST https://slack.com/api/users.profile.set \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"profile":{"status_emoji":":dumpsterfire:","status_text":"Offline","status_expiration":0}}'
|
-d '{"profile":{"status_emoji":":dumpsterfire:","status_text":"Offline","status_expiration":0}}'
|
||||||
|
|
||||||
end_snooze:
|
end_snooze:
|
||||||
@@ -57,6 +57,30 @@ modules:
|
|||||||
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
||||||
|
|
||||||
|
# Pomodoro / focus timer — the `flow` CLI.
|
||||||
|
#
|
||||||
|
# Absolute path required (minimal service PATH). Apple Silicon Homebrew installs
|
||||||
|
# live at /opt/homebrew/bin/flow — set that in ~/.config/streamdeck-go/.env:
|
||||||
|
# FLOW_CMD=/opt/homebrew/bin/flow
|
||||||
|
#
|
||||||
|
# Control verbs (start/pause/resume/toggle/skip/stop/reset/gui) run on key press.
|
||||||
|
# `status` prints one line like `state=running phase=work remaining=24:12` for
|
||||||
|
# icon-swap poll blocks (match: "state=running").
|
||||||
|
#
|
||||||
|
# The LIVE countdown label uses a key's `text_command` field directly (not a
|
||||||
|
# module), e.g. text_command: "/usr/local/bin/flow status --short" which prints
|
||||||
|
# a two-line label like WORK\n24:12 (an actual newline between the lines).
|
||||||
|
flow:
|
||||||
|
start: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} start' }
|
||||||
|
pause: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} pause' }
|
||||||
|
resume: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} resume' }
|
||||||
|
toggle: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} toggle' }
|
||||||
|
skip: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} skip' }
|
||||||
|
stop: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} stop' }
|
||||||
|
reset: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} reset' }
|
||||||
|
gui: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} gui' }
|
||||||
|
status: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} status' }
|
||||||
|
|
||||||
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
||||||
#
|
#
|
||||||
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
||||||
@@ -182,3 +206,67 @@ modules:
|
|||||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}" && \
|
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}" && \
|
||||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-duration {{.duration}} && \
|
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-duration {{.duration}} && \
|
||||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
|
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Camera — cameractrls (https://github.com/soyersoyer/cameractrls), Linux only.
|
||||||
|
#
|
||||||
|
# Drives any UVC webcam's V4L2 controls (zoom, pan/tilt, focus, exposure, white
|
||||||
|
# balance, brightness/contrast/...). Install: pacman -S cameractrls / pip.
|
||||||
|
#
|
||||||
|
# Device: set CAMERA_DEVICE in ~/.config/streamdeck-go/.env to the stable path, e.g.
|
||||||
|
# CAMERA_DEVICE=/dev/v4l/by-id/usb-046d_Logitech_BRIO_XXXX-video-index0
|
||||||
|
# (defaults to /dev/video0; any function also takes a per-key `device` param).
|
||||||
|
#
|
||||||
|
# Discover control names/ranges: cameractrls -d /dev/video0 -l
|
||||||
|
# Note: cameractrls exits 0 even for unknown controls, so poll blocks must use
|
||||||
|
# the `is` function (which greps `-l` output) rather than exit codes.
|
||||||
|
camera:
|
||||||
|
# Set one or more controls, comma-separated (eg. "zoom_absolute=150,contrast=140").
|
||||||
|
# Buttons work too: "preset=save_1", "preset=load_1", "color_preset=vivid".
|
||||||
|
set:
|
||||||
|
params:
|
||||||
|
device: ""
|
||||||
|
controls: "zoom_absolute=100"
|
||||||
|
exec: /usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "{{.controls}}" 2>/dev/null
|
||||||
|
|
||||||
|
# Flip a control between two values (a <-> b). Works for 0/1 and menu controls.
|
||||||
|
toggle:
|
||||||
|
params:
|
||||||
|
device: ""
|
||||||
|
control: auto_exposure
|
||||||
|
a: aperture_priority_mode
|
||||||
|
b: manual_mode
|
||||||
|
exec: |
|
||||||
|
cur=$(/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | sed -n 's/^ {{.control}} = \([^[:space:]]*\).*/\1/p')
|
||||||
|
if [ "$cur" = "{{.a}}" ]; then v="{{.b}}"; else v="{{.a}}"; fi
|
||||||
|
/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "{{.control}}=$v" 2>/dev/null
|
||||||
|
|
||||||
|
# Step a numeric control by delta (negative to decrease), clamped to its min/max.
|
||||||
|
# `extra` is applied first on the same call — eg. "auto_exposure=manual_mode"
|
||||||
|
# so stepping exposure also takes the camera out of auto.
|
||||||
|
adjust:
|
||||||
|
params:
|
||||||
|
device: ""
|
||||||
|
control: contrast
|
||||||
|
delta: "16"
|
||||||
|
extra: ""
|
||||||
|
exec: |
|
||||||
|
line=$(/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | grep '^ {{.control}} = ')
|
||||||
|
cur=$(printf '%s\n' "$line" | sed -n 's/^ [a-z0-9_]* = \([-0-9]*\).*/\1/p')
|
||||||
|
min=$(printf '%s\n' "$line" | sed -n 's/.*min: \([-0-9]*\).*/\1/p')
|
||||||
|
max=$(printf '%s\n' "$line" | sed -n 's/.*max: \([-0-9]*\).*/\1/p')
|
||||||
|
[ -n "$cur" ] || exit 1
|
||||||
|
v=$((cur + {{.delta}}))
|
||||||
|
[ -n "$min" ] && [ "$v" -lt "$min" ] && v=$min
|
||||||
|
[ -n "$max" ] && [ "$v" -gt "$max" ] && v=$max
|
||||||
|
ctl="{{.control}}=$v"
|
||||||
|
[ -n "{{.extra}}" ] && ctl="{{.extra}},$ctl"
|
||||||
|
/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "$ctl" 2>/dev/null
|
||||||
|
|
||||||
|
# Poll helper for toggle keys: exit 0 when control == value.
|
||||||
|
is:
|
||||||
|
params:
|
||||||
|
device: ""
|
||||||
|
control: auto_exposure
|
||||||
|
value: aperture_priority_mode
|
||||||
|
exec: /usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | grep -q '^ {{.control}} = {{.value}}[[:space:]]'
|
||||||
|
|||||||
@@ -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,6 +21,7 @@ const VendorID = 0x0fd9
|
|||||||
|
|
||||||
// ModelInfo describes hardware-specific constants for a Stream Deck model.
|
// ModelInfo describes hardware-specific constants for a Stream Deck model.
|
||||||
type ModelInfo struct {
|
type ModelInfo struct {
|
||||||
|
Name string
|
||||||
KeyCount int
|
KeyCount int
|
||||||
Cols int
|
Cols int
|
||||||
Rows int
|
Rows int
|
||||||
@@ -31,9 +34,35 @@ type ModelInfo struct {
|
|||||||
|
|
||||||
// models maps USB product IDs to their hardware specs.
|
// models maps USB product IDs to their hardware specs.
|
||||||
var models = map[uint16]ModelInfo{
|
var models = map[uint16]ModelInfo{
|
||||||
0x00ba: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v2
|
0x00ba: {Name: "Stream Deck XL v2", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true},
|
||||||
0x006c: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v1
|
0x006c: {Name: "Stream Deck XL v1", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true},
|
||||||
0x006d: {KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true}, // MK.2
|
0x006d: {Name: "Stream Deck MK.2", KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the hardware spec for a product ID without opening the device.
|
||||||
|
// Callers that only need geometry (key count, grid shape) can use this while the
|
||||||
|
// daemon holds the HID handle.
|
||||||
|
func Lookup(productID uint16) (ModelInfo, bool) {
|
||||||
|
m, ok := models[productID]
|
||||||
|
return m, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present reports whether a Stream Deck with the given USB IDs is currently
|
||||||
|
// enumerated. It does not open the device, so it is safe to call while the
|
||||||
|
// daemon has it open.
|
||||||
|
func Present(vendorID, productID uint16) (bool, error) {
|
||||||
|
if err := hid.Init(); err != nil {
|
||||||
|
return false, fmt.Errorf("hid init: %w", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
err := hid.Enumerate(vendorID, productID, func(*hid.DeviceInfo) error {
|
||||||
|
found = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("hid enumerate: %w", err)
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -167,8 +196,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` |
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ modules:
|
|||||||
exec: |
|
exec: |
|
||||||
curl -s -X POST https://slack.com/api/users.profile.set \
|
curl -s -X POST https://slack.com/api/users.profile.set \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}'
|
-d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}'
|
||||||
|
|
||||||
clear_status:
|
clear_status:
|
||||||
exec: |
|
exec: |
|
||||||
curl -s -X POST https://slack.com/api/users.profile.set \
|
curl -s -X POST https://slack.com/api/users.profile.set \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
|
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
|
||||||
|
|
||||||
set_presence:
|
set_presence:
|
||||||
@@ -29,7 +29,7 @@ modules:
|
|||||||
exec: |
|
exec: |
|
||||||
curl -s -X POST https://slack.com/api/users.setPresence \
|
curl -s -X POST https://slack.com/api/users.setPresence \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"presence":"{{.presence}}"}'
|
-d '{"presence":"{{.presence}}"}'
|
||||||
|
|
||||||
snooze:
|
snooze:
|
||||||
@@ -38,18 +38,18 @@ modules:
|
|||||||
exec: |
|
exec: |
|
||||||
curl -s -X POST https://slack.com/api/dnd.setSnooze \
|
curl -s -X POST https://slack.com/api/dnd.setSnooze \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"num_minutes":{{.minutes}}}'
|
-d '{"num_minutes":{{.minutes}}}'
|
||||||
|
|
||||||
go_offline:
|
go_offline:
|
||||||
exec: |
|
exec: |
|
||||||
curl -s -X POST https://slack.com/api/users.setPresence \
|
curl -s -X POST https://slack.com/api/users.setPresence \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"presence":"away"}' && \
|
-d '{"presence":"away"}' && \
|
||||||
curl -s -X POST https://slack.com/api/users.profile.set \
|
curl -s -X POST https://slack.com/api/users.profile.set \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json; charset=utf-8" \
|
||||||
-d '{"profile":{"status_emoji":":dumpsterfire:","status_text":"Offline","status_expiration":0}}'
|
-d '{"profile":{"status_emoji":":dumpsterfire:","status_text":"Offline","status_expiration":0}}'
|
||||||
|
|
||||||
end_snooze:
|
end_snooze:
|
||||||
@@ -57,6 +57,30 @@ modules:
|
|||||||
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
||||||
|
|
||||||
|
# Pomodoro / focus timer — the `flow` CLI.
|
||||||
|
#
|
||||||
|
# Absolute path required (minimal service PATH). Apple Silicon Homebrew installs
|
||||||
|
# live at /opt/homebrew/bin/flow — set that in ~/.config/streamdeck-go/.env:
|
||||||
|
# FLOW_CMD=/opt/homebrew/bin/flow
|
||||||
|
#
|
||||||
|
# Control verbs (start/pause/resume/toggle/skip/stop/reset/gui) run on key press.
|
||||||
|
# `status` prints one line like `state=running phase=work remaining=24:12` for
|
||||||
|
# icon-swap poll blocks (match: "state=running").
|
||||||
|
#
|
||||||
|
# The LIVE countdown label uses a key's `text_command` field directly (not a
|
||||||
|
# module), e.g. text_command: "/usr/local/bin/flow status --short" which prints
|
||||||
|
# a two-line label like WORK\n24:12 (an actual newline between the lines).
|
||||||
|
flow:
|
||||||
|
start: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} start' }
|
||||||
|
pause: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} pause' }
|
||||||
|
resume: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} resume' }
|
||||||
|
toggle: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} toggle' }
|
||||||
|
skip: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} skip' }
|
||||||
|
stop: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} stop' }
|
||||||
|
reset: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} reset' }
|
||||||
|
gui: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} gui' }
|
||||||
|
status: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} status' }
|
||||||
|
|
||||||
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
||||||
#
|
#
|
||||||
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
||||||
@@ -182,3 +206,67 @@ modules:
|
|||||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}" && \
|
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}" && \
|
||||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-duration {{.duration}} && \
|
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-duration {{.duration}} && \
|
||||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
|
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Camera — cameractrls (https://github.com/soyersoyer/cameractrls), Linux only.
|
||||||
|
#
|
||||||
|
# Drives any UVC webcam's V4L2 controls (zoom, pan/tilt, focus, exposure, white
|
||||||
|
# balance, brightness/contrast/...). Install: pacman -S cameractrls / pip.
|
||||||
|
#
|
||||||
|
# Device: set CAMERA_DEVICE in ~/.config/streamdeck-go/.env to the stable path, e.g.
|
||||||
|
# CAMERA_DEVICE=/dev/v4l/by-id/usb-046d_Logitech_BRIO_XXXX-video-index0
|
||||||
|
# (defaults to /dev/video0; any function also takes a per-key `device` param).
|
||||||
|
#
|
||||||
|
# Discover control names/ranges: cameractrls -d /dev/video0 -l
|
||||||
|
# Note: cameractrls exits 0 even for unknown controls, so poll blocks must use
|
||||||
|
# the `is` function (which greps `-l` output) rather than exit codes.
|
||||||
|
camera:
|
||||||
|
# Set one or more controls, comma-separated (eg. "zoom_absolute=150,contrast=140").
|
||||||
|
# Buttons work too: "preset=save_1", "preset=load_1", "color_preset=vivid".
|
||||||
|
set:
|
||||||
|
params:
|
||||||
|
device: ""
|
||||||
|
controls: "zoom_absolute=100"
|
||||||
|
exec: /usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "{{.controls}}" 2>/dev/null
|
||||||
|
|
||||||
|
# Flip a control between two values (a <-> b). Works for 0/1 and menu controls.
|
||||||
|
toggle:
|
||||||
|
params:
|
||||||
|
device: ""
|
||||||
|
control: auto_exposure
|
||||||
|
a: aperture_priority_mode
|
||||||
|
b: manual_mode
|
||||||
|
exec: |
|
||||||
|
cur=$(/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | sed -n 's/^ {{.control}} = \([^[:space:]]*\).*/\1/p')
|
||||||
|
if [ "$cur" = "{{.a}}" ]; then v="{{.b}}"; else v="{{.a}}"; fi
|
||||||
|
/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "{{.control}}=$v" 2>/dev/null
|
||||||
|
|
||||||
|
# Step a numeric control by delta (negative to decrease), clamped to its min/max.
|
||||||
|
# `extra` is applied first on the same call — eg. "auto_exposure=manual_mode"
|
||||||
|
# so stepping exposure also takes the camera out of auto.
|
||||||
|
adjust:
|
||||||
|
params:
|
||||||
|
device: ""
|
||||||
|
control: contrast
|
||||||
|
delta: "16"
|
||||||
|
extra: ""
|
||||||
|
exec: |
|
||||||
|
line=$(/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | grep '^ {{.control}} = ')
|
||||||
|
cur=$(printf '%s\n' "$line" | sed -n 's/^ [a-z0-9_]* = \([-0-9]*\).*/\1/p')
|
||||||
|
min=$(printf '%s\n' "$line" | sed -n 's/.*min: \([-0-9]*\).*/\1/p')
|
||||||
|
max=$(printf '%s\n' "$line" | sed -n 's/.*max: \([-0-9]*\).*/\1/p')
|
||||||
|
[ -n "$cur" ] || exit 1
|
||||||
|
v=$((cur + {{.delta}}))
|
||||||
|
[ -n "$min" ] && [ "$v" -lt "$min" ] && v=$min
|
||||||
|
[ -n "$max" ] && [ "$v" -gt "$max" ] && v=$max
|
||||||
|
ctl="{{.control}}=$v"
|
||||||
|
[ -n "{{.extra}}" ] && ctl="{{.extra}},$ctl"
|
||||||
|
/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "$ctl" 2>/dev/null
|
||||||
|
|
||||||
|
# Poll helper for toggle keys: exit 0 when control == value.
|
||||||
|
is:
|
||||||
|
params:
|
||||||
|
device: ""
|
||||||
|
control: auto_exposure
|
||||||
|
value: aperture_priority_mode
|
||||||
|
exec: /usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | grep -q '^ {{.control}} = {{.value}}[[:space:]]'
|
||||||
|
|||||||
407
omarchy-plugin/KeyEditor.qml
Normal file
407
omarchy-plugin/KeyEditor.qml
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Ui
|
||||||
|
import "Model.js" as Model
|
||||||
|
|
||||||
|
// Editor for one key. It edits a local draft and only writes to config.yaml when
|
||||||
|
// Save is pressed, so a half-typed command never reaches the running deck.
|
||||||
|
//
|
||||||
|
// Inputs are deliberately *not* two-way bound — neither the text fields nor the
|
||||||
|
// dropdowns. Typing into a QML TextField (or Ui/Dropdown selecting internally)
|
||||||
|
// writes the property directly and destroys any declarative binding on it, which
|
||||||
|
// would leave Revert unable to repaint the control. Instead the draft is the
|
||||||
|
// single source of truth: edit handlers push user input into it, and
|
||||||
|
// syncFields()/paramsSynced() push the draft back out whenever it changes
|
||||||
|
// underneath the user. Controls with focus (or an open popup) are skipped so a
|
||||||
|
// background poll can never move the cursor mid-keystroke.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var service: null
|
||||||
|
property var status: null
|
||||||
|
property int keyIndex: -1
|
||||||
|
property color foreground: Color.foreground
|
||||||
|
property color accent: Color.accent
|
||||||
|
property string fontFamily: Style.font.family
|
||||||
|
|
||||||
|
signal closed()
|
||||||
|
signal saved()
|
||||||
|
|
||||||
|
readonly property color dim: Qt.darker(foreground, 1.55)
|
||||||
|
|
||||||
|
readonly property var currentKey: {
|
||||||
|
if (!status || keyIndex < 0) return null
|
||||||
|
for (var i = 0; i < status.keys.length; i++) {
|
||||||
|
if (Number(status.keys[i].index) === keyIndex) return status.keys[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
readonly property var original: Model.editorDraft(currentKey)
|
||||||
|
readonly property bool isNew: currentKey === null
|
||||||
|
|
||||||
|
// The working copy.
|
||||||
|
property var draft: Model.editorDraft(null)
|
||||||
|
|
||||||
|
// `touched` is set by user edits rather than derived from draft-vs-original.
|
||||||
|
// A derived flag would be read by onCurrentKeyChanged while `original` was
|
||||||
|
// still evaluating, which Qt reports as a binding loop.
|
||||||
|
property bool touched: false
|
||||||
|
readonly property bool dirty: touched && Model.draftDiffers(draft, original)
|
||||||
|
readonly property bool usingModule: String(draft.module || "") !== ""
|
||||||
|
|
||||||
|
// The parameter Repeater's model. Rebuilt ONLY when the parameter name set
|
||||||
|
// changes (module/function switch), never on keystrokes or status polls —
|
||||||
|
// a model whose identity churned with `draft` or `status` would destroy and
|
||||||
|
// recreate the delegates, dropping focus after every typed character.
|
||||||
|
property var paramNames: []
|
||||||
|
|
||||||
|
// Fired when param VALUES in the draft changed underneath the delegates
|
||||||
|
// (reset, module switch). Delegates re-pull their value unless focused.
|
||||||
|
signal paramsSynced()
|
||||||
|
|
||||||
|
onKeyIndexChanged: reset()
|
||||||
|
|
||||||
|
// A background poll replaces the status object every few seconds. Adopt the
|
||||||
|
// refreshed key only when the user has nothing in flight.
|
||||||
|
//
|
||||||
|
// The adoption is deferred: `currentKey` is evaluated lazily, so the first
|
||||||
|
// read of `dirty` (via `original`) is what triggers this handler. Writing
|
||||||
|
// `draft` synchronously here would invalidate `dirty` while it is still being
|
||||||
|
// computed, which Qt reports as a binding loop. Qt.callLater moves the write
|
||||||
|
// to after the current evaluation pass.
|
||||||
|
onCurrentKeyChanged: if (!touched) Qt.callLater(adoptIfClean)
|
||||||
|
|
||||||
|
Component.onCompleted: reset()
|
||||||
|
|
||||||
|
// Escape closes the editor — it bubbles up here from whichever field has
|
||||||
|
// focus, since QQC2 TextField doesn't consume it.
|
||||||
|
Keys.onEscapePressed: closed()
|
||||||
|
|
||||||
|
// The shipped-panel idiom for inline editors: focus a real control the moment
|
||||||
|
// the editor appears (see network's password field). Landing focus on a plain
|
||||||
|
// Item would leave typed keys dead and Tab navigation stranded.
|
||||||
|
onVisibleChanged: if (visible) Qt.callLater(focusFirstField)
|
||||||
|
|
||||||
|
function focusFirstField() {
|
||||||
|
if (!visible) return
|
||||||
|
commandField.forceActiveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function adoptIfClean() {
|
||||||
|
if (!touched) reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
draft = Model.editorDraft(currentKey)
|
||||||
|
touched = false
|
||||||
|
rebuildParams()
|
||||||
|
syncFields()
|
||||||
|
paramsSynced()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildParams() {
|
||||||
|
var rows = status ? Model.paramRows(status, draft.module, draft["function"], draft.params) : []
|
||||||
|
var names = rows.map(function(row) { return { name: row.name, placeholder: row.placeholder } })
|
||||||
|
// Identity guard: an unchanged name set must not touch the model, or the
|
||||||
|
// Repeater rebuilds delegates for nothing.
|
||||||
|
if (JSON.stringify(names) !== JSON.stringify(paramNames)) paramNames = names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push draft values into the controls. Focused fields and open popups are
|
||||||
|
// left alone so a sync landing mid-interaction cannot fight the user.
|
||||||
|
function syncFields() {
|
||||||
|
if (!commandField.activeFocus) commandField.text = draft.command
|
||||||
|
if (!labelField.activeFocus) labelField.text = draft.text
|
||||||
|
if (!colorField.activeFocus) colorField.text = draft.textColor
|
||||||
|
if (!moduleDropdown.popupOpen) moduleDropdown.value = draft.module
|
||||||
|
if (!functionDropdown.popupOpen) functionDropdown.value = draft["function"]
|
||||||
|
if (!iconDropdown.popupOpen) iconDropdown.value = draft.icon
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramValueFor(name) {
|
||||||
|
var value = draft.params ? draft.params[name] : undefined
|
||||||
|
return (value === undefined || value === null) ? "" : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setField(name, value) {
|
||||||
|
if (String(draft[name] || "") === String(value)) return
|
||||||
|
var next = Model.shallowCopy(draft)
|
||||||
|
next.params = Model.shallowCopy(draft.params)
|
||||||
|
next[name] = value
|
||||||
|
// Switching or clearing the module invalidates the function and its params.
|
||||||
|
if (name === "module") {
|
||||||
|
next["function"] = ""
|
||||||
|
next.params = {}
|
||||||
|
}
|
||||||
|
if (name === "function") next.params = {}
|
||||||
|
draft = next
|
||||||
|
touched = true
|
||||||
|
if (name === "module" || name === "function") {
|
||||||
|
rebuildParams()
|
||||||
|
paramsSynced()
|
||||||
|
}
|
||||||
|
syncFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setParam(name, value) {
|
||||||
|
if (String(draft.params[name] || "") === String(value)) return
|
||||||
|
var next = Model.shallowCopy(draft)
|
||||||
|
next.params = Model.shallowCopy(draft.params)
|
||||||
|
next.params[name] = value
|
||||||
|
draft = next
|
||||||
|
touched = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!service) return
|
||||||
|
service.saveKey(keyIndex, Model.editArguments(draft, original))
|
||||||
|
touched = false
|
||||||
|
saved()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKey() {
|
||||||
|
if (!service) return
|
||||||
|
service.clearKey(keyIndex)
|
||||||
|
touched = false
|
||||||
|
closed()
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitHeight: column.implicitHeight
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: column
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
spacing: Style.space(10)
|
||||||
|
|
||||||
|
// ── header ───────────────────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: (root.isNew ? "New key " : "Key ") + root.keyIndex
|
||||||
|
color: root.foreground
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.subtitle
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
iconText: ""
|
||||||
|
tooltipText: "Revert changes"
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
enabled: root.dirty
|
||||||
|
opacity: enabled ? 1.0 : 0.35
|
||||||
|
onClicked: root.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
iconText: ""
|
||||||
|
tooltipText: "Delete this key"
|
||||||
|
foreground: Color.urgent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
enabled: !root.isNew
|
||||||
|
opacity: enabled ? 1.0 : 0.35
|
||||||
|
onClicked: root.removeKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
iconText: ""
|
||||||
|
tooltipText: "Close editor"
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
onClicked: root.closed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parts of the key this editor doesn't cover are preserved on save (merge
|
||||||
|
// semantics), but say so rather than leaving them invisible.
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: !!(root.currentKey && (root.currentKey.poll || root.currentKey.textCommand))
|
||||||
|
text: "This key also has " +
|
||||||
|
(root.currentKey && root.currentKey.poll ? "a poll block" : "live text") +
|
||||||
|
" — saved edits keep it, but edit it in config.yaml"
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── action: module function, or a plain shell command ────────────────────
|
||||||
|
|
||||||
|
FieldLabel {
|
||||||
|
text: "MODULE"
|
||||||
|
visible: root.status && root.status.modules.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Dropdown {
|
||||||
|
id: moduleDropdown
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: root.status && root.status.modules.length > 0
|
||||||
|
showLabel: false
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
options: root.status ? Model.moduleOptions(root.status) : []
|
||||||
|
onChanged: function(v) { root.setField("module", v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldLabel { text: "FUNCTION"; visible: root.usingModule }
|
||||||
|
|
||||||
|
Dropdown {
|
||||||
|
id: functionDropdown
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: root.usingModule
|
||||||
|
showLabel: false
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
options: root.status ? Model.functionOptions(root.status, root.draft.module) : []
|
||||||
|
onChanged: function(v) { root.setField("function", v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldLabel {
|
||||||
|
text: "PARAMETERS"
|
||||||
|
visible: root.usingModule && root.paramNames.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.usingModule ? root.paramNames : []
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: paramRow
|
||||||
|
required property var modelData
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: Style.space(76)
|
||||||
|
text: paramRow.modelData.name
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: paramField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
placeholderText: paramRow.modelData.placeholder
|
||||||
|
Component.onCompleted: text = root.paramValueFor(paramRow.modelData.name)
|
||||||
|
onTextEdited: root.setParam(paramRow.modelData.name, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: root
|
||||||
|
function onParamsSynced() {
|
||||||
|
if (!paramField.activeFocus) paramField.text = root.paramValueFor(paramRow.modelData.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldLabel { text: root.usingModule ? "COMMAND — OVERRIDES THE MODULE" : "COMMAND" }
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: commandField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
placeholderText: "shell command to run on press"
|
||||||
|
onTextEdited: root.setField("command", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSeparator { Layout.fillWidth: true; foreground: root.foreground }
|
||||||
|
|
||||||
|
// ── appearance ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
FieldLabel { text: "ICON" }
|
||||||
|
|
||||||
|
Dropdown {
|
||||||
|
id: iconDropdown
|
||||||
|
Layout.fillWidth: true
|
||||||
|
showLabel: false
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
options: root.status ? Model.iconOptions(root.status) : []
|
||||||
|
onChanged: function(v) { root.setField("icon", v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.spacing.labelGap
|
||||||
|
|
||||||
|
FieldLabel { text: "LABEL" }
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: labelField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
placeholderText: "text drawn on the key"
|
||||||
|
onTextEdited: root.setField("text", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.preferredWidth: Style.space(104)
|
||||||
|
spacing: Style.spacing.labelGap
|
||||||
|
|
||||||
|
FieldLabel { text: "COLOUR" }
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: colorField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
placeholderText: "white"
|
||||||
|
onTextEdited: root.setField("textColor", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── footer ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.dirty ? "Unsaved changes" : "Saved edits reload the deck immediately"
|
||||||
|
color: root.dirty ? root.foreground : root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Save"
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
enabled: root.dirty
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
onClicked: root.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldLabel is the small-caps heading above each control.
|
||||||
|
component FieldLabel: PanelSectionHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
}
|
||||||
|
}
|
||||||
200
omarchy-plugin/KeyGrid.qml
Normal file
200
omarchy-plugin/KeyGrid.qml
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Commons
|
||||||
|
import "Model.js" as Model
|
||||||
|
|
||||||
|
// The deck laid out as it sits on the desk. Grid shape comes from the device
|
||||||
|
// report (8x4 on an XL, 5x3 on an MK.2), so this is not hardcoded to one model.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var slots: []
|
||||||
|
property int cols: 8
|
||||||
|
property int rows: 4
|
||||||
|
property int cursorIndex: -1
|
||||||
|
property bool cursorActive: false
|
||||||
|
property color foreground: Color.foreground
|
||||||
|
property color accent: Color.accent
|
||||||
|
property string fontFamily: Style.font.family
|
||||||
|
// Icons are previewed from disk when the file is a raster format; SVG and GIF
|
||||||
|
// fall back to the kind glyph rather than risking a slow or failed decode in
|
||||||
|
// the panel's paint path.
|
||||||
|
property bool showIconPreviews: true
|
||||||
|
|
||||||
|
signal keyActivated(int index)
|
||||||
|
signal keyFocused(int index)
|
||||||
|
signal keySecondary(int index)
|
||||||
|
|
||||||
|
// Themes are not tagged light/dark, so derive it from the panel background's
|
||||||
|
// perceived luminance (Rec. 601 weights).
|
||||||
|
readonly property color panelBackground: Color.popups.background
|
||||||
|
readonly property bool lightTheme: (0.299 * panelBackground.r
|
||||||
|
+ 0.587 * panelBackground.g
|
||||||
|
+ 0.114 * panelBackground.b) > 0.5
|
||||||
|
// Stands in for the deck's physical key face.
|
||||||
|
readonly property color keyFace: Qt.rgba(0.08, 0.08, 0.08, 1.0)
|
||||||
|
|
||||||
|
readonly property real spacing: Style.space(4)
|
||||||
|
readonly property real cellWidth: cols > 0 ? (width - spacing * (cols - 1)) / cols : 0
|
||||||
|
// Stream Deck keys are square; keeping the cells square makes the panel read
|
||||||
|
// as the physical device rather than an abstract table.
|
||||||
|
readonly property real cellHeight: cellWidth
|
||||||
|
|
||||||
|
implicitHeight: rows > 0 ? cellHeight * rows + spacing * (rows - 1) : 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
// Modelled on the slot COUNT, not the slots array: `slots` gets a fresh
|
||||||
|
// identity on every status poll, and a Repeater over a JS array rebuilds
|
||||||
|
// every delegate on identity change — 32 cells of object churn and a frame
|
||||||
|
// of Image flicker per poll. With an int model the delegates persist and
|
||||||
|
// only the bindings that actually changed re-evaluate.
|
||||||
|
model: root.cols * root.rows
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: cell
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property var slot: (index < root.slots.length)
|
||||||
|
? root.slots[index]
|
||||||
|
: ({ index: index, configured: false, kind: "empty", label: "" })
|
||||||
|
|
||||||
|
readonly property int keyIndex: Number(slot.index)
|
||||||
|
readonly property bool configured: slot.configured === true
|
||||||
|
readonly property bool hasCursor: root.cursorActive && root.cursorIndex === keyIndex
|
||||||
|
readonly property bool hot: mouse.containsMouse || hasCursor
|
||||||
|
|
||||||
|
x: (index % root.cols) * (root.cellWidth + root.spacing)
|
||||||
|
y: Math.floor(index / root.cols) * (root.cellHeight + root.spacing)
|
||||||
|
width: root.cellWidth
|
||||||
|
height: root.cellHeight
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: face
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Style.cornerRadius > 0 ? Style.space(3) : 0
|
||||||
|
|
||||||
|
// Every colour here resolves through Style/Color, so the grid repaints
|
||||||
|
// itself when the Omarchy theme changes.
|
||||||
|
color: cell.hot ? Style.hoverFillFor(root.foreground, root.accent)
|
||||||
|
: cell.configured ? Style.normalFillFor(root.foreground, root.accent)
|
||||||
|
: "transparent"
|
||||||
|
|
||||||
|
border.width: cell.hot ? Style.hoverBorderWidth : Style.normalBorderWidth
|
||||||
|
border.color: cell.hot ? Style.hoverBorderFor(root.foreground, root.accent)
|
||||||
|
: Util.alpha(root.foreground, cell.configured ? 0.28 : 0.12)
|
||||||
|
|
||||||
|
Behavior on color { ColorAnimation { duration: 90 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deck icons are usually light artwork on transparency, drawn for the
|
||||||
|
// hardware's black key faces — on a light theme they would wash out. Under
|
||||||
|
// a light theme only, back the preview with a dark plate standing in for
|
||||||
|
// the physical key. Dark themes need no plate and get none.
|
||||||
|
Rectangle {
|
||||||
|
anchors.centerIn: preview
|
||||||
|
width: preview.width * 1.12
|
||||||
|
height: preview.height * 1.12
|
||||||
|
radius: Style.cornerRadius > 0 ? Style.space(2) : 0
|
||||||
|
visible: preview.visible && root.lightTheme
|
||||||
|
color: root.keyFace
|
||||||
|
opacity: 0.9
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon preview, when the key has one and it is a format Image decodes
|
||||||
|
// cheaply. Falls back to the kind glyph below.
|
||||||
|
Image {
|
||||||
|
id: preview
|
||||||
|
anchors.centerIn: parent
|
||||||
|
// Lifted off centre so the artwork clears the label strip along the
|
||||||
|
// bottom edge of the cell.
|
||||||
|
anchors.verticalCenterOffset: -Style.space(4)
|
||||||
|
width: parent.width * 0.46
|
||||||
|
height: width
|
||||||
|
visible: root.showIconPreviews && status === Image.Ready
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
smooth: true
|
||||||
|
asynchronous: true
|
||||||
|
cache: true
|
||||||
|
sourceSize.width: Math.max(24, Math.round(width))
|
||||||
|
sourceSize.height: Math.max(24, Math.round(width))
|
||||||
|
source: {
|
||||||
|
if (!root.showIconPreviews || !cell.configured) return ""
|
||||||
|
var path = String(cell.slot.iconPath || "")
|
||||||
|
if (path === "") return ""
|
||||||
|
var lower = path.toLowerCase()
|
||||||
|
if (!lower.endsWith(".png") && !lower.endsWith(".jpg") && !lower.endsWith(".jpeg")) return ""
|
||||||
|
return Util.fileUrl(path)
|
||||||
|
}
|
||||||
|
opacity: cell.configured ? 1.0 : 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
anchors.verticalCenterOffset: -Style.space(4)
|
||||||
|
visible: cell.configured && !preview.visible
|
||||||
|
text: Model.kindGlyph(String(cell.slot.kind || ""))
|
||||||
|
color: root.foreground
|
||||||
|
opacity: 0.75
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Math.max(Style.font.caption, cell.height * 0.34)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot number, always visible so the grid maps onto config.yaml indices.
|
||||||
|
Text {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.topMargin: Style.space(2)
|
||||||
|
anchors.leftMargin: Style.space(3)
|
||||||
|
text: cell.keyIndex
|
||||||
|
color: root.foreground
|
||||||
|
opacity: cell.configured ? 0.45 : 0.28
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottomMargin: Style.space(2)
|
||||||
|
anchors.leftMargin: Style.space(2)
|
||||||
|
anchors.rightMargin: Style.space(2)
|
||||||
|
visible: cell.configured && Model.gridLabel(cell.slot) !== ""
|
||||||
|
text: Model.gridLabel(cell.slot)
|
||||||
|
color: root.foreground
|
||||||
|
opacity: 0.8
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
// A privileged key is worth flagging: pressing it from here is refused by
|
||||||
|
// the CLI, and it behaves differently on the deck itself.
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.topMargin: Style.space(3)
|
||||||
|
anchors.rightMargin: Style.space(3)
|
||||||
|
visible: cell.slot.privileged === true
|
||||||
|
width: Style.space(4)
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: Color.urgent
|
||||||
|
opacity: 0.85
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onEntered: root.keyFocused(cell.keyIndex)
|
||||||
|
onClicked: function(event) {
|
||||||
|
if (event.button === Qt.RightButton) root.keySecondary(cell.keyIndex)
|
||||||
|
else root.keyActivated(cell.keyIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
omarchy-plugin/LICENSE
Normal file
21
omarchy-plugin/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Levi Woodard
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
311
omarchy-plugin/Model.js
Normal file
311
omarchy-plugin/Model.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
.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 "folder": return "" // folder
|
||||||
|
case "toggle": return "" // toggle-switch
|
||||||
|
case "module": return "" // puzzle piece
|
||||||
|
case "text": return "" // text
|
||||||
|
case "static": return "" // image
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeKey is the detail line shown when a grid cell is focused. Module keys
|
||||||
|
// show only module · function — their rendered command can inline secrets, so
|
||||||
|
// streamdeck-ctl no longer emits it at all.
|
||||||
|
function describeKey(key) {
|
||||||
|
if (!key || !key.configured) return "Empty slot — click to configure"
|
||||||
|
var parts = []
|
||||||
|
if (key.module && key.function) parts.push(key.module + " · " + key.function)
|
||||||
|
else if (key.command) parts.push(truncate(key.command, 70))
|
||||||
|
if (key.poll) parts.push("polls every " + (key.poll.interval || "2s"))
|
||||||
|
if (key.privileged) parts.push("privileged")
|
||||||
|
return parts.length ? parts.join(" · ") : "No command"
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text, limit) {
|
||||||
|
var value = String(text || "").replace(/\s+/g, " ").trim()
|
||||||
|
return value.length > limit ? value.substring(0, limit - 1) + "…" : value
|
||||||
|
}
|
||||||
|
|
||||||
|
// gridLabel keeps cell text to something that fits an 8-column grid.
|
||||||
|
function gridLabel(key) {
|
||||||
|
if (!key || !key.configured) return ""
|
||||||
|
return truncate(key.label || "", 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// iconOptions builds the dropdown model for icon selection: "(none)" plus every
|
||||||
|
// image file the CLI found in icons_dir.
|
||||||
|
function iconOptions(status) {
|
||||||
|
var options = [{ value: "", label: "(none)" }]
|
||||||
|
for (var i = 0; i < status.icons.length; i++) {
|
||||||
|
options.push({ value: status.icons[i], label: status.icons[i] })
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleOptions(status) {
|
||||||
|
var options = [{ value: "", label: "(no module)" }]
|
||||||
|
for (var i = 0; i < status.modules.length; i++) {
|
||||||
|
options.push({ value: status.modules[i].name, label: status.modules[i].name })
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionOptions(status, moduleName) {
|
||||||
|
var options = [{ value: "", label: "(no function)" }]
|
||||||
|
if (!moduleName) return options
|
||||||
|
for (var i = 0; i < status.modules.length; i++) {
|
||||||
|
if (status.modules[i].name !== moduleName) continue
|
||||||
|
var functions = status.modules[i].functions || []
|
||||||
|
for (var j = 0; j < functions.length; j++) {
|
||||||
|
options.push({ value: functions[j].name, label: functions[j].name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
// functionParams returns the declared default params for a module function, used
|
||||||
|
// to seed the editor's parameter rows.
|
||||||
|
function functionParams(status, moduleName, functionName) {
|
||||||
|
for (var i = 0; i < status.modules.length; i++) {
|
||||||
|
if (status.modules[i].name !== moduleName) continue
|
||||||
|
var functions = status.modules[i].functions || []
|
||||||
|
for (var j = 0; j < functions.length; j++) {
|
||||||
|
if (functions[j].name === functionName) return functions[j].params || {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// paramRows merges a function's declared defaults with the values already set on
|
||||||
|
// the key, so the editor shows every parameter the function accepts.
|
||||||
|
function paramRows(status, moduleName, functionName, current) {
|
||||||
|
var defaults = functionParams(status, moduleName, functionName)
|
||||||
|
var names = {}
|
||||||
|
var name
|
||||||
|
for (name in defaults) names[name] = true
|
||||||
|
for (name in (current || {})) names[name] = true
|
||||||
|
|
||||||
|
var ordered = Object.keys(names).sort()
|
||||||
|
var rows = []
|
||||||
|
for (var i = 0; i < ordered.length; i++) {
|
||||||
|
name = ordered[i]
|
||||||
|
var value = (current && current[name] !== undefined) ? current[name] : defaults[name]
|
||||||
|
rows.push({
|
||||||
|
name: name,
|
||||||
|
value: value === undefined || value === null ? "" : String(value),
|
||||||
|
placeholder: defaults[name] === undefined ? "" : String(defaults[name])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorDraft snapshots a key into the flat shape the editor binds to.
|
||||||
|
function editorDraft(key) {
|
||||||
|
return {
|
||||||
|
index: key ? Number(key.index) : 0,
|
||||||
|
icon: key && key.icon ? key.icon : "",
|
||||||
|
text: key && key.text ? key.text : "",
|
||||||
|
textColor: key && key.textColor ? key.textColor : "",
|
||||||
|
command: key && key.command ? key.command : "",
|
||||||
|
module: key && key.module ? key.module : "",
|
||||||
|
function: key && key.function ? key.function : "",
|
||||||
|
params: key && key.params ? shallowCopy(key.params) : {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shallowCopy(source) {
|
||||||
|
var copy = {}
|
||||||
|
for (var name in source) copy[name] = source[name]
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
// editArguments turns an editor draft into streamdeck-ctl `key set` flags.
|
||||||
|
// Fields the user emptied are passed as -unset so they leave the YAML entirely
|
||||||
|
// rather than lingering as empty strings.
|
||||||
|
function editArguments(draft, original) {
|
||||||
|
var args = []
|
||||||
|
|
||||||
|
function apply(flag, field, value) {
|
||||||
|
var text = String(value || "").trim()
|
||||||
|
var had = String((original && original[field]) || "").trim() !== ""
|
||||||
|
if (text !== "") args.push(flag, text)
|
||||||
|
else if (had) args.push("-unset", yamlField(field))
|
||||||
|
}
|
||||||
|
|
||||||
|
apply("-icon", "icon", draft.icon)
|
||||||
|
apply("-text", "text", draft.text)
|
||||||
|
apply("-text-color", "textColor", draft.textColor)
|
||||||
|
apply("-command", "command", draft.command)
|
||||||
|
apply("-module", "module", draft.module)
|
||||||
|
apply("-function", "function", draft.function)
|
||||||
|
|
||||||
|
// Params only travel with a module function; without one they have nothing to
|
||||||
|
// substitute into, and the daemon would ignore them.
|
||||||
|
if (String(draft.module || "").trim() !== "" && String(draft.function || "").trim() !== "") {
|
||||||
|
var wrote = false
|
||||||
|
for (var name in draft.params) {
|
||||||
|
var value = draft.params[name]
|
||||||
|
if (value === undefined || value === null) continue
|
||||||
|
args.push("-param", name + "=" + value)
|
||||||
|
wrote = true
|
||||||
|
}
|
||||||
|
if (!wrote && original && original.params && Object.keys(original.params).length > 0) {
|
||||||
|
args.push("-unset", "params")
|
||||||
|
}
|
||||||
|
} else if (original && original.params && Object.keys(original.params).length > 0) {
|
||||||
|
args.push("-unset", "params")
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlField maps the editor's camelCase field names to the YAML keys the CLI's
|
||||||
|
// -unset flag expects.
|
||||||
|
function yamlField(field) {
|
||||||
|
switch (field) {
|
||||||
|
case "textColor": return "text_color"
|
||||||
|
case "function": return "function"
|
||||||
|
default: return field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// draftDiffers reports whether the editor has unsaved changes.
|
||||||
|
function draftDiffers(draft, original) {
|
||||||
|
var fields = ["icon", "text", "textColor", "command", "module", "function"]
|
||||||
|
for (var i = 0; i < fields.length; i++) {
|
||||||
|
var field = fields[i]
|
||||||
|
if (String(draft[field] || "") !== String(original[field] || "")) return true
|
||||||
|
}
|
||||||
|
var name
|
||||||
|
for (name in draft.params) {
|
||||||
|
if (String(draft.params[name] || "") !== String((original.params || {})[name] || "")) return true
|
||||||
|
}
|
||||||
|
for (name in (original.params || {})) {
|
||||||
|
if (draft.params[name] === undefined) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
501
omarchy-plugin/Panel.qml
Normal file
501
omarchy-plugin/Panel.qml
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Ui
|
||||||
|
import "Model.js" as Model
|
||||||
|
|
||||||
|
// Bar widget and popout panel for streamdeck-go.
|
||||||
|
//
|
||||||
|
// Every colour and metric resolves through qs.Commons (Color/Style), so the
|
||||||
|
// widget repaints itself to match whatever Omarchy theme is active rather than
|
||||||
|
// carrying a palette of its own.
|
||||||
|
Panel {
|
||||||
|
id: root
|
||||||
|
moduleName: "dev.woodard.streamdeck"
|
||||||
|
ipcTarget: "dev.woodard.streamdeck"
|
||||||
|
manageIpc: false
|
||||||
|
|
||||||
|
// ── theme-derived palette ──────────────────────────────────────────────────
|
||||||
|
readonly property color foreground: bar ? bar.foreground : Color.foreground
|
||||||
|
readonly property color accent: Color.accent
|
||||||
|
readonly property color urgent: bar ? bar.urgent : Color.urgent
|
||||||
|
readonly property color dim: Qt.darker(foreground, 1.55)
|
||||||
|
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||||
|
|
||||||
|
// The bar icon dims when the deck is unreachable, the same convention the
|
||||||
|
// built-in network and bluetooth widgets use.
|
||||||
|
readonly property bool live: deck.connected && deck.daemonActive
|
||||||
|
readonly property color barIconColor: live ? barForeground : Qt.darker(barForeground, 1.55)
|
||||||
|
|
||||||
|
readonly property var slots: Model.keySlots(deck.status)
|
||||||
|
readonly property int cols: Number(deck.status.device.cols) || 8
|
||||||
|
readonly property int rows: Number(deck.status.device.rows) || 4
|
||||||
|
|
||||||
|
// ── panel navigation state ─────────────────────────────────────────────────
|
||||||
|
property int cursorIndex: 0
|
||||||
|
property bool cursorActive: false
|
||||||
|
property int editingIndex: -1
|
||||||
|
readonly property bool editing: editingIndex >= 0
|
||||||
|
|
||||||
|
// A device-shape change (XL -> MK.2, or the first real poll shrinking the
|
||||||
|
// fallback grid) can strand the cursor or the editor beyond the last slot.
|
||||||
|
onSlotsChanged: {
|
||||||
|
if (cursorIndex >= slots.length) cursorIndex = Math.max(0, slots.length - 1)
|
||||||
|
if (editing && editingIndex >= slots.length) closeEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursor(dx, dy) {
|
||||||
|
cursorActive = true
|
||||||
|
if (editing) return
|
||||||
|
var next = cursorIndex + dx + dy * cols
|
||||||
|
if (next < 0 || next >= slots.length) return
|
||||||
|
cursorIndex = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateCursor() {
|
||||||
|
if (editing) return
|
||||||
|
editKey(cursorIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusKey(index) {
|
||||||
|
cursorActive = true
|
||||||
|
cursorIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function editKey(index) {
|
||||||
|
cursorIndex = index
|
||||||
|
editingIndex = index
|
||||||
|
// Focus lands inside the editor via its own onVisibleChanged handler —
|
||||||
|
// the key catcher is blocked while editing.
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
editingIndex = -1
|
||||||
|
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var focusedKey: {
|
||||||
|
for (var i = 0; i < slots.length; i++) {
|
||||||
|
if (Number(slots[i].index) === cursorIndex) return slots[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitWidth: button.implicitWidth
|
||||||
|
implicitHeight: button.implicitHeight
|
||||||
|
visible: !(deck.setting("hideWhenDisconnected", false) === true && deck.loaded && !deck.connected)
|
||||||
|
|
||||||
|
onOpenedChanged: if (opened) {
|
||||||
|
cursorActive = false
|
||||||
|
editingIndex = -1
|
||||||
|
if (panelFlick) panelFlick.contentY = 0
|
||||||
|
deck.refresh()
|
||||||
|
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||||
|
}
|
||||||
|
|
||||||
|
Service {
|
||||||
|
id: deck
|
||||||
|
settings: root.settings
|
||||||
|
panelOpen: root.opened
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: root.ipcTarget
|
||||||
|
function open(): void { root.open() }
|
||||||
|
function close(): void { root.close() }
|
||||||
|
function show(): void { root.open() }
|
||||||
|
function hide(): void { root.close() }
|
||||||
|
function toggle(): void { root.toggle() }
|
||||||
|
function refresh(): string { deck.refresh(); return "ok" }
|
||||||
|
function status(): string { return Model.statusLine(deck.status) }
|
||||||
|
function brightness(level: string): string {
|
||||||
|
var value = parseInt(level, 10)
|
||||||
|
if (!isFinite(value)) return "expected a number 0-100"
|
||||||
|
deck.setBrightness(value)
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
function press(index: string): string {
|
||||||
|
var value = parseInt(index, 10)
|
||||||
|
if (!isFinite(value)) return "expected a key index"
|
||||||
|
deck.pressKey(value)
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── bar icon ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BarIconButton {
|
||||||
|
id: button
|
||||||
|
anchors.fill: parent
|
||||||
|
bar: root.bar
|
||||||
|
iconComponent: Component {
|
||||||
|
Item {
|
||||||
|
StreamDeckIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
iconSize: Style.space(13)
|
||||||
|
color: root.barIconColor
|
||||||
|
// The icon fills in proportionally to how much of the deck is mapped,
|
||||||
|
// so a glance at the bar says whether the deck is live and loaded.
|
||||||
|
litKeys: {
|
||||||
|
if (!deck.connected) return 0
|
||||||
|
var total = Number(deck.status.device.keyCount) || 0
|
||||||
|
if (total === 0) return 6
|
||||||
|
var used = Model.countConfigured(deck.status)
|
||||||
|
return Math.max(1, Math.round((used / total) * 6))
|
||||||
|
}
|
||||||
|
opacity: root.live ? 1.0 : 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onPressed: function(buttonCode) {
|
||||||
|
if (buttonCode === Qt.RightButton) deck.refresh()
|
||||||
|
else if (buttonCode === Qt.MiddleButton) deck.toggleDaemon()
|
||||||
|
else root.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── panel ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
KeyboardPanel {
|
||||||
|
id: panel
|
||||||
|
anchorItem: button
|
||||||
|
owner: root
|
||||||
|
bar: root.bar
|
||||||
|
open: root.opened
|
||||||
|
focusTarget: keyCatcher
|
||||||
|
// Wide enough that an 8-column grid still gives each key a legible face.
|
||||||
|
contentWidth: panel.fittedContentWidth(Style.space(520))
|
||||||
|
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(640))
|
||||||
|
|
||||||
|
PanelKeyCatcher {
|
||||||
|
id: keyCatcher
|
||||||
|
anchors.fill: parent
|
||||||
|
// While the editor is up its fields own the keyboard — the shipped-panel
|
||||||
|
// idiom for inline editors. Escape then bubbles to the editor's own
|
||||||
|
// handler instead of tearing down the whole panel mid-edit.
|
||||||
|
blocked: root.editing
|
||||||
|
onMoveRequested: function(dx, dy) {
|
||||||
|
if (!root.cursorActive) { root.cursorActive = true; return }
|
||||||
|
root.moveCursor(dx, dy)
|
||||||
|
}
|
||||||
|
onActivateRequested: if (root.cursorActive) root.activateCursor()
|
||||||
|
onCloseRequested: root.close()
|
||||||
|
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||||
|
onTextKey: function(t) {
|
||||||
|
if (t === "r" || t === "R") deck.refresh()
|
||||||
|
else if (t === "p" || t === "P") deck.toggleDaemon()
|
||||||
|
else if (t === "e" || t === "E") root.editKey(root.cursorIndex)
|
||||||
|
else if (t === " ") deck.pressKey(root.cursorIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: panelFlick
|
||||||
|
anchors.fill: parent
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: column.implicitHeight
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
flickableDirection: Flickable.VerticalFlick
|
||||||
|
interactive: contentHeight > height
|
||||||
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: column
|
||||||
|
width: panelFlick.width
|
||||||
|
spacing: Style.space(12)
|
||||||
|
|
||||||
|
// ── hero: title, status line, daemon switch ────────────────────────
|
||||||
|
Item {
|
||||||
|
id: header
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: hero.implicitHeight
|
||||||
|
|
||||||
|
// Exposed for the hero's trailingControl, whose `root` resolves to
|
||||||
|
// PanelHero rather than this Panel.
|
||||||
|
readonly property bool ringVisible: false
|
||||||
|
|
||||||
|
PanelHero {
|
||||||
|
id: hero
|
||||||
|
width: parent.width
|
||||||
|
title: "Stream Deck"
|
||||||
|
meta: Model.statusLine(deck.status)
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
iconOpacity: root.live ? 1.0 : 0.5
|
||||||
|
iconComponent: Component {
|
||||||
|
StreamDeckIcon {
|
||||||
|
iconSize: Style.font.display
|
||||||
|
color: root.live ? root.foreground : root.dim
|
||||||
|
litKeys: deck.connected ? 6 : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trailingControl: Component {
|
||||||
|
ToggleSwitch {
|
||||||
|
id: powerSwitch
|
||||||
|
visible: !deck.ctlMissing && deck.status.daemon.state !== "unknown"
|
||||||
|
checked: deck.daemonActive
|
||||||
|
busy: deck.busy
|
||||||
|
foreground: hero.foreground
|
||||||
|
onToggled: deck.toggleDaemon()
|
||||||
|
|
||||||
|
PanelToolTip {
|
||||||
|
visible: powerSwitch.containsMouse
|
||||||
|
text: deck.daemonActive ? "Stop the daemon" : "Start the daemon"
|
||||||
|
fontFamily: hero.fontFamily
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── messages ───────────────────────────────────────────────────────
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: deck.actionStatus !== "" || deck.lastError !== ""
|
||||||
|
text: deck.actionStatus !== "" ? deck.actionStatus : deck.lastError
|
||||||
|
color: deck.lastError !== "" && deck.actionStatus === "" ? root.urgent : root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamdeck-ctl is what makes this widget work at all; if it isn't
|
||||||
|
// installed, say so plainly instead of showing dead controls.
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: deck.ctlMissing
|
||||||
|
spacing: Style.spacing.labelGap
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "streamdeck-ctl was not found"
|
||||||
|
color: root.foreground
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Build and install it from the streamdeck-go repo:\nmake build-ctl && make install-ctl"
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── brightness ─────────────────────────────────────────────────────
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: !deck.ctlMissing && !root.editing
|
||||||
|
spacing: Style.spacing.labelGap
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
PanelSectionHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "BRIGHTNESS"
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: deck.brightness + "%"
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSlider {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
bar: root.bar
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
step: 5
|
||||||
|
integer: true
|
||||||
|
value: deck.brightness
|
||||||
|
enabled: deck.connected
|
||||||
|
opacity: deck.connected ? 1.0 : 0.45
|
||||||
|
onMoved: function(v) { deck.setBrightness(v) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── device / daemon facts ──────────────────────────────────────────
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: !deck.ctlMissing && !root.editing
|
||||||
|
spacing: Style.spacing.labelGap
|
||||||
|
|
||||||
|
InfoPair { label: "Device"; value: Model.deviceLine(deck.status) }
|
||||||
|
InfoPair {
|
||||||
|
label: "Connection"
|
||||||
|
value: deck.connected ? "Connected" : "Disconnected"
|
||||||
|
emphasised: !deck.connected
|
||||||
|
}
|
||||||
|
InfoPair { label: "Daemon"; value: Model.daemonLine(deck.status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSeparator {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: !deck.ctlMissing
|
||||||
|
foreground: root.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── key grid ───────────────────────────────────────────────────────
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: !deck.ctlMissing && !root.editing
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
PanelSectionHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "KEYS"
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyGrid {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
slots: root.slots
|
||||||
|
cols: root.cols
|
||||||
|
rows: root.rows
|
||||||
|
cursorIndex: root.cursorIndex
|
||||||
|
cursorActive: root.cursorActive
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
onKeyFocused: function(index) { root.focusKey(index) }
|
||||||
|
// Left-click mirrors a physical press; editing is the secondary action.
|
||||||
|
onKeyActivated: function(index) { deck.pressKey(index) }
|
||||||
|
// Right-click fires the key, mirroring a physical press.
|
||||||
|
onKeySecondary: function(index) { root.editKey(index) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.focusedKey
|
||||||
|
? "Key " + root.cursorIndex + " — " + Model.describeKey(root.focusedKey)
|
||||||
|
: ""
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
maximumLineCount: 2
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Click a key to run it · right-click to edit"
|
||||||
|
color: root.dim
|
||||||
|
opacity: 0.7
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── editor ─────────────────────────────────────────────────────────
|
||||||
|
KeyEditor {
|
||||||
|
id: editorPane
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: root.editing
|
||||||
|
service: deck
|
||||||
|
status: deck.status
|
||||||
|
keyIndex: root.editingIndex
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
onClosed: root.closeEditor()
|
||||||
|
onSaved: root.closeEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSeparator {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: !deck.ctlMissing && !root.editing
|
||||||
|
foreground: root.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── actions ────────────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: !deck.ctlMissing && !root.editing
|
||||||
|
spacing: Style.space(6)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Restart"
|
||||||
|
iconText: ""
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
onClicked: deck.restartDaemon()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Config"
|
||||||
|
iconText: ""
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
onClicked: deck.openConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Icons"
|
||||||
|
iconText: ""
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
onClicked: deck.openIcons()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
iconText: ""
|
||||||
|
tooltipText: "Follow the daemon log"
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
onClicked: deck.openLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoPair is a label on the left, value on the right, with the gap between
|
||||||
|
// them absorbing the leftover width.
|
||||||
|
component InfoPair: RowLayout {
|
||||||
|
id: pair
|
||||||
|
property string label: ""
|
||||||
|
property string value: ""
|
||||||
|
property bool emphasised: false
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: pair.label
|
||||||
|
color: root.foreground
|
||||||
|
opacity: 0.6
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: pair.value
|
||||||
|
color: pair.emphasised ? root.urgent : root.foreground
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
omarchy-plugin/README.md
Normal file
169
omarchy-plugin/README.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Stream Deck — Omarchy shell plugin
|
||||||
|
|
||||||
|
A bar widget for [Omarchy](https://omarchy.org) that controls the
|
||||||
|
[streamdeck-go](https://git.i0t.app/lwoodard/streamdeck-go) daemon: device and
|
||||||
|
daemon status, brightness, and a live 8×4 key grid you can edit in place.
|
||||||
|
|
||||||
|
Every colour and metric resolves through Omarchy's `qs.Commons` theme tokens
|
||||||
|
(`Color.*`, `Style.*`), so the widget follows the active theme rather than
|
||||||
|
carrying a palette of its own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
**Bar icon** — a miniature deck that fills in proportionally to how much of the
|
||||||
|
deck is mapped, and dims when the deck is unplugged or the daemon is stopped.
|
||||||
|
|
||||||
|
**Panel**
|
||||||
|
|
||||||
|
| Section | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| Header | Title, status line, and an on/off switch for the `streamdeck-go` user service |
|
||||||
|
| Brightness | Slider writing `brightness:` into `config.yaml`; the daemon hot-reloads, so the deck dims as you drag |
|
||||||
|
| Facts | Device model, connection state, daemon state and uptime |
|
||||||
|
| Key grid | One cell per physical key, with icon previews, slot indices, and labels. Empty slots are shown so you can fill them |
|
||||||
|
| Editor | Right-click a key to edit its module/function, parameters, shell command, icon, label, and label colour |
|
||||||
|
| Actions | Restart the daemon, open `config.yaml`, open the icons folder, follow the log |
|
||||||
|
|
||||||
|
A red dot on a cell marks a `priv:` key. Those run through the privileged helper
|
||||||
|
and are deliberately **not** runnable from the panel — press them on the deck.
|
||||||
|
|
||||||
|
### Mouse and keyboard
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|---|---|
|
||||||
|
| Left-click bar icon | Open/close the panel |
|
||||||
|
| Right-click bar icon | Refresh |
|
||||||
|
| Middle-click bar icon | Start/stop the daemon |
|
||||||
|
| Left-click a key | Run that key's command (fire-and-forget, like a physical press) |
|
||||||
|
| Right-click a key | Open the editor for that key |
|
||||||
|
| Arrow keys | Move the grid cursor |
|
||||||
|
| `Enter` | Edit the focused key |
|
||||||
|
| `Space` | Run the focused key |
|
||||||
|
| `e` | Edit the focused key |
|
||||||
|
| `r` | Refresh |
|
||||||
|
| `p` | Start/stop the daemon |
|
||||||
|
| `Esc` | Leave the field → close the editor → close the panel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Omarchy with `omarchy-shell` running
|
||||||
|
- `streamdeck-go` installed and its user service present
|
||||||
|
- **`streamdeck-ctl`** — the helper CLI the plugin drives. The panel refuses to
|
||||||
|
show controls without it and tells you how to install it.
|
||||||
|
|
||||||
|
The plugin never edits YAML or probes USB itself; it shells out to
|
||||||
|
`streamdeck-ctl`, which reuses the daemon's own config and device code. That
|
||||||
|
keeps one implementation of the config format rather than two.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
From a checkout of the streamdeck-go repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install-plugin # builds + installs streamdeck-ctl, links the plugin
|
||||||
|
omarchy plugin enable dev.woodard.streamdeck right
|
||||||
|
```
|
||||||
|
|
||||||
|
`make install-plugin` symlinks this directory into
|
||||||
|
`~/.config/omarchy/plugins/dev.woodard.streamdeck`, so edits to the QML are live
|
||||||
|
after a rescan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy-shell shell rescanPlugins
|
||||||
|
```
|
||||||
|
|
||||||
|
> Qt caches compiled QML in `~/.cache/quickshell/qmlcache`, and a running shell
|
||||||
|
> can keep serving a stale compiled unit across a rescan. If an edit doesn't seem
|
||||||
|
> to take effect, `omarchy restart shell`.
|
||||||
|
|
||||||
|
To validate before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make validate-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
`omarchy plugin validate` refuses a symlinked plugin folder by design, so this
|
||||||
|
target validates the source directory rather than the installed link.
|
||||||
|
|
||||||
|
### Remove
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make uninstall-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Configurable from the Omarchy bar widget settings:
|
||||||
|
|
||||||
|
| Setting | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `refreshIntervalSec` | 10 | Poll cadence while the panel is open; closed, it relaxes to at most once a minute |
|
||||||
|
| `ctlPath` | *(empty)* | Explicit path to `streamdeck-ctl`; empty searches `~/.local/bin`, `~/go/bin`, `/usr/local/bin`, then `$PATH` |
|
||||||
|
| `configPath` | *(empty)* | Explicit `config.yaml`; empty uses `~/.config/streamdeck-go/config.yaml` |
|
||||||
|
| `hideWhenDisconnected` | false | Hide the bar icon entirely when no deck is connected |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IPC
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy-shell dev.woodard.streamdeck open
|
||||||
|
omarchy-shell dev.woodard.streamdeck toggle
|
||||||
|
omarchy-shell dev.woodard.streamdeck status # "20 of 32 keys in use"
|
||||||
|
omarchy-shell dev.woodard.streamdeck refresh
|
||||||
|
omarchy-shell dev.woodard.streamdeck brightness 60
|
||||||
|
omarchy-shell dev.woodard.streamdeck press 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful for Hyprland binds — e.g. a key that dims the deck without opening the bar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security note
|
||||||
|
|
||||||
|
Module keys can reference secrets via `{{env "TOKEN"}}` in their exec templates.
|
||||||
|
`streamdeck-ctl status` never emits the rendered command — the panel shows only
|
||||||
|
`module · function` for module keys — so tokens can't leak into the UI or into
|
||||||
|
anything scraping the CLI's JSON. On Linux, `key press` merges the systemd user
|
||||||
|
manager's environment over the caller's before running, so a token imported with
|
||||||
|
`systemctl --user import-environment` reaches panel presses exactly as it reaches
|
||||||
|
hardware presses. (macOS keeps the caller's environment — launchd has no
|
||||||
|
equivalent bulk query.)
|
||||||
|
|
||||||
|
## A note on config formatting
|
||||||
|
|
||||||
|
Saving from the editor rewrites `config.yaml` through a YAML node-level editor.
|
||||||
|
Comments and blank lines are preserved, but two normalisations happen on any
|
||||||
|
save:
|
||||||
|
|
||||||
|
- trailing whitespace on values is trimmed
|
||||||
|
- a comment block dangling at the end of a key entry may be re-indented to match
|
||||||
|
the block it attaches to
|
||||||
|
|
||||||
|
Both are cosmetic. Values, ordering, and comment text are preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.json plugin metadata + settings schema
|
||||||
|
Panel.qml bar icon and popout panel (entry point)
|
||||||
|
Service.qml owns every streamdeck-ctl invocation
|
||||||
|
KeyGrid.qml the 8x4 (or 5x3) key grid
|
||||||
|
KeyEditor.qml per-key editor
|
||||||
|
StreamDeckIcon.qml drawn deck icon, takes its colour from the caller
|
||||||
|
Model.js parsing and formatting helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
311
omarchy-plugin/Service.qml
Normal file
311
omarchy-plugin/Service.qml
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import "Model.js" as Model
|
||||||
|
|
||||||
|
// Service owns every conversation with streamdeck-ctl. The panel binds to the
|
||||||
|
// properties here and calls the functions; it never spawns a process itself.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var settings: ({})
|
||||||
|
|
||||||
|
// Set by the panel. While the popout is closed only the bar icon consumes
|
||||||
|
// status, so polling relaxes to at most once a minute.
|
||||||
|
property bool panelOpen: false
|
||||||
|
|
||||||
|
// Full snapshot from `streamdeck-ctl status`, normalised by Model.parseStatus.
|
||||||
|
property var status: Model.EMPTY_STATUS
|
||||||
|
property bool loaded: false
|
||||||
|
property bool refreshing: false
|
||||||
|
property string lastError: ""
|
||||||
|
property string actionStatus: ""
|
||||||
|
|
||||||
|
// True once we have looked for the binary and failed — the panel shows install
|
||||||
|
// guidance instead of controls in that state.
|
||||||
|
property bool ctlMissing: false
|
||||||
|
property string ctlResolved: ""
|
||||||
|
|
||||||
|
readonly property bool busy: statusProcess.running || actionProcess.running
|
||||||
|
readonly property bool connected: status.device.connected === true
|
||||||
|
readonly property bool daemonActive: status.daemon.active === true
|
||||||
|
|
||||||
|
// Brightness is echoed optimistically so the slider tracks the drag instead of
|
||||||
|
// snapping back on the next poll, which lands up to refreshIntervalSec later.
|
||||||
|
property int pendingBrightness: -1
|
||||||
|
readonly property int brightness: pendingBrightness >= 0 ? pendingBrightness : (Number(status.brightness) || 0)
|
||||||
|
|
||||||
|
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 10, 2, 600)
|
||||||
|
readonly property string configPath: String(setting("configPath", "") || "")
|
||||||
|
|
||||||
|
function setting(name, fallback) {
|
||||||
|
var value = settings ? settings[name] : undefined
|
||||||
|
return value === undefined || value === null || value === "" ? fallback : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function intSetting(name, fallback, min, max) {
|
||||||
|
var n = parseInt(String(setting(name, fallback)), 10)
|
||||||
|
if (!isFinite(n)) n = fallback
|
||||||
|
return Math.max(min, Math.min(max, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── locating streamdeck-ctl ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The shell inherits a minimal PATH under systemd, so ~/.local/bin (Linux) and
|
||||||
|
// ~/go/bin (macOS, and Go's default install target) are checked explicitly
|
||||||
|
// before falling back to a PATH lookup.
|
||||||
|
readonly property string home: Quickshell.env("HOME")
|
||||||
|
readonly property var candidatePaths: [
|
||||||
|
String(setting("ctlPath", "") || ""),
|
||||||
|
home + "/.local/bin/streamdeck-ctl",
|
||||||
|
home + "/go/bin/streamdeck-ctl",
|
||||||
|
"/usr/local/bin/streamdeck-ctl"
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolveCtl() {
|
||||||
|
if (locateProcess.running) return
|
||||||
|
locateProcess.command = ["sh", "-c", locateScript()]
|
||||||
|
locateProcess.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// A changed ctlPath setting (or HOME, in theory) must invalidate the cached
|
||||||
|
// location — without this, ctlResolved is sticky for the life of the shell.
|
||||||
|
onCandidatePathsChanged: {
|
||||||
|
ctlResolved = ""
|
||||||
|
ctlMissing = false
|
||||||
|
resolveCtl()
|
||||||
|
}
|
||||||
|
|
||||||
|
function locateScript() {
|
||||||
|
var checks = []
|
||||||
|
for (var i = 0; i < candidatePaths.length; i++) {
|
||||||
|
var path = candidatePaths[i]
|
||||||
|
if (!path) continue
|
||||||
|
checks.push('[ -x ' + shellQuote(path) + ' ] && { printf %s ' + shellQuote(path) + '; exit 0; }')
|
||||||
|
}
|
||||||
|
checks.push('command -v streamdeck-ctl 2>/dev/null || true')
|
||||||
|
return checks.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellQuote(value) {
|
||||||
|
return "'" + String(value).replace(/'/g, "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseArgs prefixes every invocation, carrying the -config override when the
|
||||||
|
// widget was pointed at a non-default config.
|
||||||
|
function baseArgs() {
|
||||||
|
var args = []
|
||||||
|
if (configPath !== "") args.push("-config", configPath)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── reading ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (ctlResolved === "") {
|
||||||
|
if (!locateProcess.running) resolveCtl()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (statusProcess.running) return
|
||||||
|
refreshing = true
|
||||||
|
statusProcess.command = [ctlResolved].concat(baseArgs()).concat(["status"])
|
||||||
|
statusProcess.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── writing ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setBrightness(value) {
|
||||||
|
var clamped = Math.max(0, Math.min(100, Math.round(value)))
|
||||||
|
pendingBrightness = clamped
|
||||||
|
brightnessDebounce.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDaemon() {
|
||||||
|
runAction(["daemon", "toggle"], daemonActive ? "Stopping daemon…" : "Starting daemon…")
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartDaemon() {
|
||||||
|
runAction(["daemon", "restart"], "Restarting daemon…")
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressKey(index) {
|
||||||
|
runAction(["key", "press", String(index)], "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearKey(index) {
|
||||||
|
runAction(["key", "clear", String(index)], "Cleared key " + index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveKey takes the flag list Model.editArguments built. Returning early on an
|
||||||
|
// empty list keeps a no-op "Save" from rewriting the config file.
|
||||||
|
function saveKey(index, flags) {
|
||||||
|
if (!flags || flags.length === 0) {
|
||||||
|
actionStatus = "No changes"
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runAction(["key", "set", String(index)].concat(flags), "Saved key " + index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes are queued, not dropped: a save clicked while another action is in
|
||||||
|
// flight must still land. Brightness entries coalesce to the newest value so
|
||||||
|
// a slider drag queues one write, not a backlog of intermediate positions.
|
||||||
|
property var actionQueue: []
|
||||||
|
|
||||||
|
function runAction(args, message) {
|
||||||
|
if (ctlResolved === "") { lastError = "streamdeck-ctl not found"; return }
|
||||||
|
if (args[0] === "brightness") {
|
||||||
|
actionQueue = actionQueue.filter(function(entry) { return entry.args[0] !== "brightness" })
|
||||||
|
}
|
||||||
|
actionQueue.push({ args: args, message: message })
|
||||||
|
pumpActions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pumpActions() {
|
||||||
|
if (actionProcess.running || actionQueue.length === 0) return
|
||||||
|
var next = actionQueue.shift()
|
||||||
|
actionProcess.pendingMessage = next.message
|
||||||
|
actionProcess.command = [ctlResolved].concat(baseArgs()).concat(next.args)
|
||||||
|
actionProcess.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfig() {
|
||||||
|
var path = status.configPath || (home + "/.config/streamdeck-go/config.yaml")
|
||||||
|
Quickshell.execDetached(["uwsm-app", "--", "xdg-open", path])
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLogs() {
|
||||||
|
Quickshell.execDetached(["uwsm-app", "--", "omarchy-launch-floating-terminal-with-presentation",
|
||||||
|
"journalctl", "--user", "-u", "streamdeck-go.service", "-f", "-n", "200"])
|
||||||
|
}
|
||||||
|
|
||||||
|
function openIcons() {
|
||||||
|
var dir = status.iconsDir || (home + "/.config/streamdeck-go/icons")
|
||||||
|
Quickshell.execDetached(["uwsm-app", "--", "xdg-open", dir])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── processes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: locateProcess
|
||||||
|
running: false
|
||||||
|
command: []
|
||||||
|
stdout: StdioCollector { id: locateStdout; waitForEnd: true }
|
||||||
|
onExited: function(exitCode) {
|
||||||
|
var found = String(locateStdout.text || "").trim().split("\n")[0].trim()
|
||||||
|
if (found !== "") {
|
||||||
|
root.ctlResolved = found
|
||||||
|
root.ctlMissing = false
|
||||||
|
root.refresh()
|
||||||
|
} else {
|
||||||
|
root.ctlMissing = true
|
||||||
|
root.loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: statusProcess
|
||||||
|
running: false
|
||||||
|
command: []
|
||||||
|
stdout: StdioCollector { id: statusStdout; waitForEnd: true }
|
||||||
|
stderr: StdioCollector { id: statusStderr; waitForEnd: true }
|
||||||
|
onExited: function(exitCode) {
|
||||||
|
root.refreshing = false
|
||||||
|
root.loaded = true
|
||||||
|
// A failure with empty stdout is spawn-shaped (the CLI always emits JSON,
|
||||||
|
// even on error) — the binary may have been moved or removed. Drop the
|
||||||
|
// cached location so the next tick re-probes.
|
||||||
|
if (exitCode !== 0 && String(statusStdout.text || "").trim() === "") {
|
||||||
|
root.ctlResolved = ""
|
||||||
|
root.lastError = "streamdeck-ctl failed to run"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var parsed = Model.parseStatus(statusStdout.text)
|
||||||
|
if (parsed.ok === false && parsed.error) {
|
||||||
|
root.lastError = parsed.error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.status = parsed
|
||||||
|
root.lastError = parsed.configError ? parsed.configError
|
||||||
|
: (exitCode !== 0 ? String(statusStderr.text || "").trim() : "")
|
||||||
|
// The poll caught up with the optimistic value — stop overriding it.
|
||||||
|
if (root.pendingBrightness >= 0 && Number(parsed.brightness) === root.pendingBrightness) {
|
||||||
|
root.pendingBrightness = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: actionProcess
|
||||||
|
property string pendingMessage: ""
|
||||||
|
running: false
|
||||||
|
command: []
|
||||||
|
stdout: StdioCollector { id: actionStdout; waitForEnd: true }
|
||||||
|
stderr: StdioCollector { id: actionStderr; waitForEnd: true }
|
||||||
|
onExited: function(exitCode) {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
root.lastError = ""
|
||||||
|
if (actionProcess.pendingMessage !== "") {
|
||||||
|
root.actionStatus = actionProcess.pendingMessage
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var message = String(actionStderr.text || actionStdout.text || "").trim()
|
||||||
|
root.lastError = Model.truncate(message || "streamdeck-ctl failed", 160)
|
||||||
|
root.actionStatus = ""
|
||||||
|
// A failed brightness write must release the optimistic value, or the
|
||||||
|
// slider shows a level the deck never reached — forever, since no poll
|
||||||
|
// will ever confirm it.
|
||||||
|
root.pendingBrightness = -1
|
||||||
|
}
|
||||||
|
root.pumpActions()
|
||||||
|
// Daemon transitions take a moment to settle; re-poll shortly after rather
|
||||||
|
// than waiting for the next scheduled refresh.
|
||||||
|
settleTimer.restart()
|
||||||
|
root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── timers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: refreshTimer
|
||||||
|
// The bar icon doesn't need panel-grade freshness; the panel refreshes
|
||||||
|
// explicitly on open, so a slow background cadence costs nothing visible.
|
||||||
|
interval: (root.panelOpen ? root.refreshIntervalSec
|
||||||
|
: Math.max(root.refreshIntervalSec, 60)) * 1000
|
||||||
|
repeat: true
|
||||||
|
running: true
|
||||||
|
triggeredOnStart: true
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
// Dragging the slider would otherwise rewrite config.yaml on every pixel,
|
||||||
|
// and each write triggers a daemon reload. Coalesce to the last value.
|
||||||
|
id: brightnessDebounce
|
||||||
|
interval: 180
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
if (root.pendingBrightness < 0) return
|
||||||
|
root.runAction(["brightness", String(root.pendingBrightness)], "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: settleTimer
|
||||||
|
interval: 1200
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: actionStatusTimer
|
||||||
|
interval: 2400
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.actionStatus = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: resolveCtl()
|
||||||
|
}
|
||||||
52
omarchy-plugin/StreamDeckIcon.qml
Normal file
52
omarchy-plugin/StreamDeckIcon.qml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Commons
|
||||||
|
|
||||||
|
// A 3x2 grid of keys, drawn rather than glyphed so it scales cleanly and takes
|
||||||
|
// its colour straight from the caller (and therefore from the active theme).
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property real iconSize: Style.space(14)
|
||||||
|
property color color: Color.foreground
|
||||||
|
// Lit keys are drawn filled; the rest are outlined. The panel uses this to
|
||||||
|
// show the deck's state at a glance without a second icon.
|
||||||
|
property int litKeys: 6
|
||||||
|
|
||||||
|
implicitWidth: iconSize
|
||||||
|
implicitHeight: iconSize
|
||||||
|
|
||||||
|
readonly property int cols: 3
|
||||||
|
readonly property int rows: 2
|
||||||
|
readonly property real gap: Math.max(1, iconSize * 0.1)
|
||||||
|
readonly property real cell: (iconSize - gap * (cols - 1)) / cols
|
||||||
|
readonly property real cellHeight: (iconSize * 0.72 - gap * (rows - 1)) / rows
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: root.iconSize
|
||||||
|
height: root.cellHeight * root.rows + root.gap * (root.rows - 1)
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.cols * root.rows
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property int column: index % root.cols
|
||||||
|
readonly property int row: Math.floor(index / root.cols)
|
||||||
|
readonly property bool lit: index < root.litKeys
|
||||||
|
|
||||||
|
x: column * (root.cell + root.gap)
|
||||||
|
y: row * (root.cellHeight + root.gap)
|
||||||
|
width: root.cell
|
||||||
|
height: root.cellHeight
|
||||||
|
radius: Style.cornerRadius > 0 ? Math.max(1, root.cell * 0.22) : 0
|
||||||
|
|
||||||
|
color: lit ? root.color : "transparent"
|
||||||
|
border.width: lit ? 0 : Math.max(1, Math.round(root.iconSize * 0.07))
|
||||||
|
border.color: root.color
|
||||||
|
opacity: lit ? 1.0 : 0.45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
omarchy-plugin/manifest.json
Normal file
59
omarchy-plugin/manifest.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "dev.woodard.streamdeck",
|
||||||
|
"name": "Stream Deck",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Levi Woodard",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Control the streamdeck-go daemon from the Omarchy bar: device status, brightness, and a live key grid you can edit in place.",
|
||||||
|
"kinds": [
|
||||||
|
"bar-widget"
|
||||||
|
],
|
||||||
|
"entryPoints": {
|
||||||
|
"barWidget": "Panel.qml"
|
||||||
|
},
|
||||||
|
"barWidget": {
|
||||||
|
"displayName": "Stream Deck",
|
||||||
|
"description": "Deck and daemon status, brightness, and inline key editing for streamdeck-go.",
|
||||||
|
"category": "Hardware",
|
||||||
|
"allowMultiple": false,
|
||||||
|
"defaultSection": "right",
|
||||||
|
"defaults": {
|
||||||
|
"refreshIntervalSec": 10,
|
||||||
|
"ctlPath": "",
|
||||||
|
"configPath": "",
|
||||||
|
"hideWhenDisconnected": false
|
||||||
|
},
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"key": "refreshIntervalSec",
|
||||||
|
"type": "integer",
|
||||||
|
"label": "Refresh interval (seconds)",
|
||||||
|
"min": 2,
|
||||||
|
"max": 600,
|
||||||
|
"step": 1,
|
||||||
|
"defaultValue": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ctlPath",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Path to streamdeck-ctl",
|
||||||
|
"description": "Leave empty to search ~/.local/bin, ~/go/bin, then $PATH.",
|
||||||
|
"defaultValue": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "configPath",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Path to config.yaml",
|
||||||
|
"description": "Leave empty to use ~/.config/streamdeck-go/config.yaml.",
|
||||||
|
"defaultValue": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hideWhenDisconnected",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Hide the bar icon when no deck is connected",
|
||||||
|
"defaultValue": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,20 +51,23 @@ current_addr() {
|
|||||||
'
|
'
|
||||||
;;
|
;;
|
||||||
Darwin)
|
Darwin)
|
||||||
# system_profiler entry per device:
|
# ioreg is the reliable source on macOS — system_profiler SPUSBDataType
|
||||||
# Stream Deck XL:
|
# silently omits the deck on some machines, and its "Location ID: X / N"
|
||||||
# Product ID: 0x00ba
|
# trailing bus number flaps spuriously without an actual replug.
|
||||||
# Vendor ID: 0x0fd9 (Elgato ...)
|
#
|
||||||
# ...
|
# sessionID is unique per USB enumeration session: stable while the
|
||||||
# Location ID: 0x14140000 / 5
|
# device stays plugged in, changes on every replug. Exactly what we want.
|
||||||
# The trailing "/ N" is the bus address — it changes on replug.
|
#
|
||||||
system_profiler SPUSBDataType 2>/dev/null | awk -v pids="$PIDS_RE" '
|
# Elgato vendor in decimal: 4057 (0x0fd9).
|
||||||
/^[[:space:]]*Product ID:/ { pid = $3 }
|
# Stream Deck product IDs in decimal: 186 (0x00ba), 108 (0x006c), 109 (0x006d).
|
||||||
/^[[:space:]]*Vendor ID:/ { vid = $3 }
|
ioreg -p IOUSB -l -w 0 2>/dev/null | awk '
|
||||||
/^[[:space:]]*Location ID:/ {
|
/<class IOUSBHostDevice/ { vid=""; pid=""; sid="" }
|
||||||
sub(/^[[:space:]]*Location ID:[[:space:]]*/, "")
|
/"idVendor"/ { vid=$NF }
|
||||||
if (vid == "0x0fd9" && pid ~ ("^0x(" pids ")$")) {
|
/"idProduct"/ { pid=$NF }
|
||||||
print
|
/"sessionID"/ { sid=$NF }
|
||||||
|
/}/ {
|
||||||
|
if (vid == "4057" && (pid == "186" || pid == "108" || pid == "109") && sid != "") {
|
||||||
|
print sid
|
||||||
exit
|
exit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,14 +109,52 @@ prev=""
|
|||||||
|
|
||||||
curr="$(current_addr)"
|
curr="$(current_addr)"
|
||||||
|
|
||||||
# Always update the state file so the next run sees a fresh baseline.
|
# Only update the state file when the device is present. If we overwrote with
|
||||||
printf '%s' "$curr" > "$STATE_FILE"
|
# an empty string while the device was absent (e.g. mid-KVM-swap), the very
|
||||||
|
# next run would see prev="" and miss the address change on return.
|
||||||
|
if [[ -n "$curr" ]]; then
|
||||||
|
printf '%s' "$curr" > "$STATE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
# No device present — nothing to do. Don't touch the service.
|
# No device present — nothing to do. Don't touch the service.
|
||||||
if [[ -z "$curr" ]]; then
|
if [[ -z "$curr" ]]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Linux-only: detect a stale hidraw fd held by the daemon. When the device
|
||||||
|
# unplugs, hidraw's open fd survives but its /dev node is removed; procfs
|
||||||
|
# marks the symlink "(deleted)". hid_read_timeout on this fd silently returns
|
||||||
|
# zero bytes, so the daemon's 3-error reconnect path never trips.
|
||||||
|
stale_fd_detected() {
|
||||||
|
[[ "$OS" != "Linux" ]] && return 1
|
||||||
|
local pid
|
||||||
|
pid="$(systemctl --user show -p MainPID --value streamdeck-go.service 2>/dev/null || true)"
|
||||||
|
[[ -z "$pid" || "$pid" == "0" ]] && return 1
|
||||||
|
[[ ! -d "/proc/$pid/fd" ]] && return 1
|
||||||
|
ls -la "/proc/$pid/fd/" 2>/dev/null | grep -qE 'hidraw[0-9]+ \(deleted\)'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Linux-only: detect that the system resumed from suspend after the daemon
|
||||||
|
# started. On resume, the xhci controller may reset the deck's USB device
|
||||||
|
# in place (same bus address, same hidraw node, fd not deleted). The kernel
|
||||||
|
# reset leaves the existing fd's input queue dead — buttons no longer reach
|
||||||
|
# userspace — but no externally visible signal flags the failure. Restarting
|
||||||
|
# the daemon is cheap and reliably fixes it.
|
||||||
|
#
|
||||||
|
# Idempotent by construction: once we restart, the daemon's ActiveEnterTimestamp
|
||||||
|
# moves past the resume event, so this check stops firing until the next sleep.
|
||||||
|
resumed_since_start() {
|
||||||
|
[[ "$OS" != "Linux" ]] && return 1
|
||||||
|
local started
|
||||||
|
started="$(systemctl --user show -p ActiveEnterTimestamp --value streamdeck-go.service 2>/dev/null || true)"
|
||||||
|
[[ -z "$started" || "$started" == "n/a" ]] && return 1
|
||||||
|
local started_epoch
|
||||||
|
started_epoch="$(date -d "$started" +%s 2>/dev/null || true)"
|
||||||
|
[[ -z "$started_epoch" ]] && return 1
|
||||||
|
journalctl -k --since "@$started_epoch" --no-pager 2>/dev/null \
|
||||||
|
| grep -qE 'PM: suspend exit|PM: Finishing wakeup'
|
||||||
|
}
|
||||||
|
|
||||||
reason=""
|
reason=""
|
||||||
|
|
||||||
if [[ -z "$prev" ]]; then
|
if [[ -z "$prev" ]]; then
|
||||||
@@ -127,6 +168,10 @@ elif [[ "$curr" != "$prev" ]]; then
|
|||||||
reason="device address changed: $prev → $curr (likely unplug/replug)"
|
reason="device address changed: $prev → $curr (likely unplug/replug)"
|
||||||
elif ! service_active; then
|
elif ! service_active; then
|
||||||
reason="device present at $curr but service is not active"
|
reason="device present at $curr but service is not active"
|
||||||
|
elif stale_fd_detected; then
|
||||||
|
reason="daemon holds a deleted hidraw fd (post-unplug stale handle)"
|
||||||
|
elif resumed_since_start; then
|
||||||
|
reason="system resumed from suspend since daemon started (USB reset may have invalidated input queue)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$reason" ]]; then
|
if [[ -n "$reason" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user