From 663d452ca320afddfcd32bd1326051d675c01fe6 Mon Sep 17 00:00:00 2001 From: Levi Woodard Date: Sun, 15 Mar 2026 10:10:13 -0600 Subject: [PATCH] Initial Push --- .gitignore | 31 ++ Makefile | 99 +++++++ OMARCHY.md | 320 +++++++++++++++++++++ README.md | 405 +++++++++++++++++++++++++++ cmd/streamdeck-helper/main.go | 161 +++++++++++ config.example.yaml | 30 ++ config/privileged.example.yaml | 24 ++ go.mod | 12 + go.sum | 12 + icons/.gitkeep | 0 internal/config/config.go | 53 ++++ internal/device/streamdeck.go | 230 +++++++++++++++ systemd/streamdeck-go-helper.service | 20 ++ systemd/streamdeck-go.service | 16 ++ 14 files changed, 1413 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 OMARCHY.md create mode 100644 README.md create mode 100644 cmd/streamdeck-helper/main.go create mode 100644 config.example.yaml create mode 100644 config/privileged.example.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 icons/.gitkeep create mode 100644 internal/config/config.go create mode 100644 internal/device/streamdeck.go create mode 100644 systemd/streamdeck-go-helper.service create mode 100644 systemd/streamdeck-go.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2460579 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Compiled binaries +streamdeck +/bin/ +*.exe + +# Build output +dist/ + +# Go tool cache +vendor/ + +# Test artifacts +*.test +*.out +coverage.html + +# Icons — keep the folder but not personal icon files +icons/* +!icons/.gitkeep + +# Personal config (dotfile-style — user maintains their own) +config.yaml + +# OS / editor noise +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*~ +.claude diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b22a77a --- /dev/null +++ b/Makefile @@ -0,0 +1,99 @@ +BINARY := streamdeck-go +HELPER := streamdeck-helper +PREFIX ?= $(HOME)/.local +BIN_DIR := $(PREFIX)/bin +SYS_BIN := /usr/local/bin +CONFIG_DIR := $(HOME)/.config/streamdeck-go +SYSTEMD_USER := $(HOME)/.config/systemd/user +SYSTEMD_SYS := /etc/systemd/system +ETC_DIR := /etc/streamdeck-go +UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules +GROUP := streamdeck + +.PHONY: build build-helper install install-helper uninstall udev + +# ── Build ───────────────────────────────────────────────────────────────────── + +build: + go build -o $(BINARY) ./cmd/streamdeck/ + +build-helper: + go build -o $(HELPER) ./cmd/streamdeck-helper/ + +# ── Install ─────────────────────────────────────────────────────────────────── + +install: build udev _install-user + @echo "" + @echo "Done. Edit $(CONFIG_DIR)/config.yaml to configure your keys." + @echo "To enable privileged commands (suspend, reboot, etc): make install-helper" + +_install-user: + install -Dm755 $(BINARY) $(BIN_DIR)/$(BINARY) + mkdir -p $(CONFIG_DIR)/icons + @if [ ! -f $(CONFIG_DIR)/config.yaml ]; then \ + install -Dm644 config.example.yaml $(CONFIG_DIR)/config.yaml; \ + echo "Default config written to $(CONFIG_DIR)/config.yaml"; \ + else \ + echo "Config already exists — not overwriting"; \ + fi + install -Dm644 systemd/streamdeck-go.service $(SYSTEMD_USER)/streamdeck-go.service + systemctl --user daemon-reload + systemctl --user enable --now streamdeck-go.service + +# Install the privileged helper (requires sudo). +# Creates a 'streamdeck' group, adds the current user to it, installs the +# helper binary as root, and enables it as a system service. +install-helper: build-helper + # Create group and add current user. + @if ! getent group $(GROUP) > /dev/null; then \ + sudo groupadd $(GROUP); \ + echo "Created group '$(GROUP)'"; \ + fi + sudo usermod -aG $(GROUP) $(USER) + # Install helper binary (owned root, not world-executable by others). + sudo install -Dm750 $(HELPER) $(SYS_BIN)/$(HELPER) + sudo chown root:$(GROUP) $(SYS_BIN)/$(HELPER) + # Install whitelist config (root-owned, not world-writable). + sudo mkdir -p $(ETC_DIR) + @if [ ! -f $(ETC_DIR)/privileged.yaml ]; then \ + sudo install -Dm640 config/privileged.example.yaml $(ETC_DIR)/privileged.yaml; \ + sudo chown root:$(GROUP) $(ETC_DIR)/privileged.yaml; \ + echo "Whitelist written to $(ETC_DIR)/privileged.yaml"; \ + else \ + echo "Whitelist already exists — not overwriting"; \ + fi + # Install and enable system service. + sudo install -Dm644 systemd/streamdeck-go-helper.service $(SYSTEMD_SYS)/streamdeck-go-helper.service + sudo systemctl daemon-reload + sudo systemctl enable --now streamdeck-go-helper.service + @echo "" + @echo "Helper installed. Edit $(ETC_DIR)/privileged.yaml (as root) to add commands." + @echo "NOTE: log out and back in for group membership to take effect," + @echo " or run: newgrp $(GROUP)" + +udev: + @if [ ! -f $(UDEV_RULE) ]; then \ + echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \ + | sudo tee $(UDEV_RULE); \ + sudo udevadm control --reload; \ + sudo udevadm trigger; \ + echo "udev rule installed."; \ + else \ + echo "udev rule already exists."; \ + fi + +# ── Uninstall ───────────────────────────────────────────────────────────────── + +uninstall: + systemctl --user disable --now streamdeck-go.service || true + rm -f $(BIN_DIR)/$(BINARY) + rm -f $(SYSTEMD_USER)/streamdeck-go.service + systemctl --user daemon-reload + @echo "Uninstalled. Config at $(CONFIG_DIR) preserved." + +uninstall-helper: + sudo systemctl disable --now streamdeck-go-helper.service || true + sudo rm -f $(SYS_BIN)/$(HELPER) + sudo rm -f $(SYSTEMD_SYS)/streamdeck-go-helper.service + sudo systemctl daemon-reload + @echo "Helper uninstalled. Whitelist at $(ETC_DIR)/privileged.yaml preserved." diff --git a/OMARCHY.md b/OMARCHY.md new file mode 100644 index 0000000..838263c --- /dev/null +++ b/OMARCHY.md @@ -0,0 +1,320 @@ +# streamdeck-go — Omarchy Integration + +Omarchy-specific commands and config examples for [streamdeck-go](README.md). +All commands run via `sh -c` on key press unless marked `priv:` (requires the +privileged helper — see [README § Privileged commands](README.md)). + +--- + +## Terminal (Ghostty) + +```yaml +keys: + 0: + icon: ghostty.png + command: ghostty + + # Open a new window with a specific command + 1: + icon: ssh.png + command: "ghostty -e ssh user@homeserver" + + # Open in a specific directory + 2: + icon: projects.png + command: "ghostty --working-directory=~/projects" +``` + +--- + +## Walker (app launcher) + +```yaml +keys: + 3: + icon: walker.png + command: walker + + # Open walker directly in a specific mode + 4: + icon: calc.png + command: "walker --modules calc" +``` + +--- + +## Hyprland + +Hyprland exposes everything via `hyprctl dispatch`. No privileges needed. + +### Workspaces + +```yaml +keys: + 0: + icon: ws1.png + command: "hyprctl dispatch workspace 1" + 1: + icon: ws2.png + command: "hyprctl dispatch workspace 2" + 2: + icon: ws3.png + command: "hyprctl dispatch workspace 3" + + # Move active window to a workspace + 8: + icon: move.png + command: "hyprctl dispatch movetoworkspace 2" + + # Toggle floating on the active window + 9: + icon: float.png + command: "hyprctl dispatch togglefloating" + + # Fullscreen + 10: + icon: fullscreen.png + command: "hyprctl dispatch fullscreen 0" + + # Kill active window + 11: + icon: close.png + command: "hyprctl dispatch killactive" +``` + +### Multi-monitor + +```yaml +keys: + 16: + icon: monitor-left.png + command: "hyprctl dispatch focusmonitor l" + 17: + icon: monitor-right.png + command: "hyprctl dispatch focusmonitor r" + + # Move window to other monitor + 18: + icon: move-monitor.png + command: "hyprctl dispatch movewindow mon:next" +``` + +### Hyprlock (screen lock) + +```yaml +keys: + 31: + icon: lock.png + command: hyprlock +``` + +### Hypridle (idle inhibitor) + +```yaml +keys: + 30: + icon: idle-off.png + # Toggle idle inhibitor — stops screen from locking during presentations + command: "pkill hypridle || hypridle &" +``` + +### Hyprpaper (wallpaper) + +```yaml +keys: + 24: + icon: wallpaper.png + # Cycle to a specific wallpaper on the active monitor + command: "hyprctl hyprpaper wallpaper 'eDP-1,~/wallpapers/mountain.jpg'" +``` + +--- + +## Waybar + +```yaml +keys: + 7: + icon: waybar-reload.png + # Reload Waybar config and style without restarting + command: "pkill -SIGUSR2 waybar" + + 15: + icon: waybar-toggle.png + # Show/hide the bar + command: "pkill -SIGUSR1 waybar" + + # Full restart (if SIGUSR2 isn't enough after big config changes) + 23: + icon: waybar-restart.png + command: "pkill waybar; waybar &" +``` + +--- + +## Mako (notifications) + +```yaml +keys: + 5: + icon: notif-dismiss.png + # Dismiss all notifications + command: "makoctl dismiss --all" + + 6: + icon: notif-invoke.png + # Invoke the default action on the last notification + command: "makoctl invoke" + + # Toggle do-not-disturb + 13: + icon: dnd.png + command: "makoctl set-mode $([ $(makoctl get-mode) = 'do-not-disturb' ] && echo default || echo do-not-disturb)" +``` + +--- + +## Audio (PipeWire / wpctl) + +```yaml +keys: + 16: + icon: vol-up.png + command: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+" + 17: + icon: vol-down.png + command: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-" + 18: + icon: mute.png + command: "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle" + + # Mic mute (useful for calls) + 19: + icon: mic-mute.png + command: "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle" +``` + +--- + +## Brightness (brightnessctl) + +```yaml +keys: + 20: + icon: bright-up.png + command: "brightnessctl set +10%" + 21: + icon: bright-down.png + command: "brightnessctl set 10%-" + 22: + icon: bright-max.png + command: "brightnessctl set 100%" +``` + +--- + +## Screenshots (grimblast / hyprshot) + +Omarchy ships with `grimblast`. Output goes to `~/Pictures/Screenshots/` by default. + +```yaml +keys: + 8: + icon: screenshot.png + # Fullscreen screenshot + command: "grimblast save screen" + + 9: + icon: screenshot-area.png + # Select area + command: "grimblast save area" + + 10: + icon: screenshot-window.png + # Active window + command: "grimblast save active" + + # Copy to clipboard instead of saving + 11: + icon: screenshot-clip.png + command: "grimblast copy area" +``` + +--- + +## Night light (hyprsunset / wlsunset) + +```yaml +keys: + 25: + icon: nightlight.png + command: "pkill hyprsunset; hyprsunset -t 3500 &" + 26: + icon: nightlight-off.png + command: "pkill hyprsunset" +``` + +--- + +## Power (privileged — requires helper) + +These use the `priv:` prefix and must be added to `/etc/streamdeck-go/privileged.yaml`. + +```yaml +# /etc/streamdeck-go/privileged.yaml +commands: + suspend: "systemctl suspend" + hibernate: "systemctl hibernate" + reboot: "systemctl reboot" + poweroff: "systemctl poweroff" +``` + +```yaml +# ~/.config/streamdeck-go/config.yaml +keys: + 28: + icon: suspend.png + command: "priv:suspend" + 29: + icon: reboot.png + command: "priv:reboot" + 30: + icon: poweroff.png + command: "priv:poweroff" +``` + +--- + +## Media (playerctl) + +Works with Spotify, Firefox, mpv, and anything that exposes MPRIS. + +```yaml +keys: + 20: + icon: prev.png + command: "playerctl previous" + 21: + icon: playpause.png + command: "playerctl play-pause" + 22: + icon: next.png + command: "playerctl next" + 23: + icon: stop.png + command: "playerctl stop" +``` + +--- + +## Useful hyprctl one-liners + +| What | Command | +|---|---| +| List all windows | `hyprctl clients` | +| Active window info | `hyprctl activewindow` | +| List monitors | `hyprctl monitors` | +| Reload Hyprland config | `hyprctl reload` | +| Toggle special workspace | `hyprctl dispatch togglespecialworkspace` | +| Focus next window | `hyprctl dispatch cyclenext` | +| Rotate layout | `hyprctl dispatch layoutmsg orientationcycle` | diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb8efa7 --- /dev/null +++ b/README.md @@ -0,0 +1,405 @@ +# streamdeck-go + +> [!IMPORTANT] +> This is a sloppy utility that's designed to just work. [406.fail](https://406.fail) + + +A lightweight, dotfile-style controller for the **Elgato Stream Deck XL** on Linux. +No Elgato software required — communicates directly with the device over USB HID. + +--- + +## Features + +- Configure keys with a single YAML file (Designed for dotfiles compatability) +- PNG and JPEG icons, automatically scaled to key size +- Animated GIF support — frames pre-encoded at startup, cycled at the GIF's native rate +- Runs any shell command on key press +- **Live config reload** — save your config and the deck updates instantly, no restart needed +- Privileged command helper — run whitelisted root commands via a Unix socket; supports polkit auth dialogs +- Automatic reconnect — survives USB unplug, KVM switches, and suspend/resume +- Runs as a systemd user service, starts automatically with your desktop session +- No Stream Deck app, no Node.js, no Electron + +**Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap) + +--- + +## Architecture + +``` +streamdeck-go/ +├── cmd/ +│ └── streamdeck/ +│ └── main.go # Entry point, config watcher, event loop +├── internal/ +│ ├── config/ +│ │ └── config.go # YAML parsing with XDG-aware defaults +│ └── device/ +│ └── streamdeck.go # USB HID communication, image encoding, button reads +├── systemd/ +│ └── streamdeck-go.service # Systemd user service unit +├── config.example.yaml # Starter config (copied to ~/.config on install) +├── Makefile # build / install / uninstall +├── go.mod +└── go.sum +``` + +### How it works + +``` +~/.config/streamdeck-go/config.yaml + │ + ├── fsnotify watcher ──── file saved? ──▶ cancel ctx → reload config → restart run() + │ + └──▶ run(ctx) + │ + ├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID) + │ + ├── animated GIF ──▶ device.EncodeFrame() (pre-encode all frames once) + │ └──▶ goroutine/key: loop frames, sleep per-frame delay + │ (cancelled via ctx on reload) + │ + └── event loop ──▶ device.ReadButtons() (250 ms timeout, checks ctx) + └──▶ key-down: exec.Command("sh", "-c", command) +``` + +HID output reports are mutex-guarded so concurrent animation goroutines never +interleave partial image data across keys. + +--- + +## Dependencies + +### Go packages + +| Package | Purpose | +|---|---| +| [`github.com/sstallion/go-hid`](https://github.com/sstallion/go-hid) | Bindings for `libhidapi` — USB HID read/write | +| [`github.com/fsnotify/fsnotify`](https://github.com/fsnotify/fsnotify) | Config file watching for live reload | +| [`golang.org/x/image`](https://pkg.go.dev/golang.org/x/image) | Bi-linear image scaling | +| [`gopkg.in/yaml.v3`](https://pkg.go.dev/gopkg.in/yaml.v3) | YAML config parsing | +| Go stdlib `image/gif`, `image/jpeg`, `image/png` | Image decoding and JPEG encoding | + +### System library + +`libhidapi` must be present at runtime: + +| Distro | Command | +|---|---| +| Arch / Manjaro | `sudo pacman -S hidapi` | +| Debian / Ubuntu | `sudo apt install libhidapi-hidraw0` | +| Fedora / RHEL | `sudo dnf install hidapi` | +| openSUSE | `sudo zypper install libhidapi-hidraw0` | + +--- + +## Installation + +### Option A — `make install` (recommended) + +Builds the binary, installs the systemd user service, and writes a default config +if one doesn't already exist. + +```bash +# Prerequisites +sudo pacman -S go hidapi # Arch; adjust for your distro (see table above) + +git clone https://github.com/WoodardDigital/streamdeck-go +cd streamdeck-go +make install +``` + +`make install` does the following automatically: + +1. Installs the udev rule (`/etc/udev/rules.d/99-streamdeck.rules`) so the device is accessible without root +2. Builds the binary and copies it to `~/.local/bin/streamdeck-go` +3. Creates `~/.config/streamdeck-go/` and `~/.config/streamdeck-go/icons/` +4. Writes a starter `~/.config/streamdeck-go/config.yaml` (only if one doesn't exist) +5. Installs and enables the systemd user service — starts now and on every login + +After install, edit your config: + +```bash +$EDITOR ~/.config/streamdeck-go/config.yaml +``` + +Changes are picked up automatically — no need to restart anything. + +To remove: + +```bash +make uninstall # stops the service and removes the binary; config is preserved +``` + +--- + +### Option B — manual / dev setup + +Use this if you want to run directly from the repo (e.g. while developing). + +**1. udev rule** (one-time, needed once regardless of install method): + +```bash +echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \ + | sudo tee /etc/udev/rules.d/99-streamdeck.rules +sudo udevadm control --reload +sudo udevadm trigger +``` + +**2. Build and run:** + +```bash +git clone https://github.com/WoodardDigital/streamdeck-go +cd streamdeck-go + +# Run with the repo's config.yaml (created from the example if absent): +cp config.example.yaml config.yaml +go run ./cmd/streamdeck/ + +# Or point at any config file: +go run ./cmd/streamdeck/ -config ~/.config/streamdeck-go/config.yaml +``` + +When no `-config` flag is given, the binary always checks +`~/.config/streamdeck-go/config.yaml` first (respecting `$XDG_CONFIG_HOME`). +The repo's `config.yaml` is gitignored — it's your local scratchpad. + +--- + +### Option C — AUR (Arch Linux) + +> AUR package coming soon. Until then, use `make install` above. + +--- + +## Systemd service + +The service file lives at `systemd/streamdeck-go.service` in the repo and is +installed to `~/.config/systemd/user/streamdeck-go.service` by `make install`. + +Useful commands: + +```bash +systemctl --user status streamdeck-go # check if running +systemctl --user restart streamdeck-go # restart manually +systemctl --user stop streamdeck-go # stop +journalctl --user -u streamdeck-go -f # follow logs +``` + +To use a custom config path with the service, edit the unit after install: + +```bash +systemctl --user edit streamdeck-go +``` + +Add: + +```ini +[Service] +ExecStart= +ExecStart=%h/.local/bin/streamdeck-go -config %h/.config/streamdeck-go/config.yaml +``` + +--- + +## Configuration + +Config lives at `~/.config/streamdeck-go/config.yaml` (or wherever `-config` points). +**Editing and saving the file reloads the deck live** — icons update, animations restart, +no service restart required. + +```yaml +icons_dir: ~/.config/streamdeck-go/icons # default; can be any path +brightness: 70 # 0–100 + +# USB IDs — defaults match Stream Deck XL v2 +# Run: lsusb | grep Elgato +device: + vendor_id: 0x0fd9 + product_id: 0x00ba + +# Keys are 0-indexed, left-to-right, top-to-bottom. +# Stream Deck XL layout (8 columns × 4 rows): +# +# 0 1 2 3 4 5 6 7 +# 8 9 10 11 12 13 14 15 +# 16 17 18 19 20 21 22 23 +# 24 25 26 27 28 29 30 31 + +keys: + 0: + icon: ghostty.png # PNG, JPEG, or GIF — relative to icons_dir + command: ghostty + 1: + icon: firefox.png + command: firefox + 8: + icon: loading.gif # animated — cycles at the GIF's native frame rate + command: "" +``` + +### Terminal & SSH commands + +Any shell command works — including launching terminals and SSH sessions. + +**Open a terminal on key press:** + +```yaml +keys: + 0: + icon: ghostty.png + command: ghostty + 1: + icon: terminal.png + command: alacritty # or kitty, wezterm, foot, etc. +``` + +**SSH — open an interactive session in a terminal:** + +```yaml +keys: + 5: + icon: homeserver.png + command: "ghostty -e ssh user@homeserver" + 6: + icon: pi.png + command: "alacritty -e ssh pi@raspberrypi.local" +``` + +This is the recommended pattern for SSH — the terminal handles the TTY, +resize events, and any passphrase prompt. + +**SSH — run a remote command silently (no terminal):** + +```yaml +keys: + 7: + icon: deploy.png + command: "ssh user@host 'cd /app && git pull && systemctl restart app'" +``` + +Works as long as SSH key auth is set up and the key has no passphrase (or the agent is available — see note below). + +> **SSH agent & the systemd service** +> +> The service starts before your desktop session fully initialises, so +> `SSH_AUTH_SOCK` (used for passphrase-protected keys) may not be in its +> environment. Fix by importing it from your session startup: +> +> ```bash +> # Add to ~/.config/fish/config.fish, ~/.bashrc, or session init: +> systemctl --user import-environment SSH_AUTH_SOCK +> ``` +> +> Or hardcode the socket path in the service: +> +> ```bash +> systemctl --user edit streamdeck-go +> ``` +> +> ```ini +> [Service] +> Environment=SSH_AUTH_SOCK=%t/keyring/ssh +> ``` +> +> Run `echo $SSH_AUTH_SOCK` in a terminal to find the correct path for your +> desktop environment. + +**Common terminal flags:** + +| Terminal | Flag to run a command | +|---|---| +| ghostty | `ghostty -e ` | +| alacritty | `alacritty -e ` | +| kitty | `kitty ` | +| foot | `foot ` | +| wezterm | `wezterm start -- ` | + +--- + +### Supported icon formats + +| Format | Notes | +|---|---| +| PNG | Recommended for static icons | +| JPEG | Good for photos / complex images | +| GIF | Animated — all frames pre-encoded at startup, cycled in a background goroutine | + +Icons are scaled to 96×96 px using bi-linear filtering. The XL renders images +mirrored, so they are pre-flipped before sending — your icons will appear the right +way round. + +--- + +## Supported Devices + +| Model | Product ID | Keys | Status | +|---|---|---|---| +| Stream Deck XL v2 | `0x00ba` | 32 | tested | +| Stream Deck XL v1 | `0x006c` | 32 | untested | +| Stream Deck MK.2 | `0x006d` | 15 | untested | + +Run `lsusb | grep Elgato` to find your device's product ID. +To add a model, edit the `models` map in [internal/device/streamdeck.go](internal/device/streamdeck.go). + +--- + +## Roadmap + +### Text / label overlay on icons + +Render dynamic text directly onto a key image at runtime — useful for showing +live state like volume level, a clock, a counter, or the current git branch. +Icons would be composited with a text layer before being sent to the device, so +no pre-made image is needed for every possible value. + +Example config (proposed): + +```yaml +keys: + 4: + icon: volume.png + label: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')" + label_position: bottom # top | center | bottom + refresh: 5s # re-evaluate and redraw every 5 seconds +``` + +### Multi-page layouts + +Support more than 32 actions by organising keys into named pages. A designated +key (or key combination) switches between pages. The deck reloads instantly with +the new page's icons when switching. + +Example config (proposed): + +```yaml +pages: + default: + 0: + icon: apps.png + command: "page:apps" # switch to the 'apps' page + 1: + icon: firefox.png + command: firefox + + apps: + 0: + icon: back.png + command: "page:default" + 1: + icon: ghostty.png + command: ghostty + 2: + icon: obsidian.png + command: obsidian +``` + +### AUR package + +A `PKGBUILD` for Arch Linux so the full install (binary, services, udev rule, +config skeleton) is handled by `yay` or `paru` like any other package. + +```bash +yay -S streamdeck-go # coming soon +``` diff --git a/cmd/streamdeck-helper/main.go b/cmd/streamdeck-helper/main.go new file mode 100644 index 0000000..1cf1516 --- /dev/null +++ b/cmd/streamdeck-helper/main.go @@ -0,0 +1,161 @@ +// streamdeck-helper is a small privileged daemon that runs as root and executes +// whitelisted commands on behalf of the unprivileged streamdeck-go process. +// +// It communicates over a Unix socket. The main process sends a JSON request +// containing only a command name; the helper validates it against a root-owned +// whitelist before running anything. Arbitrary shell commands are never accepted. +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + + "gopkg.in/yaml.v3" +) + +const ( + socketPath = "/run/streamdeck-go/helper.sock" + whitelistPath = "/etc/streamdeck-go/privileged.yaml" + socketMode = 0660 +) + +type whitelist struct { + Commands map[string]string `yaml:"commands"` +} + +type request struct { + Command string `json:"command"` +} + +type response struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +func main() { + // Must run as root. + if os.Getuid() != 0 { + log.Fatal("streamdeck-helper must run as root (install as a system service)") + } + + wl, err := loadWhitelist(whitelistPath) + if err != nil { + log.Fatalf("load whitelist %q: %v", whitelistPath, err) + } + log.Printf("loaded %d whitelisted commands from %s", len(wl.Commands), whitelistPath) + + if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil { + log.Fatalf("create socket dir: %v", err) + } + // Remove stale socket from a previous run. + _ = os.Remove(socketPath) + + ln, err := net.Listen("unix", socketPath) + if err != nil { + log.Fatalf("listen on %s: %v", socketPath, err) + } + defer ln.Close() + + if err := os.Chmod(socketPath, socketMode); err != nil { + log.Fatalf("chmod socket: %v", err) + } + // Chown the socket to root:streamdeck so group members can connect. + // Without this the socket is root:root and only root can reach the helper. + if grp, err := user.LookupGroup("streamdeck"); err != nil { + log.Fatalf("group 'streamdeck' not found — run 'make install-helper' first: %v", err) + } else if gid, err := strconv.Atoi(grp.Gid); err != nil { + log.Fatalf("invalid gid %q: %v", grp.Gid, err) + } else if err := os.Lchown(socketPath, 0, gid); err != nil { + log.Fatalf("chown socket: %v", err) + } + + log.Printf("listening on %s (group: streamdeck)", socketPath) + + for { + conn, err := ln.Accept() + if err != nil { + log.Printf("accept: %v", err) + continue + } + go handle(conn, wl) + } +} + +func handle(conn net.Conn, wl *whitelist) { + defer conn.Close() + + scanner := bufio.NewScanner(conn) + if !scanner.Scan() { + return + } + + var req request + if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { + send(conn, response{Error: "invalid request"}) + return + } + + if req.Command == "" { + send(conn, response{Error: "empty command name"}) + return + } + + shell, ok := wl.Commands[req.Command] + if !ok { + log.Printf("REJECTED unknown command %q", req.Command) + send(conn, response{Error: fmt.Sprintf("unknown command %q — add it to %s", req.Command, whitelistPath)}) + return + } + + log.Printf("running %q → %q", req.Command, shell) + out, err := exec.Command("sh", "-c", shell).CombinedOutput() + if err != nil { + msg := fmt.Sprintf("%v", err) + if len(out) > 0 { + msg = fmt.Sprintf("%v: %s", err, out) + } + log.Printf("command %q failed: %s", req.Command, msg) + send(conn, response{Error: msg}) + return + } + + send(conn, response{OK: true}) +} + +func send(conn net.Conn, resp response) { + data, _ := json.Marshal(resp) + _, _ = conn.Write(append(data, '\n')) +} + +func loadWhitelist(path string) (*whitelist, error) { + // Verify the file is owned by root and not world-writable. + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if info.Mode().Perm()&0002 != 0 { + return nil, fmt.Errorf("%s is world-writable — refusing to load (chmod o-w %s)", path, path) + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var wl whitelist + if err := yaml.Unmarshal(data, &wl); err != nil { + return nil, err + } + if wl.Commands == nil { + wl.Commands = map[string]string{} + } + return &wl, nil +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..3e24a7b --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,30 @@ +icons_dir: ./icons +brightness: 70 + +# Device USB IDs — defaults match Stream Deck XL v2. +# Run `lsusb | grep Elgato` to confirm yours. +device: + vendor_id: 0x0fd9 + product_id: 0x00ba + +# Keys are 0-indexed, left-to-right, top-to-bottom. +# Stream Deck XL layout (8 columns × 4 rows): +# +# 0 1 2 3 4 5 6 7 +# 8 9 10 11 12 13 14 15 +# 16 17 18 19 20 21 22 23 +# 24 25 26 27 28 29 30 31 +# +# icon: filename inside icons_dir (PNG or JPEG) +# command: any shell command + +keys: + 0: + icon: ghostty.png + command: ghostty + 1: + icon: terminal.png + command: alacritty + 2: + icon: files.png + command: nautilus diff --git a/config/privileged.example.yaml b/config/privileged.example.yaml new file mode 100644 index 0000000..5ffbd38 --- /dev/null +++ b/config/privileged.example.yaml @@ -0,0 +1,24 @@ +# /etc/streamdeck-go/privileged.yaml +# +# Root-owned whitelist of commands that streamdeck-go is allowed to run with +# elevated privileges via the helper daemon. +# +# IMPORTANT: +# - This file must be owned by root and not world-writable. +# - Only root can add or modify entries. +# - The helper will refuse to start if this file is world-writable. +# +# Usage in ~/.config/streamdeck-go/config.yaml: +# keys: +# 5: +# icon: suspend.png +# command: "priv:suspend" +# +# The value is a shell command run as root. Keep these minimal and specific. + +commands: + suspend: "systemctl suspend" + reboot: "systemctl reboot" + poweroff: "systemctl poweroff" + # brightness_max: "brightnessctl set 100%" + # wake_on_lan: "etherwake AA:BB:CC:DD:EE:FF" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a2887c2 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/WoodardDigital/streamdeck-go + +go 1.25.0 + +require ( + github.com/fsnotify/fsnotify v1.9.0 + github.com/sstallion/go-hid v0.15.0 + golang.org/x/image v0.37.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require golang.org/x/sys v0.13.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..13b4dac --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/sstallion/go-hid v0.15.0 h1:WERW/VW3Us6N73V2qa7HjdqWQvwHd0CoRDOP/N707/w= +github.com/sstallion/go-hid v0.15.0/go.mod h1:fPKp4rqx0xuoTV94gwKojsPG++KNKhxuU88goGuGM7I= +golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= +golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/icons/.gitkeep b/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2c30680 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,53 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// KeyConfig defines what a single Stream Deck key does. +type KeyConfig struct { + Icon string `yaml:"icon"` // filename relative to icons_dir + Command string `yaml:"command"` // shell command to run on press +} + +// Config is the top-level structure of the YAML config file. +type Config struct { + IconsDir string `yaml:"icons_dir"` + Brightness int `yaml:"brightness"` + Device DeviceConfig `yaml:"device"` + Keys map[int]KeyConfig `yaml:"keys"` +} + +// DeviceConfig allows overriding USB IDs (defaults work for Stream Deck XL v2). +type DeviceConfig struct { + VendorID uint16 `yaml:"vendor_id"` + ProductID uint16 `yaml:"product_id"` +} + +// Load reads and parses a YAML config file, applying sane defaults. +// icons_dir defaults to an "icons" folder next to the config file. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config %q: %w", path, err) + } + + cfg := &Config{ + IconsDir: filepath.Join(filepath.Dir(path), "icons"), + Brightness: 70, + Device: DeviceConfig{ + VendorID: 0x0fd9, + ProductID: 0x00ba, // Stream Deck XL v2 + }, + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + return cfg, nil +} diff --git a/internal/device/streamdeck.go b/internal/device/streamdeck.go new file mode 100644 index 0000000..658ee7d --- /dev/null +++ b/internal/device/streamdeck.go @@ -0,0 +1,230 @@ +package device + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + "image/jpeg" + _ "image/png" + "sync" + + "github.com/sstallion/go-hid" + "golang.org/x/image/draw" +) + +const VendorID = 0x0fd9 + +// ModelInfo describes hardware-specific constants for a Stream Deck model. +type ModelInfo struct { + KeyCount int + Cols int + Rows int + ImageWidth int + ImageHeight int + // FlipX/FlipY: the XL renders images mirrored; we pre-flip before sending. + FlipX bool + FlipY bool +} + +// models maps USB product IDs to their hardware specs. +var models = map[uint16]ModelInfo{ + 0x00ba: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v2 + 0x006c: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v1 + 0x006d: {KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true}, // MK.2 +} + +const ( + reportSize = 1024 + imageHeaderSize = 8 + imagePayloadSize = reportSize - imageHeaderSize + readReportSize = 512 +) + +// StreamDeck represents an open Stream Deck device. +type StreamDeck struct { + mu sync.Mutex + dev *hid.Device + model ModelInfo +} + +// Open finds and opens the first Stream Deck with the given product ID. +func Open(vendorID, productID uint16) (*StreamDeck, error) { + if err := hid.Init(); err != nil { + return nil, fmt.Errorf("hid init: %w", err) + } + + m, ok := models[productID] + if !ok { + return nil, fmt.Errorf("unsupported product ID: 0x%04x (add it to internal/device/streamdeck.go)", productID) + } + + dev, err := hid.OpenFirst(vendorID, productID) + if err != nil { + return nil, fmt.Errorf("open device 0x%04x:0x%04x: %w (try: sudo chmod a+rw /dev/hidraw*)", vendorID, productID, err) + } + + return &StreamDeck{dev: dev, model: m}, nil +} + +// Close releases the device. +func (sd *StreamDeck) Close() error { + _ = hid.Exit() + return sd.dev.Close() +} + +// KeyCount returns the number of keys on this device. +func (sd *StreamDeck) KeyCount() int { return sd.model.KeyCount } + +// Reset clears all key images and returns the device to its default state. +func (sd *StreamDeck) Reset() error { + report := make([]byte, 32) + report[0] = 0x03 + report[1] = 0x02 + _, err := sd.dev.SendFeatureReport(report) + return err +} + +// SetBrightness sets the display brightness (0–100). +func (sd *StreamDeck) SetBrightness(pct int) error { + if pct < 0 { + pct = 0 + } + if pct > 100 { + pct = 100 + } + report := make([]byte, 32) + report[0] = 0x03 + report[1] = 0x08 + report[2] = byte(pct) + _, err := sd.dev.SendFeatureReport(report) + return err +} + +// EncodeFrame scales img to key size and returns the JPEG bytes ready to send. +// Use this to pre-encode animation frames once rather than on every tick. +func (sd *StreamDeck) EncodeFrame(img image.Image) ([]byte, error) { + dst := image.NewRGBA(image.Rect(0, 0, sd.model.ImageWidth, sd.model.ImageHeight)) + draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil) + if sd.model.FlipX || sd.model.FlipY { + dst = flipImage(dst, sd.model.FlipX, sd.model.FlipY) + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 95}); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// SetKeyFrame sends pre-encoded JPEG bytes (from EncodeFrame) to a key. +func (sd *StreamDeck) SetKeyFrame(keyIndex int, jpegData []byte) error { + return sd.sendKeyImageData(keyIndex, jpegData) +} + +// SetKeyImage scales img to the key size and sends it to the given key (0-indexed). +func (sd *StreamDeck) SetKeyImage(keyIndex int, img image.Image) error { + // Scale to key pixel dimensions. + dst := image.NewRGBA(image.Rect(0, 0, sd.model.ImageWidth, sd.model.ImageHeight)) + draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil) + + // The XL renders images flipped; pre-flip so they appear correct. + if sd.model.FlipX || sd.model.FlipY { + dst = flipImage(dst, sd.model.FlipX, sd.model.FlipY) + } + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 95}); err != nil { + return fmt.Errorf("jpeg encode: %w", err) + } + + return sd.sendKeyImageData(keyIndex, buf.Bytes()) +} + +// ClearKey fills the given key with solid black. +func (sd *StreamDeck) ClearKey(keyIndex int) error { + blank := image.NewRGBA(image.Rect(0, 0, sd.model.ImageWidth, sd.model.ImageHeight)) + return sd.SetKeyImage(keyIndex, blank) +} + +// ReadButtons waits up to 250 ms for a button state report. +// Returns (nil, nil) on timeout — callers should check context and retry. +func (sd *StreamDeck) ReadButtons() ([]bool, error) { + data := make([]byte, readReportSize) + n, err := sd.dev.ReadWithTimeout(data, 250) + if err != nil { + return nil, err + } + if n == 0 { + return nil, nil // timeout, no data + } + + // Input report layout (XL v2): [report_id, 0x00, 0x00, 0x00, key0, key1, ...] + const offset = 4 + if n < offset+sd.model.KeyCount { + return nil, fmt.Errorf("short read: got %d bytes, expected at least %d", n, offset+sd.model.KeyCount) + } + + buttons := make([]bool, sd.model.KeyCount) + for i := 0; i < sd.model.KeyCount; i++ { + buttons[i] = data[offset+i] == 0x01 + } + return buttons, nil +} + +// sendKeyImageData sends raw JPEG bytes to a key, split across 1024-byte HID reports. +func (sd *StreamDeck) sendKeyImageData(keyIndex int, data []byte) error { + if keyIndex < 0 || keyIndex >= sd.model.KeyCount { + return fmt.Errorf("key index %d out of range (device has %d keys, 0–%d)", + keyIndex, sd.model.KeyCount, sd.model.KeyCount-1) + } + sd.mu.Lock() + defer sd.mu.Unlock() + pageIndex := 0 + for len(data) > 0 { + chunk := data + if len(chunk) > imagePayloadSize { + chunk = chunk[:imagePayloadSize] + } + + isLast := byte(0) + if len(data) == len(chunk) { + isLast = 1 + } + + report := make([]byte, reportSize) + report[0] = 0x02 + report[1] = 0x07 + report[2] = byte(keyIndex) + report[3] = isLast + binary.LittleEndian.PutUint16(report[4:6], uint16(len(chunk))) + binary.LittleEndian.PutUint16(report[6:8], uint16(pageIndex)) + copy(report[8:], chunk) + + if _, err := sd.dev.Write(report); err != nil { + return fmt.Errorf("write report page %d: %w", pageIndex, err) + } + + data = data[len(chunk):] + pageIndex++ + } + return nil +} + +func flipImage(src *image.RGBA, flipX, flipY bool) *image.RGBA { + b := src.Bounds() + w, h := b.Max.X, b.Max.Y + dst := image.NewRGBA(b) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + dx, dy := x, y + if flipX { + dx = w - 1 - x + } + if flipY { + dy = h - 1 - y + } + dst.SetRGBA(dx, dy, src.RGBAAt(x, y)) + } + } + return dst +} diff --git a/systemd/streamdeck-go-helper.service b/systemd/streamdeck-go-helper.service new file mode 100644 index 0000000..507574c --- /dev/null +++ b/systemd/streamdeck-go-helper.service @@ -0,0 +1,20 @@ +[Unit] +Description=Stream Deck privileged command helper +Documentation=https://github.com/WoodardDigital/streamdeck-go +# Start before the user session so the socket is ready when streamdeck-go starts. +Before=graphical.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/streamdeck-helper +Restart=on-failure +RestartSec=3 + +# Run as root (required to execute privileged commands). +# The socket is group-restricted to the 'streamdeck' group — only +# members of that group can connect. +RuntimeDirectory=streamdeck-go +RuntimeDirectoryMode=0755 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/streamdeck-go.service b/systemd/streamdeck-go.service new file mode 100644 index 0000000..cac62ee --- /dev/null +++ b/systemd/streamdeck-go.service @@ -0,0 +1,16 @@ +[Unit] +Description=Stream Deck controller +Documentation=https://github.com/WoodardDigital/streamdeck-go +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/streamdeck-go +Restart=on-failure +RestartSec=3 +# Config is auto-discovered at ~/.config/streamdeck-go/config.yaml +# Override with: ExecStart=%h/.local/bin/streamdeck-go -config /path/to/config.yaml + +[Install] +WantedBy=graphical-session.target