# 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 and macOS. No Elgato software required — communicates directly with the device over USB HID. --- ## Features - Configure keys with a single YAML file (designed for dotfiles compatibility) - PNG, JPEG, and SVG 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 - **Status/toggle keys** — poll any shell command on an interval, swap icons based on output; icon updates on press - **Live config reload** — save your config and the deck updates instantly, no restart needed - **Privileged commands** — run whitelisted root/admin commands; Linux uses a root helper daemon, macOS uses the native admin auth dialog - Automatic reconnect — survives USB unplug, KVM switches, and suspend/resume - Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login - No Stream Deck app, no Node.js, no Electron - **Modules** — define reusable, parameterised commands in `modules.yaml` with Go templates; secrets stay in env vars, config stays in dotfiles. First built-in example: Slack (status, presence, snooze). See [Modules](#modules). - **Interactive config builder** — TUI tool (`streamdeck-init`) that walks you through key setup: pick a slot, pick a module/function, customize params, choose an icon. No YAML editing required. See [Config builder](#config-builder). **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 │ └── streamdeck-helper/ │ └── main.go # Privileged helper daemon (Linux only) ├── internal/ │ ├── config/ │ │ └── config.go # YAML parsing with XDG-aware defaults │ ├── device/ │ │ └── streamdeck.go # USB HID communication, image encoding, button reads │ └── modules/ │ └── modules.go # Module registry, template resolution, env/expiry helpers ├── systemd/ │ └── streamdeck-go.service # systemd user service (Linux) ├── launchd/ │ └── com.woodarddigital.streamdeck-go.plist # launchd agent (macOS) ├── config.example.yaml # Starter config (copied to ~/.config on install) ├── modules.example.yaml # Example modules file (Slack integration) ├── Makefile # build / install / uninstall ├── go.mod └── go.sum ``` ### How it works ``` ~/.config/streamdeck-go/config.yaml ~/.config/streamdeck-go/modules.yaml (optional) │ ├── fsnotify watcher ──── file saved? ──▶ cancel ctx → reload both → restart run() │ └──▶ run(ctx, registry) │ ├── module resolution ──▶ key has module/function? │ └──▶ registry.Resolve() → rendered shell command │ (templates expanded, env vars resolved) │ ├── 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) │ ├── poll goroutine/key ──▶ exec poll command every interval │ └──▶ match in stdout? swap icon_true / icon_false │ (also triggered immediately on button press) │ └── 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 | | [`github.com/srwiley/oksvg`](https://github.com/srwiley/oksvg) | SVG rasterisation | | Go stdlib `image/gif`, `image/jpeg`, `image/png` | Image decoding and JPEG encoding | ### System library `libhidapi` must be present at runtime: | Platform | Command | |---|---| | macOS | `brew install hidapi` | | 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 ### macOS ```bash # Prerequisites brew install go hidapi git clone https://github.com/WoodardDigital/streamdeck-go cd streamdeck-go make install ``` The binary installs to `~/go/bin/streamdeck-go`. Make sure `~/go/bin` is in your `PATH`: ```bash # Add to ~/.zshrc or ~/.bash_profile if not already present: export PATH="$HOME/go/bin:$PATH" ``` A launchd agent is installed to `~/Library/LaunchAgents/` and started automatically. No sudo required for the main binary. For privileged commands (sleep, reboot, etc.) see [Privileged commands — macOS](#privileged-commands--macos). --- ### Linux — `make install` (recommended) An interactive installer that handles everything, including optional dotfiles directory integration. ```bash # Prerequisites — Arch example; adjust for your distro (see table above) sudo pacman -S go hidapi git clone https://github.com/WoodardDigital/streamdeck-go cd streamdeck-go make install ``` The installer walks you through the whole setup: ``` ❯ Building streamdeck-go... ✓ Build complete ❯ Checking udev rule... ✓ udev rule already installed — skipping Config location ───────────────────────────────────────────── · streamdeck-go stores its config and icons in a single directory. · You can keep that directory inside your dotfiles repo and symlink it · into ~/.config — the same pattern used by Hyprland, Waybar, etc. ❯ Use a dotfiles directory? [Y/n] ❯ Path to dotfiles repo [~/dotfiles]: · Will create: · ~/dotfiles/.config/streamdeck-go/ · ~/dotfiles/.config/streamdeck-go/config.yaml · ~/dotfiles/.config/streamdeck-go/icons/ · Will symlink: · ~/.config/streamdeck-go · └─▶ ~/dotfiles/.config/streamdeck-go ❯ Confirm? [Y/n] ``` The resulting structure inside your dotfiles repo mirrors everything else in `.config`: ``` ~/dotfiles/ └── .config/ ├── hypr/ ├── waybar/ └── streamdeck-go/ ← lives here, symlinked to ~/.config/streamdeck-go ├── config.yaml └── icons/ ``` --- ### Linux — manual / dev setup **1. udev rule** (one-time): ```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 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 checks `~/.config/streamdeck-go/config.yaml` first (respecting `$XDG_CONFIG_HOME`). The repo's `config.yaml` is gitignored. --- ### AUR (Arch Linux) > AUR package coming soon. Until then, use `make install` above. --- ## Service management ### macOS (launchd) ```bash # Status launchctl list | grep streamdeck # Stop / start launchctl unload ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist launchctl load ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist # Logs (persistent across reboots) tail -f ~/Library/Logs/streamdeck-go.log ``` The agent starts automatically at login and restarts if the process exits. Sleep/wake is handled by the reconnect loop in the binary — no extra config needed. To use a custom config path, edit the installed plist at `~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist` and add `-config /path/to/config.yaml` to the `ProgramArguments` array, then reload. ### Linux (systemd) ```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: ```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 # macOS: system_profiler SPUSBDataType | grep -A5 "Stream Deck" # Linux: 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, GIF, or SVG — relative to icons_dir command: ghostty 1: icon: firefox.png command: open -a Firefox # macOS; use "firefox" on Linux 8: icon: loading.gif # animated — cycles at the GIF's native frame rate command: "" ``` ### 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. ```yaml keys: 3: command: pactl set-source-mute @DEFAULT_SOURCE@ toggle icon_true: mic-muted.png # shown when poll output contains match icon_false: mic-active.png # shown when poll output does not contain match poll: command: pactl get-source-mute @DEFAULT_SOURCE@ interval: 2s # how often to check (default: 2s) match: "yes" # substring to find in stdout → true ``` **How matching works:** | `match` set? | True condition | False condition | |---|---|---| | Yes | stdout contains the string | stdout does not contain it | | No (omitted) | command exits 0 | command exits non-zero | **macOS examples:** ```yaml # Mute microphone (macOS — requires SwitchAudioSource or similar) 3: command: "osascript -e 'set volume input muted not (input muted of (get volume settings))'" icon_true: mic-muted.png icon_false: mic-active.png poll: command: "osascript -e 'input muted of (get volume settings)'" interval: 2s match: "true" # VPN toggle (macOS) 4: command: "osascript -e 'tell application \"Tunnelblick\" to connect \"My VPN\"'" icon_true: vpn-on.png icon_false: vpn-off.png poll: command: "scutil --nc status \"My VPN\"" interval: 5s match: "Connected" ``` **Linux examples:** ```yaml # VPN status (exit-code match — no match string needed) 4: command: nmcli connection up my-vpn icon_true: vpn-on.png icon_false: vpn-off.png poll: command: nmcli connection show --active my-vpn interval: 5s # Systemd service toggle 5: command: systemctl --user toggle my-service icon_true: service-running.png icon_false: service-stopped.png poll: command: systemctl --user is-active my-service interval: 3s # Speaker mute 6: command: pactl set-sink-mute @DEFAULT_SINK@ toggle icon_true: speaker-muted.png icon_false: speaker-on.png poll: command: pactl get-sink-mute @DEFAULT_SINK@ interval: 2s match: "yes" ``` --- ### Launching applications On Linux, GUI apps are typically launched by their binary name (`firefox`, `ghostty`, `nautilus`). On macOS, apps live in `/Applications/` as `.app` bundles and need to be opened differently. #### macOS — `open -a` The `open -a` command launches (or focuses) a macOS application by name: ```yaml keys: 0: icon: firefox.png command: "open -a Firefox" 1: icon: slack.png command: "open -a Slack" 2: icon: ghostty.png command: "open -a Ghostty" 3: icon: finder.png command: "open -a Finder ~/Documents" # open Finder to a specific folder 4: icon: vscode.png command: "open -a 'Visual Studio Code'" # quote names with spaces ``` **How `open -a` works:** - If the app is already running, it brings it to the front (no duplicate launched). - If the app is not running, it launches it. - The app name matches what's in `/Applications/` minus the `.app` extension. - To find the exact name: `ls /Applications/` or `osascript -e 'tell application "System Events" to get name of every process whose background only is false'` **Opening files and URLs:** ```yaml keys: 5: icon: project.png command: "open -a 'Visual Studio Code' ~/Projects/myproject" # open a folder in VS Code 6: icon: notes.png command: "open ~/Documents/notes.txt" # opens in default app for .txt 7: icon: github.png command: "open https://github.com" # opens in default browser ``` #### Linux — direct binary ```yaml keys: 0: icon: firefox.png command: firefox 1: icon: slack.png command: slack 2: icon: ghostty.png command: ghostty 3: icon: files.png command: nautilus ~/Documents ``` Most Linux desktop apps can also be launched with their `.desktop` file via `gtk-launch`: ```yaml keys: 4: icon: vscode.png command: "gtk-launch code" # uses the .desktop file name (without .desktop) ``` #### Cross-platform keys If you use the same config on both platforms, you can use a shell one-liner: ```yaml keys: 0: icon: browser.png command: "if [ \"$(uname)\" = \"Darwin\" ]; then open -a Firefox; else firefox; fi" ``` --- ### Terminal & SSH commands Any shell command works — including launching terminals and SSH sessions. **Open a terminal:** ```yaml keys: 0: icon: ghostty.png command: ghostty # Linux 1: icon: terminal.png command: open -a Ghostty # macOS — or: open -a Terminal, open -a iTerm ``` **SSH:** ```yaml keys: 5: icon: homeserver.png command: "ghostty -e ssh user@homeserver" # Linux 6: icon: homeserver.png command: "open -a Terminal ssh://user@homeserver" # macOS ``` > **SSH agent & the service** > > The service starts before your shell environment is fully loaded, so > `SSH_AUTH_SOCK` may not be set. Fix: > > **Linux:** > ```bash > systemctl --user import-environment SSH_AUTH_SOCK > ``` > > **macOS** — add to `~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist` > inside the `` block: > ```xml > EnvironmentVariables > > SSH_AUTH_SOCK > /private/tmp/com.apple.launchd.XXXXX/Listeners > > ``` > Run `echo $SSH_AUTH_SOCK` in a terminal to find the correct path. **Common terminal flags:** | Terminal | Flag to run a command | |---|---| | ghostty | `ghostty -e ` | | alacritty | `alacritty -e ` | | kitty | `kitty ` | | wezterm | `wezterm start -- ` | | Terminal.app | `open -a Terminal