2026-04-13 09:13:58 -06:00
2026-04-13 08:11:19 -06:00
2026-03-15 10:10:13 -06:00
2026-03-15 10:10:13 -06:00
2026-04-13 08:11:19 -06:00
2026-04-13 08:11:19 -06:00
2026-04-08 20:26:09 -06:00
2026-03-15 10:10:13 -06:00
2026-04-13 07:19:49 -06:00
2026-04-13 08:11:19 -06:00
2026-04-13 08:11:19 -06:00
2026-04-13 08:11:19 -06:00
2026-03-15 10:10:13 -06:00
2026-04-13 08:11:19 -06:00

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.


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 install above.


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                             # 0100

# 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_SOCK may not be set. Fix:

Linux:

systemctl --user import-environment SSH_AUTH_SOCK

macOS — add to ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist inside 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_SOCK in 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
Description
Personal Streamdeck utility for Omarchy | 406.fail
Readme 7.4 MiB
Languages
Go 66.6%
Shell 23%
Makefile 10.4%