diff --git a/Makefile b/Makefile index 86a5063..c2320e5 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ else UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules endif -.PHONY: build build-helper build-init install install-helper uninstall uninstall-helper udev +.PHONY: build build-helper build-init install install-helper install-watchdog uninstall uninstall-helper uninstall-watchdog udev # ── Build ───────────────────────────────────────────────────────────────────── @@ -88,6 +88,37 @@ install-helper: build-helper @echo " or run: newgrp $(GROUP)" endif +# Install the watchdog timer that detects USB unplug/replug and restarts the +# service when the daemon's in-process reconnect misses an event. Linux only. +ifeq ($(OS),Darwin) +WATCHDOG_PLIST := $(LAUNCHAGENTS)/com.woodarddigital.streamdeck-go-watchdog.plist +WATCHDOG_LOG := $(HOME)/Library/Logs/streamdeck-go-watchdog.log +install-watchdog: + mkdir -p $(BIN_DIR) $(LAUNCHAGENTS) $(HOME)/Library/Logs + install -m 755 systemd/streamdeck-go-watchdog.sh $(BIN_DIR)/streamdeck-go-watchdog + # Substitute the binary and log paths into the plist. + sed -e 's|STREAMDECK_WATCHDOG_PATH|$(BIN_DIR)/streamdeck-go-watchdog|' \ + -e 's|STREAMDECK_WATCHDOG_LOG_PATH|$(WATCHDOG_LOG)|g' \ + launchd/com.woodarddigital.streamdeck-go-watchdog.plist > $(WATCHDOG_PLIST) + # Reload: bootout (ignore if not loaded) then bootstrap. + launchctl bootout gui/$$(id -u)/com.woodarddigital.streamdeck-go-watchdog 2>/dev/null || true + launchctl bootstrap gui/$$(id -u) $(WATCHDOG_PLIST) + @echo "" + @echo "Watchdog installed. Fires every 30s." + @echo " Logs: $(WATCHDOG_LOG)" +else +install-watchdog: + install -Dm755 systemd/streamdeck-go-watchdog.sh $(BIN_DIR)/streamdeck-go-watchdog + install -Dm644 systemd/streamdeck-go-watchdog.service $(SYSTEMD_USER)/streamdeck-go-watchdog.service + install -Dm644 systemd/streamdeck-go-watchdog.timer $(SYSTEMD_USER)/streamdeck-go-watchdog.timer + systemctl --user daemon-reload + systemctl --user enable --now streamdeck-go-watchdog.timer + @echo "" + @echo "Watchdog timer installed and started." + @echo " Status: systemctl --user status streamdeck-go-watchdog.timer" + @echo " Logs: journalctl --user -u streamdeck-go-watchdog.service" +endif + # udev: Linux-only device permission rule. udev: ifeq ($(OS),Darwin) @@ -116,6 +147,12 @@ uninstall: uninstall-helper: @echo "No helper daemon on macOS — nothing to uninstall." @echo "Whitelist at $(CONFIG_DIR)/privileged.yaml preserved." + +uninstall-watchdog: + launchctl bootout gui/$$(id -u)/com.woodarddigital.streamdeck-go-watchdog 2>/dev/null || true + rm -f $(WATCHDOG_PLIST) + rm -f $(BIN_DIR)/streamdeck-go-watchdog + @echo "Watchdog uninstalled." else uninstall: systemctl --user disable --now streamdeck-go.service || true @@ -124,6 +161,14 @@ uninstall: systemctl --user daemon-reload @echo "Uninstalled. Config at $(CONFIG_DIR) preserved." +uninstall-watchdog: + systemctl --user disable --now streamdeck-go-watchdog.timer || true + rm -f $(BIN_DIR)/streamdeck-go-watchdog + rm -f $(SYSTEMD_USER)/streamdeck-go-watchdog.service + rm -f $(SYSTEMD_USER)/streamdeck-go-watchdog.timer + systemctl --user daemon-reload + @echo "Watchdog uninstalled." + uninstall-helper: sudo systemctl disable --now streamdeck-go-helper.service || true sudo rm -f $(SYS_BIN)/$(HELPER) diff --git a/README.md b/README.md index 1b37bec..2ce3919 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ interleave partial image data across keys. # Prerequisites brew install go hidapi -git clone https://github.com/WoodardDigital/streamdeck-go +git clone https://git.i0t.app/WoodardDigital/streamdeck-go cd streamdeck-go make install ``` @@ -154,7 +154,7 @@ 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 +git clone https://git.i0t.app/WoodardDigital/streamdeck-go cd streamdeck-go make install ``` @@ -218,7 +218,7 @@ sudo udevadm trigger **2. Build and run:** ```bash -git clone https://github.com/WoodardDigital/streamdeck-go +git clone https://git.i0t.app/WoodardDigital/streamdeck-go cd streamdeck-go cp config.example.yaml config.yaml @@ -791,6 +791,7 @@ keys: function: is_recording_paused match: "Paused: true" interval: 2s + ``` **Note — absolute paths in modules:** The example templates call `/usr/local/bin/obs-cmd` rather than just `obs-cmd`. This is because launchd (macOS) and systemd (Linux) give the service a minimal `PATH` that doesn't include `/usr/local/bin` or Homebrew. Use the absolute path returned by `which obs-cmd` in your own module templates, or set `PATH` in the launchd plist / systemd unit. diff --git a/cmd/streamdeck-init/grid.go b/cmd/streamdeck-init/grid.go index da18f7f..cd5ba08 100644 --- a/cmd/streamdeck-init/grid.go +++ b/cmd/streamdeck-init/grid.go @@ -29,7 +29,7 @@ import ( "fmt" "strings" - "github.com/WoodardDigital/streamdeck-go/internal/config" + "git.i0t.app/lwoodard/streamdeck-go/internal/config" "github.com/charmbracelet/lipgloss" ) diff --git a/cmd/streamdeck-init/main.go b/cmd/streamdeck-init/main.go index 0a89558..fed0880 100644 --- a/cmd/streamdeck-init/main.go +++ b/cmd/streamdeck-init/main.go @@ -51,9 +51,9 @@ import ( "sort" "strings" - "github.com/WoodardDigital/streamdeck-go/internal/config" - "github.com/WoodardDigital/streamdeck-go/internal/defaults" - "github.com/WoodardDigital/streamdeck-go/internal/modules" + "git.i0t.app/lwoodard/streamdeck-go/internal/config" + "git.i0t.app/lwoodard/streamdeck-go/internal/defaults" + "git.i0t.app/lwoodard/streamdeck-go/internal/modules" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) diff --git a/cmd/streamdeck/main.go b/cmd/streamdeck/main.go index fd42720..5bf8ca8 100644 --- a/cmd/streamdeck/main.go +++ b/cmd/streamdeck/main.go @@ -23,9 +23,9 @@ import ( "sync" "time" - "github.com/WoodardDigital/streamdeck-go/internal/config" - "github.com/WoodardDigital/streamdeck-go/internal/device" - "github.com/WoodardDigital/streamdeck-go/internal/modules" + "git.i0t.app/lwoodard/streamdeck-go/internal/config" + "git.i0t.app/lwoodard/streamdeck-go/internal/device" + "git.i0t.app/lwoodard/streamdeck-go/internal/modules" "github.com/fsnotify/fsnotify" "github.com/srwiley/oksvg" "github.com/srwiley/rasterx" @@ -921,7 +921,7 @@ func ensureConfigDir(cfgPath string) error { func defaultConfig(iconsDir string) string { return `# streamdeck-go configuration -# https://github.com/WoodardDigital/streamdeck-go +# https://git.i0t.app/lwoodard/streamdeck-go icons_dir: ` + iconsDir + ` brightness: 70 diff --git a/go.mod b/go.mod index 749fb65..0e9f563 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/WoodardDigital/streamdeck-go +module git.i0t.app/lwoodard/streamdeck-go go 1.25.0 diff --git a/install.sh b/install.sh index d0e5fb1..601ac65 100755 --- a/install.sh +++ b/install.sh @@ -449,6 +449,33 @@ else ok "Service enabled and started" fi +# ── 9. Watchdog ──────────────────────────────────────────────────────────────── +nl +step "Installing watchdog (USB unplug/replug recovery)..." +if $IS_MAC; then + WATCHDOG_BIN="${BIN_DIR}/streamdeck-go-watchdog" + WATCHDOG_PLIST_LABEL="com.woodarddigital.streamdeck-go-watchdog" + WATCHDOG_PLIST="${LAUNCHAGENTS_DIR}/${WATCHDOG_PLIST_LABEL}.plist" + WATCHDOG_LOG="${HOME}/Library/Logs/streamdeck-go-watchdog.log" + + install -m 755 systemd/streamdeck-go-watchdog.sh "${WATCHDOG_BIN}" + sed \ + -e "s|STREAMDECK_WATCHDOG_PATH|${WATCHDOG_BIN}|g" \ + -e "s|STREAMDECK_WATCHDOG_LOG_PATH|${WATCHDOG_LOG}|g" \ + launchd/com.woodarddigital.streamdeck-go-watchdog.plist \ + > "${WATCHDOG_PLIST}" + launchctl bootout "gui/$(id -u)/${WATCHDOG_PLIST_LABEL}" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "${WATCHDOG_PLIST}" + ok "Watchdog loaded — fires every 30s" +else + install_file 755 systemd/streamdeck-go-watchdog.sh "${BIN_DIR}/streamdeck-go-watchdog" + install_file 644 systemd/streamdeck-go-watchdog.service "${SYSTEMD_USER}/streamdeck-go-watchdog.service" + install_file 644 systemd/streamdeck-go-watchdog.timer "${SYSTEMD_USER}/streamdeck-go-watchdog.timer" + systemctl --user daemon-reload + systemctl --user enable --now streamdeck-go-watchdog.timer + ok "Watchdog timer enabled — fires every 30s" +fi + # ── Done ─────────────────────────────────────────────────────────────────────── nl echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" diff --git a/launchd/com.woodarddigital.streamdeck-go-watchdog.plist b/launchd/com.woodarddigital.streamdeck-go-watchdog.plist new file mode 100644 index 0000000..0f4cf06 --- /dev/null +++ b/launchd/com.woodarddigital.streamdeck-go-watchdog.plist @@ -0,0 +1,36 @@ + + + + + + Label + com.woodarddigital.streamdeck-go-watchdog + + ProgramArguments + + STREAMDECK_WATCHDOG_PATH + + + + StartInterval + 30 + + + RunAtLoad + + + StandardOutPath + STREAMDECK_WATCHDOG_LOG_PATH + StandardErrorPath + STREAMDECK_WATCHDOG_LOG_PATH + + \ No newline at end of file diff --git a/systemd/streamdeck-go-helper.service b/systemd/streamdeck-go-helper.service index 507574c..761b4b4 100644 --- a/systemd/streamdeck-go-helper.service +++ b/systemd/streamdeck-go-helper.service @@ -1,6 +1,6 @@ [Unit] Description=Stream Deck privileged command helper -Documentation=https://github.com/WoodardDigital/streamdeck-go +Documentation=https://git.i0t.app/WoodardDigital/streamdeck-go # Start before the user session so the socket is ready when streamdeck-go starts. Before=graphical.target diff --git a/systemd/streamdeck-go-watchdog.service b/systemd/streamdeck-go-watchdog.service new file mode 100644 index 0000000..6674e14 --- /dev/null +++ b/systemd/streamdeck-go-watchdog.service @@ -0,0 +1,8 @@ +[Unit] +Description=Stream Deck watchdog (one-shot) +Documentation=https://git.i0t.app/WoodardDigital/streamdeck-go +After=streamdeck-go.service + +[Service] +Type=oneshot +ExecStart=%h/.local/bin/streamdeck-go-watchdog diff --git a/systemd/streamdeck-go-watchdog.sh b/systemd/streamdeck-go-watchdog.sh new file mode 100644 index 0000000..f98738f --- /dev/null +++ b/systemd/streamdeck-go-watchdog.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# streamdeck-go watchdog — runs every 30s via systemd timer (Linux) or launchd +# StartInterval (macOS). +# +# Why this exists: when the Stream Deck is unplugged and replugged, the daemon's +# in-process reconnect logic does not always notice. On Linux, hidraw can keep +# returning read timeouts on the now-stale fd instead of surfacing an error, so +# the "3 consecutive errors → reconnect" path never triggers, and the service +# manager still reports the service as active even though the device is +# unreachable. +# +# Strategy: track the device's transient USB address (Linux: bus:device, +# macOS: Location ID). When it changes (unplug/replug) or the service is +# inactive while a device is present, restart the service. +set -euo pipefail + +# Stream Deck product IDs we support (see internal/device/streamdeck.go). +PIDS_RE="00ba|006c|006d" + +OS="$(uname -s)" + +case "$OS" in + Linux) + STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}" + ;; + Darwin) + # No XDG_RUNTIME_DIR on macOS; use the user-private temp dir. + STATE_DIR="${TMPDIR:-/tmp}" + ;; + *) + echo "watchdog: unsupported OS: $OS" >&2 + exit 1 + ;; +esac + +STATE_FILE="$STATE_DIR/streamdeck-go-watchdog.state" + +# Print a transient identifier for the first matching Stream Deck on the USB +# bus, or empty if none is present. The identifier must change across +# unplug/replug so we can detect it. +current_addr() { + case "$OS" in + Linux) + # "Bus 003 Device 052: ID 0fd9:00ba ..." → "003:052" + lsusb 2>/dev/null | awk -v pids="$PIDS_RE" ' + $0 ~ ("ID 0fd9:(" pids ")") { + gsub(":", "", $4) + print $2 ":" $4 + exit + } + ' + ;; + Darwin) + # system_profiler entry per device: + # Stream Deck XL: + # Product ID: 0x00ba + # Vendor ID: 0x0fd9 (Elgato ...) + # ... + # Location ID: 0x14140000 / 5 + # The trailing "/ N" is the bus address — it changes on replug. + system_profiler SPUSBDataType 2>/dev/null | awk -v pids="$PIDS_RE" ' + /^[[:space:]]*Product ID:/ { pid = $3 } + /^[[:space:]]*Vendor ID:/ { vid = $3 } + /^[[:space:]]*Location ID:/ { + sub(/^[[:space:]]*Location ID:[[:space:]]*/, "") + if (vid == "0x0fd9" && pid ~ ("^0x(" pids ")$")) { + print + exit + } + } + ' + ;; + esac +} + +# Is the streamdeck-go service currently active? +service_active() { + case "$OS" in + Linux) + systemctl --user is-active --quiet streamdeck-go.service + ;; + Darwin) + # launchctl list prints "PID Status Label". A PID of "-" means + # the agent is loaded but not running. + local line + line="$(launchctl list 2>/dev/null | awk '$3 == "com.woodarddigital.streamdeck-go" { print $1 }')" + [[ -n "$line" && "$line" != "-" ]] + ;; + esac +} + +restart_service() { + case "$OS" in + Linux) + systemctl --user restart streamdeck-go.service + ;; + Darwin) + # kickstart -k stops and restarts; works whether or not it's running. + launchctl kickstart -k "gui/$(id -u)/com.woodarddigital.streamdeck-go" + ;; + esac +} + +prev="" +[[ -f "$STATE_FILE" ]] && prev="$(cat "$STATE_FILE" 2>/dev/null || true)" + +curr="$(current_addr)" + +# Always update the state file so the next run sees a fresh baseline. +printf '%s' "$curr" > "$STATE_FILE" + +# No device present — nothing to do. Don't touch the service. +if [[ -z "$curr" ]]; then + exit 0 +fi + +reason="" + +if [[ -z "$prev" ]]; then + # First observation (or state file was wiped). Only restart if the service + # is also down — if it's already running, assume it's healthy and just + # record the baseline. + if ! service_active; then + reason="device present at $curr but service is not active" + fi +elif [[ "$curr" != "$prev" ]]; then + reason="device address changed: $prev → $curr (likely unplug/replug)" +elif ! service_active; then + reason="device present at $curr but service is not active" +fi + +if [[ -n "$reason" ]]; then + echo "watchdog: $reason — restarting streamdeck-go" + restart_service +fi diff --git a/systemd/streamdeck-go-watchdog.timer b/systemd/streamdeck-go-watchdog.timer new file mode 100644 index 0000000..c8f1296 --- /dev/null +++ b/systemd/streamdeck-go-watchdog.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Stream Deck watchdog timer (every 30s) +Documentation=https://git.i0t.app/WoodardDigital/streamdeck-go + +[Timer] +OnBootSec=30s +OnUnitActiveSec=30s +AccuracySec=5s +Unit=streamdeck-go-watchdog.service + +[Install] +WantedBy=timers.target diff --git a/systemd/streamdeck-go.service b/systemd/streamdeck-go.service index cac62ee..5ed98b1 100644 --- a/systemd/streamdeck-go.service +++ b/systemd/streamdeck-go.service @@ -1,6 +1,6 @@ [Unit] Description=Stream Deck controller -Documentation=https://github.com/WoodardDigital/streamdeck-go +Documentation=https://git.i0t.app/lwoodard/streamdeck-go After=graphical-session.target PartOf=graphical-session.target