adding mac support mainly
This commit is contained in:
71
Makefile
71
Makefile
@@ -1,16 +1,29 @@
|
|||||||
BINARY := streamdeck-go
|
BINARY := streamdeck-go
|
||||||
HELPER := streamdeck-helper
|
HELPER := streamdeck-helper
|
||||||
PREFIX ?= $(HOME)/.local
|
PREFIX ?= $(HOME)/.local
|
||||||
BIN_DIR := $(PREFIX)/bin
|
|
||||||
SYS_BIN := /usr/local/bin
|
|
||||||
CONFIG_DIR := $(HOME)/.config/streamdeck-go
|
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
|
GROUP := streamdeck
|
||||||
|
|
||||||
.PHONY: build build-helper install install-helper uninstall udev
|
# ── OS detection ──────────────────────────────────────────────────────────────
|
||||||
|
OS := $(shell uname -s)
|
||||||
|
|
||||||
|
ifeq ($(OS),Darwin)
|
||||||
|
BIN_DIR := $(HOME)/go/bin
|
||||||
|
ETC_DIR := /usr/local/etc/streamdeck-go
|
||||||
|
LAUNCHAGENTS := $(HOME)/Library/LaunchAgents
|
||||||
|
LAUNCHDAEMONS := /Library/LaunchDaemons
|
||||||
|
AGENT_PLIST := $(LAUNCHAGENTS)/com.woodarddigital.streamdeck-go.plist
|
||||||
|
DAEMON_PLIST := $(LAUNCHDAEMONS)/com.woodarddigital.streamdeck-go-helper.plist
|
||||||
|
else
|
||||||
|
BIN_DIR := $(PREFIX)/bin
|
||||||
|
SYS_BIN := /usr/local/bin
|
||||||
|
ETC_DIR := /etc/streamdeck-go
|
||||||
|
SYSTEMD_USER := $(HOME)/.config/systemd/user
|
||||||
|
SYSTEMD_SYS := /etc/systemd/system
|
||||||
|
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: build build-helper install install-helper uninstall uninstall-helper udev
|
||||||
|
|
||||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,25 +35,37 @@ build-helper:
|
|||||||
|
|
||||||
# ── Install ───────────────────────────────────────────────────────────────────
|
# ── Install ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Interactive install — prompts for dotfiles directory, creates symlink,
|
# Interactive install — prompts for dotfiles directory, installs binary + service.
|
||||||
# installs binary + udev rule + systemd user service.
|
|
||||||
install:
|
install:
|
||||||
@bash install.sh
|
@bash install.sh
|
||||||
|
|
||||||
# Install the privileged helper (requires sudo).
|
# Install the privileged helper (requires sudo).
|
||||||
# Creates a 'streamdeck' group, adds the current user to it, installs the
|
ifeq ($(OS),Darwin)
|
||||||
# helper binary as root, and enables it as a system service.
|
# On macOS there is no root daemon — privileged commands run via osascript (admin
|
||||||
|
# auth dialog). install-helper just drops the whitelist into the user config dir.
|
||||||
|
install-helper:
|
||||||
|
mkdir -p $(CONFIG_DIR)
|
||||||
|
@if [ ! -f $(CONFIG_DIR)/privileged.yaml ]; then \
|
||||||
|
install -m 644 config/privileged.example.yaml $(CONFIG_DIR)/privileged.yaml; \
|
||||||
|
echo "Whitelist written to $(CONFIG_DIR)/privileged.yaml"; \
|
||||||
|
else \
|
||||||
|
echo "Whitelist already exists — not overwriting"; \
|
||||||
|
fi
|
||||||
|
@echo ""
|
||||||
|
@echo "Edit $(CONFIG_DIR)/privileged.yaml to add priv: commands."
|
||||||
|
@echo "No sudo or daemon required — macOS will prompt for admin auth on use."
|
||||||
|
else
|
||||||
install-helper: build-helper
|
install-helper: build-helper
|
||||||
# Create group and add current user.
|
# Create group and add current user (Linux).
|
||||||
@if ! getent group $(GROUP) > /dev/null; then \
|
@if ! getent group $(GROUP) > /dev/null; then \
|
||||||
sudo groupadd $(GROUP); \
|
sudo groupadd $(GROUP); \
|
||||||
echo "Created group '$(GROUP)'"; \
|
echo "Created group '$(GROUP)'"; \
|
||||||
fi
|
fi
|
||||||
sudo usermod -aG $(GROUP) $(USER)
|
sudo usermod -aG $(GROUP) $(USER)
|
||||||
# Install helper binary (owned root, not world-executable by others).
|
# Install helper binary (owned root, not world-executable).
|
||||||
sudo install -Dm750 $(HELPER) $(SYS_BIN)/$(HELPER)
|
sudo install -Dm750 $(HELPER) $(SYS_BIN)/$(HELPER)
|
||||||
sudo chown root:$(GROUP) $(SYS_BIN)/$(HELPER)
|
sudo chown root:$(GROUP) $(SYS_BIN)/$(HELPER)
|
||||||
# Install whitelist config (root-owned, not world-writable).
|
# Install whitelist config.
|
||||||
sudo mkdir -p $(ETC_DIR)
|
sudo mkdir -p $(ETC_DIR)
|
||||||
@if [ ! -f $(ETC_DIR)/privileged.yaml ]; then \
|
@if [ ! -f $(ETC_DIR)/privileged.yaml ]; then \
|
||||||
sudo install -Dm640 config/privileged.example.yaml $(ETC_DIR)/privileged.yaml; \
|
sudo install -Dm640 config/privileged.example.yaml $(ETC_DIR)/privileged.yaml; \
|
||||||
@@ -57,8 +82,13 @@ install-helper: build-helper
|
|||||||
@echo "Helper installed. Edit $(ETC_DIR)/privileged.yaml (as root) to add commands."
|
@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 "NOTE: log out and back in for group membership to take effect,"
|
||||||
@echo " or run: newgrp $(GROUP)"
|
@echo " or run: newgrp $(GROUP)"
|
||||||
|
endif
|
||||||
|
|
||||||
|
# udev: Linux-only device permission rule.
|
||||||
udev:
|
udev:
|
||||||
|
ifeq ($(OS),Darwin)
|
||||||
|
@echo "udev is Linux-only — not needed on macOS (IOKit grants HID access directly)."
|
||||||
|
else
|
||||||
@if [ ! -f $(UDEV_RULE) ]; then \
|
@if [ ! -f $(UDEV_RULE) ]; then \
|
||||||
echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \
|
echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \
|
||||||
| sudo tee $(UDEV_RULE); \
|
| sudo tee $(UDEV_RULE); \
|
||||||
@@ -68,9 +98,21 @@ udev:
|
|||||||
else \
|
else \
|
||||||
echo "udev rule already exists."; \
|
echo "udev rule already exists."; \
|
||||||
fi
|
fi
|
||||||
|
endif
|
||||||
|
|
||||||
# ── Uninstall ─────────────────────────────────────────────────────────────────
|
# ── Uninstall ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ifeq ($(OS),Darwin)
|
||||||
|
uninstall:
|
||||||
|
launchctl unload $(AGENT_PLIST) 2>/dev/null || true
|
||||||
|
rm -f $(AGENT_PLIST)
|
||||||
|
rm -f $(BIN_DIR)/$(BINARY)
|
||||||
|
@echo "Uninstalled. Config at $(CONFIG_DIR) preserved."
|
||||||
|
|
||||||
|
uninstall-helper:
|
||||||
|
@echo "No helper daemon on macOS — nothing to uninstall."
|
||||||
|
@echo "Whitelist at $(CONFIG_DIR)/privileged.yaml preserved."
|
||||||
|
else
|
||||||
uninstall:
|
uninstall:
|
||||||
systemctl --user disable --now streamdeck-go.service || true
|
systemctl --user disable --now streamdeck-go.service || true
|
||||||
rm -f $(BIN_DIR)/$(BINARY)
|
rm -f $(BIN_DIR)/$(BINARY)
|
||||||
@@ -84,3 +126,4 @@ uninstall-helper:
|
|||||||
sudo rm -f $(SYSTEMD_SYS)/streamdeck-go-helper.service
|
sudo rm -f $(SYSTEMD_SYS)/streamdeck-go-helper.service
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
@echo "Helper uninstalled. Whitelist at $(ETC_DIR)/privileged.yaml preserved."
|
@echo "Helper uninstalled. Whitelist at $(ETC_DIR)/privileged.yaml preserved."
|
||||||
|
endif
|
||||||
|
|||||||
287
README.md
287
README.md
@@ -3,23 +3,22 @@
|
|||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> This is a sloppy utility that's designed to just work. [406.fail](https://406.fail)
|
> 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.
|
||||||
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.
|
No Elgato software required — communicates directly with the device over USB HID.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Configure keys with a single YAML file (Designed for dotfiles compatability)
|
- Configure keys with a single YAML file (designed for dotfiles compatibility)
|
||||||
- PNG and JPEG icons, automatically scaled to key size
|
- 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
|
- Animated GIF support — frames pre-encoded at startup, cycled at the GIF's native rate
|
||||||
- Runs any shell command on key press
|
- 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
|
- **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
|
- **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
|
- **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
|
- Automatic reconnect — survives USB unplug, KVM switches, and suspend/resume
|
||||||
- Runs as a systemd user service, starts automatically with your desktop session
|
- Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login
|
||||||
- No Stream Deck app, no Node.js, no Electron
|
- No Stream Deck app, no Node.js, no Electron
|
||||||
|
|
||||||
**Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap)
|
**Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap)
|
||||||
@@ -31,15 +30,19 @@ No Elgato software required — communicates directly with the device over USB H
|
|||||||
```
|
```
|
||||||
streamdeck-go/
|
streamdeck-go/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ └── streamdeck/
|
│ ├── streamdeck/
|
||||||
│ └── main.go # Entry point, config watcher, event loop
|
│ │ └── main.go # Entry point, config watcher, event loop
|
||||||
|
│ └── streamdeck-helper/
|
||||||
|
│ └── main.go # Privileged helper daemon (Linux only)
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/
|
│ ├── config/
|
||||||
│ │ └── config.go # YAML parsing with XDG-aware defaults
|
│ │ └── config.go # YAML parsing with XDG-aware defaults
|
||||||
│ └── device/
|
│ └── device/
|
||||||
│ └── streamdeck.go # USB HID communication, image encoding, button reads
|
│ └── streamdeck.go # USB HID communication, image encoding, button reads
|
||||||
├── systemd/
|
├── systemd/
|
||||||
│ └── streamdeck-go.service # Systemd user service unit
|
│ └── 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)
|
├── config.example.yaml # Starter config (copied to ~/.config on install)
|
||||||
├── Makefile # build / install / uninstall
|
├── Makefile # build / install / uninstall
|
||||||
├── go.mod
|
├── go.mod
|
||||||
@@ -84,14 +87,16 @@ interleave partial image data across keys.
|
|||||||
| [`github.com/fsnotify/fsnotify`](https://github.com/fsnotify/fsnotify) | Config file watching for live reload |
|
| [`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 |
|
| [`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 |
|
| [`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 |
|
| Go stdlib `image/gif`, `image/jpeg`, `image/png` | Image decoding and JPEG encoding |
|
||||||
|
|
||||||
### System library
|
### System library
|
||||||
|
|
||||||
`libhidapi` must be present at runtime:
|
`libhidapi` must be present at runtime:
|
||||||
|
|
||||||
| Distro | Command |
|
| Platform | Command |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| macOS | `brew install hidapi` |
|
||||||
| Arch / Manjaro | `sudo pacman -S hidapi` |
|
| Arch / Manjaro | `sudo pacman -S hidapi` |
|
||||||
| Debian / Ubuntu | `sudo apt install libhidapi-hidraw0` |
|
| Debian / Ubuntu | `sudo apt install libhidapi-hidraw0` |
|
||||||
| Fedora / RHEL | `sudo dnf install hidapi` |
|
| Fedora / RHEL | `sudo dnf install hidapi` |
|
||||||
@@ -101,14 +106,39 @@ interleave partial image data across keys.
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Option A — `make install` (recommended)
|
### 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
|
An interactive installer that handles everything, including optional dotfiles
|
||||||
directory integration.
|
directory integration.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Prerequisites
|
# Prerequisites — Arch example; adjust for your distro (see table above)
|
||||||
sudo pacman -S go hidapi # Arch; adjust for your distro (see table above)
|
sudo pacman -S go hidapi
|
||||||
|
|
||||||
git clone https://github.com/WoodardDigital/streamdeck-go
|
git clone https://github.com/WoodardDigital/streamdeck-go
|
||||||
cd streamdeck-go
|
cd streamdeck-go
|
||||||
@@ -158,25 +188,11 @@ The resulting structure inside your dotfiles repo mirrors everything else in `.c
|
|||||||
└── icons/
|
└── icons/
|
||||||
```
|
```
|
||||||
|
|
||||||
After install, edit and save `config.yaml` — the deck reloads live, no restart needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$EDITOR ~/.config/streamdeck-go/config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
To remove:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make uninstall # stops the service and removes the binary; config is preserved
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Option B — manual / dev setup
|
### Linux — manual / dev setup
|
||||||
|
|
||||||
Use this if you want to run directly from the repo (e.g. while developing).
|
**1. udev rule** (one-time):
|
||||||
|
|
||||||
**1. udev rule** (one-time, needed once regardless of install method):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \
|
echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \
|
||||||
@@ -191,7 +207,6 @@ sudo udevadm trigger
|
|||||||
git clone https://github.com/WoodardDigital/streamdeck-go
|
git clone https://github.com/WoodardDigital/streamdeck-go
|
||||||
cd streamdeck-go
|
cd streamdeck-go
|
||||||
|
|
||||||
# Run with the repo's config.yaml (created from the example if absent):
|
|
||||||
cp config.example.yaml config.yaml
|
cp config.example.yaml config.yaml
|
||||||
go run ./cmd/streamdeck/
|
go run ./cmd/streamdeck/
|
||||||
|
|
||||||
@@ -199,24 +214,41 @@ go run ./cmd/streamdeck/
|
|||||||
go run ./cmd/streamdeck/ -config ~/.config/streamdeck-go/config.yaml
|
go run ./cmd/streamdeck/ -config ~/.config/streamdeck-go/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
When no `-config` flag is given, the binary always checks
|
When no `-config` flag is given the binary checks `~/.config/streamdeck-go/config.yaml`
|
||||||
`~/.config/streamdeck-go/config.yaml` first (respecting `$XDG_CONFIG_HOME`).
|
first (respecting `$XDG_CONFIG_HOME`). The repo's `config.yaml` is gitignored.
|
||||||
The repo's `config.yaml` is gitignored — it's your local scratchpad.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Option C — AUR (Arch Linux)
|
### AUR (Arch Linux)
|
||||||
|
|
||||||
> AUR package coming soon. Until then, use `make install` above.
|
> AUR package coming soon. Until then, use `make install` above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Systemd service
|
## Service management
|
||||||
|
|
||||||
The service file lives at `systemd/streamdeck-go.service` in the repo and is
|
### macOS (launchd)
|
||||||
installed to `~/.config/systemd/user/streamdeck-go.service` by `make install`.
|
|
||||||
|
|
||||||
Useful commands:
|
```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
|
```bash
|
||||||
systemctl --user status streamdeck-go # check if running
|
systemctl --user status streamdeck-go # check if running
|
||||||
@@ -225,7 +257,7 @@ systemctl --user stop streamdeck-go # stop
|
|||||||
journalctl --user -u streamdeck-go -f # follow logs
|
journalctl --user -u streamdeck-go -f # follow logs
|
||||||
```
|
```
|
||||||
|
|
||||||
To use a custom config path with the service, edit the unit after install:
|
To use a custom config path:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user edit streamdeck-go
|
systemctl --user edit streamdeck-go
|
||||||
@@ -252,7 +284,8 @@ icons_dir: ~/.config/streamdeck-go/icons # default; can be any path
|
|||||||
brightness: 70 # 0–100
|
brightness: 70 # 0–100
|
||||||
|
|
||||||
# USB IDs — defaults match Stream Deck XL v2
|
# USB IDs — defaults match Stream Deck XL v2
|
||||||
# Run: lsusb | grep Elgato
|
# macOS: system_profiler SPUSBDataType | grep -A5 "Stream Deck"
|
||||||
|
# Linux: lsusb | grep Elgato
|
||||||
device:
|
device:
|
||||||
vendor_id: 0x0fd9
|
vendor_id: 0x0fd9
|
||||||
product_id: 0x00ba
|
product_id: 0x00ba
|
||||||
@@ -267,11 +300,11 @@ device:
|
|||||||
|
|
||||||
keys:
|
keys:
|
||||||
0:
|
0:
|
||||||
icon: ghostty.png # PNG, JPEG, or GIF — relative to icons_dir
|
icon: ghostty.png # PNG, JPEG, GIF, or SVG — relative to icons_dir
|
||||||
command: ghostty
|
command: ghostty
|
||||||
1:
|
1:
|
||||||
icon: firefox.png
|
icon: firefox.png
|
||||||
command: firefox
|
command: open -a Firefox # macOS; use "firefox" on Linux
|
||||||
8:
|
8:
|
||||||
icon: loading.gif # animated — cycles at the GIF's native frame rate
|
icon: loading.gif # animated — cycles at the GIF's native frame rate
|
||||||
command: ""
|
command: ""
|
||||||
@@ -300,7 +333,31 @@ keys:
|
|||||||
| Yes | stdout contains the string | stdout does not contain it |
|
| Yes | stdout contains the string | stdout does not contain it |
|
||||||
| No (omitted) | command exits 0 | command exits non-zero |
|
| No (omitted) | command exits 0 | command exits non-zero |
|
||||||
|
|
||||||
**More examples:**
|
**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
|
```yaml
|
||||||
# VPN status (exit-code match — no match string needed)
|
# VPN status (exit-code match — no match string needed)
|
||||||
@@ -338,68 +395,50 @@ keys:
|
|||||||
|
|
||||||
Any shell command works — including launching terminals and SSH sessions.
|
Any shell command works — including launching terminals and SSH sessions.
|
||||||
|
|
||||||
**Open a terminal on key press:**
|
**Open a terminal:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
keys:
|
keys:
|
||||||
0:
|
0:
|
||||||
icon: ghostty.png
|
icon: ghostty.png
|
||||||
command: ghostty
|
command: ghostty # Linux
|
||||||
1:
|
1:
|
||||||
icon: terminal.png
|
icon: terminal.png
|
||||||
command: alacritty # or kitty, wezterm, foot, etc.
|
command: open -a Terminal # macOS — or: open -a iTerm
|
||||||
```
|
```
|
||||||
|
|
||||||
**SSH — open an interactive session in a terminal:**
|
**SSH:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
keys:
|
keys:
|
||||||
5:
|
5:
|
||||||
icon: homeserver.png
|
icon: homeserver.png
|
||||||
command: "ghostty -e ssh user@homeserver"
|
command: "ghostty -e ssh user@homeserver" # Linux
|
||||||
6:
|
6:
|
||||||
icon: pi.png
|
icon: homeserver.png
|
||||||
command: "alacritty -e ssh pi@raspberrypi.local"
|
command: "open -a Terminal ssh://user@homeserver" # macOS
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the recommended pattern for SSH — the terminal handles the TTY,
|
> **SSH agent & the service**
|
||||||
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
|
> The service starts before your shell environment is fully loaded, so
|
||||||
> `SSH_AUTH_SOCK` (used for passphrase-protected keys) may not be in its
|
> `SSH_AUTH_SOCK` may not be set. Fix:
|
||||||
> environment. Fix by importing it from your session startup:
|
|
||||||
>
|
>
|
||||||
|
> **Linux:**
|
||||||
> ```bash
|
> ```bash
|
||||||
> # Add to ~/.config/fish/config.fish, ~/.bashrc, or session init:
|
|
||||||
> systemctl --user import-environment SSH_AUTH_SOCK
|
> systemctl --user import-environment SSH_AUTH_SOCK
|
||||||
> ```
|
> ```
|
||||||
>
|
>
|
||||||
> Or hardcode the socket path in the service:
|
> **macOS** — add to `~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist`
|
||||||
>
|
> inside the `<dict>` block:
|
||||||
> ```bash
|
> ```xml
|
||||||
> systemctl --user edit streamdeck-go
|
> <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.
|
||||||
> ```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:**
|
**Common terminal flags:**
|
||||||
|
|
||||||
@@ -408,8 +447,62 @@ Works as long as SSH key auth is set up and the key has no passphrase (or the ag
|
|||||||
| ghostty | `ghostty -e <cmd>` |
|
| ghostty | `ghostty -e <cmd>` |
|
||||||
| alacritty | `alacritty -e <cmd>` |
|
| alacritty | `alacritty -e <cmd>` |
|
||||||
| kitty | `kitty <cmd>` |
|
| kitty | `kitty <cmd>` |
|
||||||
| foot | `foot <cmd>` |
|
|
||||||
| wezterm | `wezterm start -- <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.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install-helper # copies privileged.yaml to ~/.config/streamdeck-go/
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `~/.config/streamdeck-go/privileged.yaml`:
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install-helper # creates streamdeck group, installs helper + system service
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `/etc/streamdeck-go/privileged.yaml` (as root):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
commands:
|
||||||
|
suspend: "systemctl suspend"
|
||||||
|
reboot: "systemctl reboot"
|
||||||
|
poweroff: "systemctl poweroff"
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -430,13 +523,16 @@ way round.
|
|||||||
|
|
||||||
## Supported Devices
|
## Supported Devices
|
||||||
|
|
||||||
| Model | Product ID | Keys | Status |
|
| Model | Product ID | Keys | Linux | macOS |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Stream Deck XL v2 | `0x00ba` | 32 | tested |
|
| Stream Deck XL v2 | `0x00ba` | 32 | tested | tested |
|
||||||
| Stream Deck XL v1 | `0x006c` | 32 | untested |
|
| Stream Deck XL v1 | `0x006c` | 32 | untested | untested |
|
||||||
| Stream Deck MK.2 | `0x006d` | 15 | 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`
|
||||||
|
|
||||||
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).
|
To add a model, edit the `models` map in [internal/device/streamdeck.go](internal/device/streamdeck.go).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -447,8 +543,6 @@ To add a model, edit the `models` map in [internal/device/streamdeck.go](interna
|
|||||||
|
|
||||||
Render dynamic text directly onto a key image at runtime — useful for showing
|
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.
|
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):
|
Example config (proposed):
|
||||||
|
|
||||||
@@ -458,14 +552,13 @@ keys:
|
|||||||
icon: volume.png
|
icon: volume.png
|
||||||
label: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
|
label: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
|
||||||
label_position: bottom # top | center | bottom
|
label_position: bottom # top | center | bottom
|
||||||
refresh: 5s # re-evaluate and redraw every 5 seconds
|
refresh: 5s
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multi-page layouts
|
### Multi-page layouts
|
||||||
|
|
||||||
Support more than 32 actions by organising keys into named pages. A designated
|
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
|
key switches between pages.
|
||||||
the new page's icons when switching.
|
|
||||||
|
|
||||||
Example config (proposed):
|
Example config (proposed):
|
||||||
|
|
||||||
@@ -474,7 +567,7 @@ pages:
|
|||||||
default:
|
default:
|
||||||
0:
|
0:
|
||||||
icon: apps.png
|
icon: apps.png
|
||||||
command: "page:apps" # switch to the 'apps' page
|
command: "page:apps"
|
||||||
1:
|
1:
|
||||||
icon: firefox.png
|
icon: firefox.png
|
||||||
command: firefox
|
command: firefox
|
||||||
@@ -486,16 +579,10 @@ pages:
|
|||||||
1:
|
1:
|
||||||
icon: ghostty.png
|
icon: ghostty.png
|
||||||
command: ghostty
|
command: ghostty
|
||||||
2:
|
|
||||||
icon: obsidian.png
|
|
||||||
command: obsidian
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### AUR package
|
### 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
|
```bash
|
||||||
yay -S streamdeck-go # coming soon
|
yay -S streamdeck-go # coming soon
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,16 +16,31 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const socketMode = 0660
|
||||||
socketPath = "/run/streamdeck-go/helper.sock"
|
|
||||||
whitelistPath = "/etc/streamdeck-go/privileged.yaml"
|
// socketPath returns the Unix socket path for the helper daemon.
|
||||||
socketMode = 0660
|
// /run is standard on Linux; /var/run is used on macOS.
|
||||||
)
|
func socketPath() string {
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
return "/var/run/streamdeck-go/helper.sock"
|
||||||
|
}
|
||||||
|
return "/run/streamdeck-go/helper.sock"
|
||||||
|
}
|
||||||
|
|
||||||
|
// whitelistPath returns the path to the root-owned command whitelist.
|
||||||
|
// /etc is standard on Linux; /usr/local/etc is the convention on macOS.
|
||||||
|
func whitelistPath() string {
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
return "/usr/local/etc/streamdeck-go/privileged.yaml"
|
||||||
|
}
|
||||||
|
return "/etc/streamdeck-go/privileged.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
type whitelist struct {
|
type whitelist struct {
|
||||||
Commands map[string]string `yaml:"commands"`
|
Commands map[string]string `yaml:"commands"`
|
||||||
@@ -46,25 +61,28 @@ func main() {
|
|||||||
log.Fatal("streamdeck-helper must run as root (install as a system service)")
|
log.Fatal("streamdeck-helper must run as root (install as a system service)")
|
||||||
}
|
}
|
||||||
|
|
||||||
wl, err := loadWhitelist(whitelistPath)
|
sock := socketPath()
|
||||||
if err != nil {
|
wlist := whitelistPath()
|
||||||
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 {
|
wl, err := loadWhitelist(wlist)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load whitelist %q: %v", wlist, err)
|
||||||
|
}
|
||||||
|
log.Printf("loaded %d whitelisted commands from %s", len(wl.Commands), wlist)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(sock), 0755); err != nil {
|
||||||
log.Fatalf("create socket dir: %v", err)
|
log.Fatalf("create socket dir: %v", err)
|
||||||
}
|
}
|
||||||
// Remove stale socket from a previous run.
|
// Remove stale socket from a previous run.
|
||||||
_ = os.Remove(socketPath)
|
_ = os.Remove(sock)
|
||||||
|
|
||||||
ln, err := net.Listen("unix", socketPath)
|
ln, err := net.Listen("unix", sock)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("listen on %s: %v", socketPath, err)
|
log.Fatalf("listen on %s: %v", sock, err)
|
||||||
}
|
}
|
||||||
defer ln.Close()
|
defer ln.Close()
|
||||||
|
|
||||||
if err := os.Chmod(socketPath, socketMode); err != nil {
|
if err := os.Chmod(sock, socketMode); err != nil {
|
||||||
log.Fatalf("chmod socket: %v", err)
|
log.Fatalf("chmod socket: %v", err)
|
||||||
}
|
}
|
||||||
// Chown the socket to root:streamdeck so group members can connect.
|
// Chown the socket to root:streamdeck so group members can connect.
|
||||||
@@ -73,11 +91,11 @@ func main() {
|
|||||||
log.Fatalf("group 'streamdeck' not found — run 'make install-helper' first: %v", err)
|
log.Fatalf("group 'streamdeck' not found — run 'make install-helper' first: %v", err)
|
||||||
} else if gid, err := strconv.Atoi(grp.Gid); err != nil {
|
} else if gid, err := strconv.Atoi(grp.Gid); err != nil {
|
||||||
log.Fatalf("invalid gid %q: %v", grp.Gid, err)
|
log.Fatalf("invalid gid %q: %v", grp.Gid, err)
|
||||||
} else if err := os.Lchown(socketPath, 0, gid); err != nil {
|
} else if err := os.Lchown(sock, 0, gid); err != nil {
|
||||||
log.Fatalf("chown socket: %v", err)
|
log.Fatalf("chown socket: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("listening on %s (group: streamdeck)", socketPath)
|
log.Printf("listening on %s (group: streamdeck)", sock)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
conn, err := ln.Accept()
|
conn, err := ln.Accept()
|
||||||
@@ -111,7 +129,7 @@ func handle(conn net.Conn, wl *whitelist) {
|
|||||||
shell, ok := wl.Commands[req.Command]
|
shell, ok := wl.Commands[req.Command]
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("REJECTED unknown command %q", req.Command)
|
log.Printf("REJECTED unknown command %q", req.Command)
|
||||||
send(conn, response{Error: fmt.Sprintf("unknown command %q — add it to %s", req.Command, whitelistPath)})
|
send(conn, response{Error: fmt.Sprintf("unknown command %q — add it to %s", req.Command, whitelistPath())})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
|
||||||
"image/draw"
|
"image/draw"
|
||||||
"image/gif"
|
"image/gif"
|
||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
@@ -17,6 +16,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,9 +26,17 @@ import (
|
|||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/srwiley/oksvg"
|
"github.com/srwiley/oksvg"
|
||||||
"github.com/srwiley/rasterx"
|
"github.com/srwiley/rasterx"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const helperSocket = "/run/streamdeck-go/helper.sock"
|
// helperSocketPath returns the Unix socket path for the privileged helper.
|
||||||
|
// /run is the standard location on Linux; /var/run is used on macOS.
|
||||||
|
func helperSocketPath() string {
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
return "/var/run/streamdeck-go/helper.sock"
|
||||||
|
}
|
||||||
|
return "/run/streamdeck-go/helper.sock"
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfgPath := flag.String("config", defaultConfigPath(), "path to config file")
|
cfgPath := flag.String("config", defaultConfigPath(), "path to config file")
|
||||||
@@ -467,10 +475,39 @@ func runCommand(cmd string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runPrivileged sends a named command to the helper daemon over its Unix socket.
|
// runPrivileged dispatches a named privileged command.
|
||||||
// The helper validates the name against its root-owned whitelist and runs it.
|
// On macOS: reads the user's local whitelist and runs via osascript (admin auth dialog).
|
||||||
|
// On Linux: sends to the root helper daemon over its Unix socket.
|
||||||
func runPrivileged(name string) error {
|
func runPrivileged(name string) error {
|
||||||
conn, err := net.Dial("unix", helperSocket)
|
if runtime.GOOS == "darwin" {
|
||||||
|
return runPrivilegedDarwin(name)
|
||||||
|
}
|
||||||
|
return runPrivilegedHelper(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPrivilegedDarwin looks up name in ~/.config/streamdeck-go/privileged.yaml
|
||||||
|
// and executes it via osascript, which shows the standard macOS admin auth dialog.
|
||||||
|
func runPrivilegedDarwin(name string) error {
|
||||||
|
wlPath := darwinWhitelistPath()
|
||||||
|
commands, err := loadPrivilegedCommands(wlPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load whitelist %q: %w", wlPath, err)
|
||||||
|
}
|
||||||
|
shell, ok := commands[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown command %q — add it to %s", name, wlPath)
|
||||||
|
}
|
||||||
|
script := fmt.Sprintf(`do shell script %q with administrator privileges`, shell)
|
||||||
|
out, err := exec.Command("osascript", "-e", script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPrivilegedHelper sends a named command to the root helper daemon over its Unix socket.
|
||||||
|
func runPrivilegedHelper(name string) error {
|
||||||
|
conn, err := net.Dial("unix", helperSocketPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("helper unavailable (is streamdeck-go-helper.service running?): %w", err)
|
return fmt.Errorf("helper unavailable (is streamdeck-go-helper.service running?): %w", err)
|
||||||
}
|
}
|
||||||
@@ -499,6 +536,32 @@ func runPrivileged(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func darwinWhitelistPath() string {
|
||||||
|
base := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if base == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
base = filepath.Join(home, ".config")
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "streamdeck-go", "privileged.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPrivilegedCommands(path string) (map[string]string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var wl struct {
|
||||||
|
Commands map[string]string `yaml:"commands"`
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &wl); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if wl.Commands == nil {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
return wl.Commands, nil
|
||||||
|
}
|
||||||
|
|
||||||
func defaultConfigPath() string {
|
func defaultConfigPath() string {
|
||||||
base := os.Getenv("XDG_CONFIG_HOME")
|
base := os.Getenv("XDG_CONFIG_HOME")
|
||||||
if base == "" {
|
if base == "" {
|
||||||
@@ -548,8 +611,3 @@ keys: {}
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
func blank(w, h int) image.Image {
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
||||||
draw.Draw(img, img.Bounds(), &image.Uniform{color.Black}, image.Point{}, draw.Src)
|
|
||||||
return img
|
|
||||||
}
|
|
||||||
|
|||||||
162
install.sh
162
install.sh
@@ -1,15 +1,28 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# streamdeck-go installer
|
# streamdeck-go installer — Linux and macOS
|
||||||
# Handles dotfiles-aware installation with interactive prompts.
|
# Handles dotfiles-aware installation with interactive prompts.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BINARY="streamdeck-go"
|
BINARY="streamdeck-go"
|
||||||
BIN_DIR="${HOME}/.local/bin"
|
|
||||||
SYSTEMD_USER="${HOME}/.config/systemd/user"
|
|
||||||
UDEV_RULE="/etc/udev/rules.d/99-streamdeck.rules"
|
|
||||||
XDG_CONFIG="${HOME}/.config"
|
XDG_CONFIG="${HOME}/.config"
|
||||||
DEFAULT_DOTFILES="${HOME}/dotfiles"
|
DEFAULT_DOTFILES="${HOME}/dotfiles"
|
||||||
|
|
||||||
|
# ── OS detection ───────────────────────────────────────────────────────────────
|
||||||
|
OS="$(uname -s)"
|
||||||
|
IS_MAC=false
|
||||||
|
[[ "$OS" == "Darwin" ]] && IS_MAC=true
|
||||||
|
|
||||||
|
if $IS_MAC; then
|
||||||
|
BIN_DIR="${HOME}/go/bin"
|
||||||
|
LAUNCHAGENTS_DIR="${HOME}/Library/LaunchAgents"
|
||||||
|
PLIST_LABEL="com.woodarddigital.streamdeck-go"
|
||||||
|
PLIST_DST="${LAUNCHAGENTS_DIR}/${PLIST_LABEL}.plist"
|
||||||
|
else
|
||||||
|
BIN_DIR="${HOME}/.local/bin"
|
||||||
|
SYSTEMD_USER="${HOME}/.config/systemd/user"
|
||||||
|
UDEV_RULE="/etc/udev/rules.d/99-streamdeck.rules"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Colours ────────────────────────────────────────────────────────────────────
|
# ── Colours ────────────────────────────────────────────────────────────────────
|
||||||
if [[ -t 1 ]]; then
|
if [[ -t 1 ]]; then
|
||||||
BOLD='\033[1m'; DIM='\033[2m'
|
BOLD='\033[1m'; DIM='\033[2m'
|
||||||
@@ -46,7 +59,6 @@ prompt_yn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prompt_input() {
|
prompt_input() {
|
||||||
# echo-ne goes to /dev/tty so it isn't captured when called inside $()
|
|
||||||
local msg="$1" default="$2"
|
local msg="$1" default="$2"
|
||||||
echo -ne " ${ARROW} ${msg} ${DIM}[${default}]${NC}: " >/dev/tty
|
echo -ne " ${ARROW} ${msg} ${DIM}[${default}]${NC}: " >/dev/tty
|
||||||
read -r _input </dev/tty
|
read -r _input </dev/tty
|
||||||
@@ -73,7 +85,6 @@ pick_directory() {
|
|||||||
) || result=""
|
) || result=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# fzf not available or ctrl-c pressed — fall back to plain text input
|
|
||||||
if [[ -z "$result" ]]; then
|
if [[ -z "$result" ]]; then
|
||||||
echo -ne " ${ARROW} Path to dotfiles repo ${DIM}[${default}]${NC}: " >/dev/tty
|
echo -ne " ${ARROW} Path to dotfiles repo ${DIM}[${default}]${NC}: " >/dev/tty
|
||||||
read -r result </dev/tty
|
read -r result </dev/tty
|
||||||
@@ -84,33 +95,59 @@ pick_directory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abspath() {
|
abspath() {
|
||||||
# Expand ~, resolve to absolute path without requiring it to exist yet.
|
echo "${1/#\~/$HOME}"
|
||||||
local p="${1/#\~/$HOME}"
|
}
|
||||||
echo "$p"
|
|
||||||
|
# Portable realpath: macOS ships without readlink -f unless coreutils is installed.
|
||||||
|
realpath_portable() {
|
||||||
|
local path="$1"
|
||||||
|
if command -v realpath &>/dev/null; then
|
||||||
|
realpath "$path"
|
||||||
|
elif command -v python3 &>/dev/null; then
|
||||||
|
python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "$path"
|
||||||
|
else
|
||||||
|
echo "${path/#\~/$HOME}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Portable install with parent dir creation.
|
||||||
|
# macOS install(1) lacks -D; use explicit mkdir -p instead.
|
||||||
|
install_file() {
|
||||||
|
local mode="$1" src="$2" dst="$3"
|
||||||
|
mkdir -p "$(dirname "$dst")"
|
||||||
|
install -m "$mode" "$src" "$dst"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Dependency detection ───────────────────────────────────────────────────────
|
# ── Dependency detection ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
detect_pkg_manager() {
|
detect_pkg_manager() {
|
||||||
if command -v pacman &>/dev/null; then echo "pacman"
|
if $IS_MAC; then echo "brew"
|
||||||
elif command -v apt &>/dev/null; then echo "apt"
|
elif command -v pacman &>/dev/null; then echo "pacman"
|
||||||
elif command -v dnf &>/dev/null; then echo "dnf"
|
elif command -v apt &>/dev/null; then echo "apt"
|
||||||
elif command -v zypper &>/dev/null; then echo "zypper"
|
elif command -v dnf &>/dev/null; then echo "dnf"
|
||||||
|
elif command -v zypper &>/dev/null; then echo "zypper"
|
||||||
else echo "unknown"
|
else echo "unknown"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Returns 0 if hidapi dev headers are present (needed for cgo build).
|
|
||||||
hidapi_present() {
|
hidapi_present() {
|
||||||
pkg-config --exists hidapi-hidraw 2>/dev/null ||
|
if $IS_MAC; then
|
||||||
pkg-config --exists hidapi 2>/dev/null ||
|
# On macOS hidapi is a formula; check pkg-config or the dylib directly.
|
||||||
ldconfig -p 2>/dev/null | grep -q libhidapi
|
pkg-config --exists hidapi 2>/dev/null ||
|
||||||
|
[ -f /usr/local/lib/libhidapi.dylib ] ||
|
||||||
|
[ -f /opt/homebrew/lib/libhidapi.dylib ]
|
||||||
|
else
|
||||||
|
pkg-config --exists hidapi-hidraw 2>/dev/null ||
|
||||||
|
pkg-config --exists hidapi 2>/dev/null ||
|
||||||
|
ldconfig -p 2>/dev/null | grep -q libhidapi
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_pkg() {
|
install_pkg() {
|
||||||
local pm="$1"; shift
|
local pm="$1"; shift
|
||||||
local pkgs=("$@")
|
local pkgs=("$@")
|
||||||
case "$pm" in
|
case "$pm" in
|
||||||
|
brew) brew install "${pkgs[@]}" ;;
|
||||||
pacman) sudo pacman -S --needed --noconfirm "${pkgs[@]}" ;;
|
pacman) sudo pacman -S --needed --noconfirm "${pkgs[@]}" ;;
|
||||||
apt) sudo apt-get install -y "${pkgs[@]}" ;;
|
apt) sudo apt-get install -y "${pkgs[@]}" ;;
|
||||||
dnf) sudo dnf install -y "${pkgs[@]}" ;;
|
dnf) sudo dnf install -y "${pkgs[@]}" ;;
|
||||||
@@ -137,6 +174,7 @@ if ! command -v go &>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
if prompt_yn "Install Go now?" "y"; then
|
if prompt_yn "Install Go now?" "y"; then
|
||||||
case "$PM" in
|
case "$PM" in
|
||||||
|
brew) install_pkg brew go ;;
|
||||||
pacman) install_pkg pacman go ;;
|
pacman) install_pkg pacman go ;;
|
||||||
apt) install_pkg apt golang-go ;;
|
apt) install_pkg apt golang-go ;;
|
||||||
dnf) install_pkg dnf golang ;;
|
dnf) install_pkg dnf golang ;;
|
||||||
@@ -151,20 +189,25 @@ else
|
|||||||
ok "Go found: $(go version | awk '{print $3}')"
|
ok "Go found: $(go version | awk '{print $3}')"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# hidapi (C library + dev headers required for cgo)
|
# hidapi
|
||||||
if hidapi_present; then
|
if hidapi_present; then
|
||||||
ok "hidapi found"
|
ok "hidapi found"
|
||||||
else
|
else
|
||||||
warn "hidapi not found (required for USB HID communication)."
|
warn "hidapi not found (required for USB HID communication)."
|
||||||
if [[ "$PM" == "unknown" ]]; then
|
if [[ "$PM" == "unknown" ]]; then
|
||||||
warn "Could not detect a package manager — install hidapi manually, then re-run."
|
warn "Could not detect a package manager — install hidapi manually, then re-run."
|
||||||
warn " Arch: sudo pacman -S hidapi"
|
if $IS_MAC; then
|
||||||
warn " Debian: sudo apt install libhidapi-dev libhidapi-hidraw0"
|
warn " macOS: brew install hidapi"
|
||||||
warn " Fedora: sudo dnf install hidapi-devel"
|
else
|
||||||
|
warn " Arch: sudo pacman -S hidapi"
|
||||||
|
warn " Debian: sudo apt install libhidapi-dev libhidapi-hidraw0"
|
||||||
|
warn " Fedora: sudo dnf install hidapi hidapi-devel"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if prompt_yn "Install hidapi now?" "y"; then
|
if prompt_yn "Install hidapi now?" "y"; then
|
||||||
case "$PM" in
|
case "$PM" in
|
||||||
|
brew) install_pkg brew hidapi ;;
|
||||||
pacman) install_pkg pacman hidapi ;;
|
pacman) install_pkg pacman hidapi ;;
|
||||||
apt) install_pkg apt libhidapi-dev libhidapi-hidraw0 ;;
|
apt) install_pkg apt libhidapi-dev libhidapi-hidraw0 ;;
|
||||||
dnf) install_pkg dnf hidapi hidapi-devel ;;
|
dnf) install_pkg dnf hidapi hidapi-devel ;;
|
||||||
@@ -188,26 +231,37 @@ else
|
|||||||
fi
|
fi
|
||||||
nl
|
nl
|
||||||
|
|
||||||
# ── 3. udev rule ───────────────────────────────────────────────────────────────
|
# ── 3. Device permissions ──────────────────────────────────────────────────────
|
||||||
step "Checking udev rule..."
|
if $IS_MAC; then
|
||||||
if [[ -f "${UDEV_RULE}" ]]; then
|
step "Device permissions (macOS)..."
|
||||||
ok "udev rule already installed — skipping"
|
info "macOS grants HID access via IOKit — no udev rule needed."
|
||||||
|
info "On first run the OS may show an Input Monitoring prompt; grant access if asked."
|
||||||
|
ok "No action required"
|
||||||
else
|
else
|
||||||
info "Installing udev rule (requires sudo):"
|
step "Checking udev rule..."
|
||||||
dim " ${UDEV_RULE}"
|
if [[ -f "${UDEV_RULE}" ]]; then
|
||||||
echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \
|
ok "udev rule already installed — skipping"
|
||||||
| sudo tee "${UDEV_RULE}" >/dev/null
|
else
|
||||||
sudo udevadm control --reload
|
info "Installing udev rule (requires sudo):"
|
||||||
sudo udevadm trigger
|
dim " ${UDEV_RULE}"
|
||||||
ok "udev rule installed — device accessible without root"
|
echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \
|
||||||
|
| sudo tee "${UDEV_RULE}" >/dev/null
|
||||||
|
sudo udevadm control --reload
|
||||||
|
sudo udevadm trigger
|
||||||
|
ok "udev rule installed — device accessible without root"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
nl
|
nl
|
||||||
|
|
||||||
# ── 4. Binary ──────────────────────────────────────────────────────────────────
|
# ── 4. Binary ──────────────────────────────────────────────────────────────────
|
||||||
step "Installing binary to ${BOLD}${BIN_DIR}/${BINARY}${NC}..."
|
step "Installing binary to ${BOLD}${BIN_DIR}/${BINARY}${NC}..."
|
||||||
mkdir -p "${BIN_DIR}"
|
mkdir -p "${BIN_DIR}"
|
||||||
install -Dm755 "${BINARY}" "${BIN_DIR}/${BINARY}"
|
install -m 755 "${BINARY}" "${BIN_DIR}/${BINARY}"
|
||||||
ok "Binary installed"
|
ok "Binary installed"
|
||||||
|
if $IS_MAC && [[ ":$PATH:" != *":${BIN_DIR}:"* ]]; then
|
||||||
|
warn "${BIN_DIR} is not in your PATH — add it to your shell profile:"
|
||||||
|
dim " export PATH=\"\$HOME/go/bin:\$PATH\""
|
||||||
|
fi
|
||||||
nl
|
nl
|
||||||
|
|
||||||
# ── 5. Dotfiles ────────────────────────────────────────────────────────────────
|
# ── 5. Dotfiles ────────────────────────────────────────────────────────────────
|
||||||
@@ -231,7 +285,7 @@ if prompt_yn "Use a dotfiles directory?" "y"; then
|
|||||||
warn "Directory ${DOTFILES} does not exist — it will be created."
|
warn "Directory ${DOTFILES} does not exist — it will be created."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
CONFIG_DIR="${DOTFILES}/streamdeck-go/.config/streamdeck-go"
|
CONFIG_DIR="${DOTFILES}/.config/streamdeck-go"
|
||||||
SYMLINK_TARGET="${XDG_CONFIG}/streamdeck-go"
|
SYMLINK_TARGET="${XDG_CONFIG}/streamdeck-go"
|
||||||
|
|
||||||
nl
|
nl
|
||||||
@@ -268,7 +322,7 @@ mkdir -p "${CONFIG_DIR}/icons"
|
|||||||
ok "Created ${CONFIG_DIR}/icons/"
|
ok "Created ${CONFIG_DIR}/icons/"
|
||||||
|
|
||||||
if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then
|
if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then
|
||||||
install -Dm644 config.example.yaml "${CONFIG_DIR}/config.yaml"
|
install -m 644 config.example.yaml "${CONFIG_DIR}/config.yaml"
|
||||||
ok "Default config written to config.yaml"
|
ok "Default config written to config.yaml"
|
||||||
else
|
else
|
||||||
ok "config.yaml already exists — not overwritten"
|
ok "config.yaml already exists — not overwritten"
|
||||||
@@ -299,7 +353,7 @@ if [[ "${USE_DOTFILES}" == "true" ]]; then
|
|||||||
SYMLINK_TARGET="${XDG_CONFIG}/streamdeck-go"
|
SYMLINK_TARGET="${XDG_CONFIG}/streamdeck-go"
|
||||||
|
|
||||||
if [[ -L "${SYMLINK_TARGET}" ]]; then
|
if [[ -L "${SYMLINK_TARGET}" ]]; then
|
||||||
existing="$(readlink -f "${SYMLINK_TARGET}")"
|
existing="$(realpath_portable "${SYMLINK_TARGET}")"
|
||||||
if [[ "${existing}" == "${CONFIG_DIR}" ]]; then
|
if [[ "${existing}" == "${CONFIG_DIR}" ]]; then
|
||||||
ok "Symlink already correct — skipping"
|
ok "Symlink already correct — skipping"
|
||||||
else
|
else
|
||||||
@@ -323,14 +377,30 @@ if [[ "${USE_DOTFILES}" == "true" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 8. Systemd user service ────────────────────────────────────────────────────
|
# ── 8. Service ─────────────────────────────────────────────────────────────────
|
||||||
nl
|
nl
|
||||||
step "Installing systemd user service..."
|
if $IS_MAC; then
|
||||||
mkdir -p "${SYSTEMD_USER}"
|
step "Installing launchd agent..."
|
||||||
install -Dm644 systemd/streamdeck-go.service "${SYSTEMD_USER}/streamdeck-go.service"
|
mkdir -p "${LAUNCHAGENTS_DIR}"
|
||||||
systemctl --user daemon-reload
|
LOG_PATH="${HOME}/Library/Logs/streamdeck-go.log"
|
||||||
systemctl --user enable --now streamdeck-go.service
|
# Substitute binary path and log path into the plist template.
|
||||||
ok "Service enabled and started"
|
sed \
|
||||||
|
-e "s|STREAMDECK_BINARY_PATH|${BIN_DIR}/${BINARY}|g" \
|
||||||
|
-e "s|STREAMDECK_LOG_PATH|${LOG_PATH}|g" \
|
||||||
|
launchd/com.woodarddigital.streamdeck-go.plist \
|
||||||
|
> "${PLIST_DST}"
|
||||||
|
# Unload first in case an old version is already loaded.
|
||||||
|
launchctl unload "${PLIST_DST}" 2>/dev/null || true
|
||||||
|
launchctl load "${PLIST_DST}"
|
||||||
|
ok "launchd agent loaded — will start at login"
|
||||||
|
else
|
||||||
|
step "Installing systemd user service..."
|
||||||
|
mkdir -p "${SYSTEMD_USER}"
|
||||||
|
install_file 644 systemd/streamdeck-go.service "${SYSTEMD_USER}/streamdeck-go.service"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now streamdeck-go.service
|
||||||
|
ok "Service enabled and started"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Done ───────────────────────────────────────────────────────────────────────
|
# ── Done ───────────────────────────────────────────────────────────────────────
|
||||||
nl
|
nl
|
||||||
@@ -352,5 +422,9 @@ nl
|
|||||||
info "Edit and save config.yaml — the deck reloads automatically."
|
info "Edit and save config.yaml — the deck reloads automatically."
|
||||||
info "For privileged commands (suspend, reboot, etc): ${BOLD}make install-helper${NC}"
|
info "For privileged commands (suspend, reboot, etc): ${BOLD}make install-helper${NC}"
|
||||||
nl
|
nl
|
||||||
info "Logs: ${DIM}journalctl --user -u streamdeck-go -f${NC}"
|
if $IS_MAC; then
|
||||||
|
info "Logs: ${DIM}tail -f ~/Library/Logs/streamdeck-go.log${NC}"
|
||||||
|
else
|
||||||
|
info "Logs: ${DIM}journalctl --user -u streamdeck-go -f${NC}"
|
||||||
|
fi
|
||||||
nl
|
nl
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -62,7 +63,16 @@ func Open(vendorID, productID uint16) (*StreamDeck, error) {
|
|||||||
|
|
||||||
dev, err := hid.OpenFirst(vendorID, productID)
|
dev, err := hid.OpenFirst(vendorID, productID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open device 0x%04x:0x%04x: %w (try: sudo chmod a+rw /dev/hidraw*)", vendorID, productID, err)
|
var hint string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
hint = "try: sudo chmod a+rw /dev/hidraw*"
|
||||||
|
case "darwin":
|
||||||
|
hint = "try: brew install hidapi; check System Settings → Privacy & Security → Input Monitoring"
|
||||||
|
default:
|
||||||
|
hint = "check device permissions"
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("open device 0x%04x:0x%04x: %w (%s)", vendorID, productID, err, hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &StreamDeck{dev: dev, model: m}, nil
|
return &StreamDeck{dev: dev, model: m}, nil
|
||||||
@@ -153,10 +163,14 @@ func (sd *StreamDeck) ReadButtons() ([]bool, error) {
|
|||||||
data := make([]byte, readReportSize)
|
data := make([]byte, readReportSize)
|
||||||
n, err := sd.dev.ReadWithTimeout(data, 250)
|
n, err := sd.dev.ReadWithTimeout(data, 250)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// hidraw on Linux returns errors rather than (0, nil) for non-fatal
|
// Linux hidraw returns errors (not (0,nil)) for non-fatal conditions:
|
||||||
// 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
|
||||||
|
// return an error with a different message — catch both spellings.
|
||||||
msg := strings.ToLower(err.Error())
|
msg := strings.ToLower(err.Error())
|
||||||
if strings.Contains(msg, "timeout") || strings.Contains(msg, "interrupted") {
|
if strings.Contains(msg, "timeout") ||
|
||||||
|
strings.Contains(msg, "timed out") ||
|
||||||
|
strings.Contains(msg, "interrupted") {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
38
launchd/com.woodarddigital.streamdeck-go-helper.plist
Normal file
38
launchd/com.woodarddigital.streamdeck-go-helper.plist
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<!--
|
||||||
|
LaunchDaemon for streamdeck-go-helper (privileged root daemon).
|
||||||
|
Installed to: /Library/LaunchDaemons/com.woodarddigital.streamdeck-go-helper.plist
|
||||||
|
Requires: sudo
|
||||||
|
|
||||||
|
Manage with:
|
||||||
|
sudo launchctl load /Library/LaunchDaemons/com.woodarddigital.streamdeck-go-helper.plist
|
||||||
|
sudo launchctl unload /Library/LaunchDaemons/com.woodarddigital.streamdeck-go-helper.plist
|
||||||
|
tail -f /var/log/streamdeck-go-helper.log
|
||||||
|
|
||||||
|
The binary path below is set by install.sh at install time.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.woodarddigital.streamdeck-go-helper</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>STREAMDECK_HELPER_BINARY_PATH</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- Start automatically at boot (runs as root via LaunchDaemons). -->
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Restart automatically if the process exits. -->
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/var/log/streamdeck-go-helper.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/var/log/streamdeck-go-helper.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
37
launchd/com.woodarddigital.streamdeck-go.plist
Normal file
37
launchd/com.woodarddigital.streamdeck-go.plist
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<!--
|
||||||
|
LaunchAgent for streamdeck-go (user daemon).
|
||||||
|
Installed to: ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist
|
||||||
|
|
||||||
|
Manage with:
|
||||||
|
launchctl load ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist
|
||||||
|
launchctl unload ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist
|
||||||
|
tail -f /tmp/streamdeck-go.log
|
||||||
|
|
||||||
|
The binary path below is set by install.sh at install time.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.woodarddigital.streamdeck-go</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>STREAMDECK_BINARY_PATH</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- Start automatically when the user logs in. -->
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Restart automatically if the process exits. -->
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>STREAMDECK_LOG_PATH</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>STREAMDECK_LOG_PATH</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
98
mac-support.md
Normal file
98
mac-support.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# macOS Support — Change Catalog
|
||||||
|
|
||||||
|
All changes made on the `Mac` branch to achieve a unified Linux/macOS codebase.
|
||||||
|
No Windows support planned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status Legend
|
||||||
|
- ✅ Done
|
||||||
|
- ⬜ Pending
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design philosophy
|
||||||
|
|
||||||
|
The goal is a single binary that works on both platforms with no build tags. All
|
||||||
|
platform differences are resolved at runtime via `runtime.GOOS`. The user experience
|
||||||
|
should feel native on each OS — launchd on macOS, systemd on Linux, etc.
|
||||||
|
|
||||||
|
The biggest architectural difference is privileged commands:
|
||||||
|
- **Linux** uses a root helper daemon + Unix socket. The whitelist is root-owned so the
|
||||||
|
user cannot modify it. The main process never runs as root.
|
||||||
|
- **macOS** skips the daemon entirely. `priv:` commands are looked up in the user's own
|
||||||
|
`privileged.yaml` and executed via `osascript`, which shows the standard macOS admin
|
||||||
|
auth dialog. No root process, no group management, no socket chown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Changes
|
||||||
|
|
||||||
|
### `internal/device/streamdeck.go`
|
||||||
|
- ✅ **Platform-aware open error hint** — Linux: `sudo chmod a+rw /dev/hidraw*`; macOS: `brew install hidapi` + Input Monitoring note. The hint is embedded in the error returned from `Open()` so it surfaces wherever the error is logged.
|
||||||
|
- ✅ **Broaden HID read error matching** — `ReadButtons()` catches `"timeout"`, `"timed out"`, and `"interrupted"` as non-fatal. Linux hidraw returns `"timeout"`; macOS IOHIDManager may return `"timed out"`. Both should result in a retry rather than counting toward the 3-error device-death threshold.
|
||||||
|
|
||||||
|
### `cmd/streamdeck/main.go`
|
||||||
|
- ✅ **Runtime helper socket path** — `helperSocketPath()` returns `/run/streamdeck-go/helper.sock` on Linux and `/var/run/streamdeck-go/helper.sock` on macOS. `/run` is a Linux-specific tmpfs; `/var/run` is the macOS equivalent.
|
||||||
|
- ✅ **No root daemon on macOS** — `runPrivileged()` dispatches to:
|
||||||
|
- `runPrivilegedDarwin()` on macOS: reads `~/.config/streamdeck-go/privileged.yaml`, looks up the command, runs it via `osascript -e 'do shell script "..." with administrator privileges'`. The OS shows its standard admin auth dialog. No daemon, no sudo, no group.
|
||||||
|
- `runPrivilegedHelper()` on Linux: existing Unix socket approach, unchanged.
|
||||||
|
- ✅ **Remove dead code** — unused `blank()` function removed.
|
||||||
|
|
||||||
|
### `cmd/streamdeck-helper/main.go`
|
||||||
|
- ✅ **Runtime socket path** — same `/run` vs `/var/run` split as above. The helper binary is Linux-only in practice but the path function is correct on both platforms for consistency.
|
||||||
|
- ✅ **Runtime whitelist path** — Linux: `/etc/streamdeck-go/privileged.yaml`; unchanged (helper not used on macOS).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Files
|
||||||
|
|
||||||
|
### `launchd/com.woodarddigital.streamdeck-go.plist`
|
||||||
|
- ✅ macOS LaunchAgent for the user daemon. Installed to `~/Library/LaunchAgents/` by `install.sh`.
|
||||||
|
- `RunAtLoad: true` — starts at login.
|
||||||
|
- `KeepAlive: true` — restarts automatically if the process exits.
|
||||||
|
- Log path substituted at install time by `install.sh` via `sed` → `~/Library/Logs/streamdeck-go.log`. This path persists across reboots (unlike `/tmp` which is cleared on reboot).
|
||||||
|
- Binary path also substituted at install time.
|
||||||
|
|
||||||
|
### `launchd/com.woodarddigital.streamdeck-go-helper.plist`
|
||||||
|
- ✅ Kept for reference but **not used on macOS** — no root daemon needed.
|
||||||
|
- On macOS, privileged commands are handled inline via osascript (see above).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updated Files
|
||||||
|
|
||||||
|
### `install.sh`
|
||||||
|
- ✅ **OS detection** — `uname -s` at startup sets `IS_MAC=true/false`. All platform-specific blocks branch on this.
|
||||||
|
- ✅ **Dependency detection** — macOS: checks for hidapi dylib or pkg-config, installs via `brew`. Linux: existing pkg-config / ldconfig paths.
|
||||||
|
- ✅ **Skip udev on macOS** — macOS HID devices are accessible via IOKit without any device rules. The step is replaced with an informational message about Input Monitoring.
|
||||||
|
- ✅ **Binary install path** — macOS: `~/go/bin/` (no sudo required). Linux: `~/.local/bin/`. A PATH warning is shown on macOS if `~/go/bin` is not in the current shell's PATH.
|
||||||
|
- ✅ **Service management** — macOS: generates the launchd plist from the template (substituting binary + log paths via `sed`), then `launchctl load`. Linux: `systemctl --user enable --now`.
|
||||||
|
- ✅ **Log path** — macOS: `~/Library/Logs/streamdeck-go.log` (persistent). Linux: journald (unchanged).
|
||||||
|
- ✅ **`readlink -f` portability** — macOS ships without `readlink -f` (requires coreutils). Replaced with `realpath_portable()` which tries `realpath`, then `python3 -c "os.path.realpath()"`, then a basic `~`-expansion fallback.
|
||||||
|
- ✅ **`install -D` portability** — Linux `install -D` auto-creates parent directories; macOS `install` does not support this flag. Replaced with explicit `mkdir -p "$(dirname dst)"` + `install`.
|
||||||
|
- ✅ **Fix dotfiles path bug** — was `${DOTFILES}/streamdeck-go/.config/streamdeck-go`; corrected to `${DOTFILES}/.config/streamdeck-go` to match the pattern shown in the README.
|
||||||
|
|
||||||
|
### `Makefile`
|
||||||
|
- ✅ **OS detection** — `$(shell uname -s)` sets `OS`; `ifeq ($(OS),Darwin)` / `else` blocks set all platform-specific variables.
|
||||||
|
- ✅ **macOS `install-helper`** — no daemon, no group, no sudo. Just copies `config/privileged.example.yaml` to `~/.config/streamdeck-go/privileged.yaml`. The user edits it to add `priv:` commands.
|
||||||
|
- ✅ **Linux `install-helper`** — unchanged: `dscl`-free, uses `groupadd` + `usermod`, installs helper binary + systemd system service.
|
||||||
|
- ✅ **`udev` target** — guarded: prints a skip message on macOS, runs the udev rule install on Linux.
|
||||||
|
- ✅ **`uninstall` / `uninstall-helper`** — macOS: `launchctl unload` + file removal, no daemon to stop for helper. Linux: `systemctl disable` + file removal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform comparison
|
||||||
|
|
||||||
|
| Topic | Linux | macOS |
|
||||||
|
|---|---|---|
|
||||||
|
| HID access | udev rule grants `MODE="0666"` on hidraw | IOKit — no rules needed; accessible to user by default |
|
||||||
|
| Input Monitoring | N/A | May prompt on first run; Stream Deck is not keyboard/mouse so usually auto-granted |
|
||||||
|
| Service manager | systemd (user + system units) | launchd (LaunchAgent for user; no LaunchDaemon needed) |
|
||||||
|
| Socket dir | `/run/streamdeck-go/` | `/var/run/streamdeck-go/` |
|
||||||
|
| Privileged command mechanism | Root helper daemon + Unix socket | `osascript` admin auth dialog (inline, no daemon) |
|
||||||
|
| Whitelist location | `/etc/streamdeck-go/privileged.yaml` (root-owned 640) | `~/.config/streamdeck-go/privileged.yaml` (user-owned 644) |
|
||||||
|
| Binary location | `~/.local/bin/` | `~/go/bin/` |
|
||||||
|
| 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) |
|
||||||
|
| Sleep/wake | Handled by reconnect loop | Handled by reconnect loop (same code) |
|
||||||
Reference in New Issue
Block a user