streamdeck-go
Important
This is a sloppy utility that's designed to just work. 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
Planned: text/label overlays on keys, multi-page layouts, AUR package — see 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
├── 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)
├── 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)
│
├── 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 |
Bindings for libhidapi — USB HID read/write |
github.com/fsnotify/fsnotify |
Config file watching for live reload |
golang.org/x/image |
Bi-linear image scaling |
gopkg.in/yaml.v3 |
YAML config parsing |
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
# 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:
# 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.
Linux — make install (recommended)
An interactive installer that handles everything, including optional dotfiles directory integration.
# 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):
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:
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 installabove.
Service management
macOS (launchd)
# 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)
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:
systemctl --user edit streamdeck-go
Add:
[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.
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.
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:
# 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:
# 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"
Terminal & SSH commands
Any shell command works — including launching terminals and SSH sessions.
Open a terminal:
keys:
0:
icon: ghostty.png
command: ghostty # Linux
1:
icon: terminal.png
command: open -a Terminal # macOS — or: open -a iTerm
SSH:
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_SOCKmay not be set. Fix:Linux:
systemctl --user import-environment SSH_AUTH_SOCKmacOS — add to
~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plistinside the<dict>block:<key>EnvironmentVariables</key> <dict> <key>SSH_AUTH_SOCK</key> <string>/private/tmp/com.apple.launchd.XXXXX/Listeners</string> </dict>Run
echo $SSH_AUTH_SOCKin a terminal to find the correct path.
Common terminal flags:
| Terminal | Flag to run a command |
|---|---|
| ghostty | ghostty -e <cmd> |
| alacritty | alacritty -e <cmd> |
| kitty | kitty <cmd> |
| wezterm | wezterm start -- <cmd> |
| Terminal.app | open -a Terminal <script> |
| iTerm2 | open -a iTerm <script> |
Privileged commands
Use the priv: prefix to run commands that require elevated privileges.
keys:
30:
icon: suspend.png
command: "priv:suspend"
Privileged commands — macOS
No root daemon required. The main process reads
~/.config/streamdeck-go/privileged.yaml and runs the command via osascript,
which shows the standard macOS admin authentication dialog.
Setup:
make install-helper # copies privileged.yaml to ~/.config/streamdeck-go/
Edit ~/.config/streamdeck-go/privileged.yaml:
commands:
suspend: "pmset sleepnow"
reboot: "shutdown -r now"
poweroff: "shutdown -h now"
Privileged commands — Linux
A root helper daemon (streamdeck-go-helper) validates commands against a
root-owned whitelist at /etc/streamdeck-go/privileged.yaml before running them.
The main daemon communicates with it over a Unix socket.
make install-helper # creates streamdeck group, installs helper + system service
Edit /etc/streamdeck-go/privileged.yaml (as root):
commands:
suspend: "systemctl suspend"
reboot: "systemctl reboot"
poweroff: "systemctl poweroff"
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 |
| SVG | Rasterised to 96×96 at startup via oksvg |
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 | Linux | macOS |
|---|---|---|---|---|
| Stream Deck XL v2 | 0x00ba |
32 | tested | tested |
| Stream Deck XL v1 | 0x006c |
32 | untested | untested |
| Stream Deck MK.2 | 0x006d |
15 | untested | untested |
To find your device's product ID:
- macOS:
system_profiler SPUSBDataType | grep -A5 "Stream Deck" - Linux:
lsusb | grep Elgato
To add a model, edit the models map in 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.
Example config (proposed):
keys:
4:
icon: volume.png
label: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
label_position: bottom # top | center | bottom
refresh: 5s
Multi-page layouts
Support more than 32 actions by organising keys into named pages. A designated key switches between pages.
Example config (proposed):
pages:
default:
0:
icon: apps.png
command: "page:apps"
1:
icon: firefox.png
command: firefox
apps:
0:
icon: back.png
command: "page:default"
1:
icon: ghostty.png
command: ghostty
AUR package
yay -S streamdeck-go # coming soon