#!/usr/bin/env bash # streamdeck-go installer — Linux and macOS # Handles dotfiles-aware installation with interactive prompts. set -euo pipefail BINARY="streamdeck-go" XDG_CONFIG="${HOME}/.config" 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 ──────────────────────────────────────────────────────────────────── if [[ -t 1 ]]; then BOLD='\033[1m'; DIM='\033[2m' RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; MAGENTA='\033[0;35m' NC='\033[0m' else BOLD=''; DIM=''; RED=''; GREEN=''; YELLOW='' BLUE=''; CYAN=''; MAGENTA=''; NC='' fi CHECK="${GREEN}✓${NC}" CROSS="${RED}✗${NC}" ARROW="${CYAN}❯${NC}" WARN="${YELLOW}⚠${NC}" DOT="${DIM}·${NC}" # ── Helpers ──────────────────────────────────────────────────────────────────── nl() { echo ""; } step() { echo -e " ${ARROW} $1"; } ok() { echo -e " ${CHECK} $1"; } warn() { echo -e " ${WARN} $1"; } info() { echo -e " ${DOT} $1"; } error() { echo -e " ${CROSS} ${RED}$1${NC}"; } dim() { echo -e " ${DIM}$1${NC}"; } prompt_yn() { local msg="$1" default="${2:-y}" local opts; [[ $default == "y" ]] && opts="Y/n" || opts="y/N" echo -ne " ${ARROW} ${msg} ${DIM}[${opts}]${NC} " read -r _answer /dev/tty read -r _input /dev/null; then echo -e "\n ${DIM}arrow keys to navigate · enter to select · ctrl-c to type path manually${NC}\n" >/dev/tty result=$( find "$HOME" -maxdepth 4 -type d 2>/dev/null | sort | fzf --height=50% \ --reverse \ --border=rounded \ --prompt=" ❯ " \ --header=" Select dotfiles directory" \ --preview="ls -1 {} 2>/dev/null | head -30" \ --preview-window="right:35%:border-left" \ --query="dotfiles" \ --bind="ctrl-c:abort" ) || result="" fi if [[ -z "$result" ]]; then echo -ne " ${ARROW} Path to dotfiles repo ${DIM}[${default}]${NC}: " >/dev/tty read -r result /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 ─────────────────────────────────────────────────────── detect_pkg_manager() { if $IS_MAC; then echo "brew" elif command -v pacman &>/dev/null; then echo "pacman" elif command -v apt &>/dev/null; then echo "apt" elif command -v dnf &>/dev/null; then echo "dnf" elif command -v zypper &>/dev/null; then echo "zypper" else echo "unknown" fi } hidapi_present() { if $IS_MAC; then # On macOS hidapi is a formula; check pkg-config or the dylib directly. 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() { local pm="$1"; shift local pkgs=("$@") case "$pm" in brew) brew install "${pkgs[@]}" ;; pacman) sudo pacman -S --needed --noconfirm "${pkgs[@]}" ;; apt) sudo apt-get install -y "${pkgs[@]}" ;; dnf) sudo dnf install -y "${pkgs[@]}" ;; zypper) sudo zypper install -y "${pkgs[@]}" ;; esac } # ── Header ───────────────────────────────────────────────────────────────────── nl echo -e " ${BOLD}${CYAN}streamdeck-go${NC} ${DIM}installer${NC}" echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" nl # ── 1. System dependencies ───────────────────────────────────────────────────── step "Checking system dependencies..." PM="$(detect_pkg_manager)" # Go if ! command -v go &>/dev/null; then warn "Go not found." if [[ "$PM" == "unknown" ]]; then error "Could not detect a package manager — install Go manually: https://go.dev/dl/" exit 1 fi if prompt_yn "Install Go now?" "y"; then case "$PM" in brew) install_pkg brew go ;; pacman) install_pkg pacman go ;; apt) install_pkg apt golang-go ;; dnf) install_pkg dnf golang ;; zypper) install_pkg zypper go ;; esac ok "Go installed: $(go version)" else error "Go is required to build — aborting." exit 1 fi else ok "Go found: $(go version | awk '{print $3}')" fi # hidapi if hidapi_present; then ok "hidapi found" else warn "hidapi not found (required for USB HID communication)." if [[ "$PM" == "unknown" ]]; then warn "Could not detect a package manager — install hidapi manually, then re-run." if $IS_MAC; then warn " macOS: brew install hidapi" 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 fi if prompt_yn "Install hidapi now?" "y"; then case "$PM" in brew) install_pkg brew hidapi ;; pacman) install_pkg pacman hidapi ;; apt) install_pkg apt libhidapi-dev libhidapi-hidraw0 ;; dnf) install_pkg dnf hidapi hidapi-devel ;; zypper) install_pkg zypper hidapi-devel ;; esac ok "hidapi installed" else error "hidapi is required — aborting." exit 1 fi fi nl # ── 2. Build ─────────────────────────────────────────────────────────────────── step "Building ${BOLD}${BINARY}${NC}..." if go build -o "${BINARY}" ./cmd/streamdeck/ 2>&1; then ok "Build complete" else error "Build failed — aborting." exit 1 fi nl # ── 3. Device permissions ────────────────────────────────────────────────────── if $IS_MAC; then step "Device permissions (macOS)..." 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 step "Checking udev rule..." if [[ -f "${UDEV_RULE}" ]]; then ok "udev rule already installed — skipping" else info "Installing udev rule (requires sudo):" dim " ${UDEV_RULE}" 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 nl # ── 4. Binary ────────────────────────────────────────────────────────────────── step "Installing binary to ${BOLD}${BIN_DIR}/${BINARY}${NC}..." mkdir -p "${BIN_DIR}" install -m 755 "${BINARY}" "${BIN_DIR}/${BINARY}" 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 # ── 5. Dotfiles ──────────────────────────────────────────────────────────────── echo -e " ${BOLD}Config location${NC}" echo -e " ${DIM}─────────────────────────────────────────────${NC}" nl info "streamdeck-go stores its config and icons in a single directory." info "You can keep that directory inside your dotfiles repo and symlink it" info "into ~/.config — the same pattern used by Hyprland, Waybar, etc." nl USE_DOTFILES=false CONFIG_DIR="" if prompt_yn "Use a dotfiles directory?" "y"; then nl DOTFILES_RAW="$(pick_directory "${DEFAULT_DOTFILES}")" DOTFILES="$(abspath "${DOTFILES_RAW}")" if [[ ! -d "${DOTFILES}" ]]; then warn "Directory ${DOTFILES} does not exist — it will be created." fi CONFIG_DIR="${DOTFILES}/.config/streamdeck-go" SYMLINK_TARGET="${XDG_CONFIG}/streamdeck-go" nl echo -e " ${DIM}─────────────────────────────────────────────${NC}" info "${BOLD}Will create:${NC}" dim " ${CONFIG_DIR}/" dim " ${CONFIG_DIR}/config.yaml ${DIM}(if absent)${NC}" dim " ${CONFIG_DIR}/icons/" nl info "${BOLD}Will symlink:${NC}" dim " ${SYMLINK_TARGET}" dim " └─▶ ${CONFIG_DIR}" echo -e " ${DIM}─────────────────────────────────────────────${NC}" nl if ! prompt_yn "Confirm?" "y"; then nl warn "Aborted — nothing written." exit 0 fi USE_DOTFILES=true else nl CONFIG_DIR="${XDG_CONFIG}/streamdeck-go" info "Config will go directly in ${CONFIG_DIR}" fi nl # ── 6. Create config directory ───────────────────────────────────────────────── step "Setting up config directory..." mkdir -p "${CONFIG_DIR}/icons" ok "Created ${CONFIG_DIR}/icons/" if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then install -m 644 config.example.yaml "${CONFIG_DIR}/config.yaml" ok "Default config written to config.yaml" else ok "config.yaml already exists — not overwritten" fi # Copy bundled icons (never overwrite existing ones the user may have customised). if [[ -d "icons" ]]; then copied=0 for f in icons/*; do [[ -f "$f" ]] || continue dest="${CONFIG_DIR}/icons/$(basename "$f")" if [[ ! -f "$dest" ]]; then cp "$f" "$dest" (( copied++ )) || true fi done if (( copied > 0 )); then ok "Copied ${copied} bundled icon(s) to ${CONFIG_DIR}/icons/" else ok "Bundled icons already present — not overwritten" fi fi # ── 7. Symlink (dotfiles mode only) ─────────────────────────────────────────── if [[ "${USE_DOTFILES}" == "true" ]]; then nl step "Creating symlink..." SYMLINK_TARGET="${XDG_CONFIG}/streamdeck-go" if [[ -L "${SYMLINK_TARGET}" ]]; then existing="$(realpath_portable "${SYMLINK_TARGET}")" if [[ "${existing}" == "${CONFIG_DIR}" ]]; then ok "Symlink already correct — skipping" else warn "Symlink exists but points to ${existing}" if prompt_yn "Replace it?" "y"; then rm "${SYMLINK_TARGET}" ln -s "${CONFIG_DIR}" "${SYMLINK_TARGET}" ok "Symlink updated → ${CONFIG_DIR}" else warn "Symlink not updated — you may need to fix this manually." fi fi elif [[ -d "${SYMLINK_TARGET}" ]]; then warn "${SYMLINK_TARGET} is a real directory (not a symlink)." info "Move its contents to ${CONFIG_DIR} first, then re-run install." warn "Skipping symlink — config will work from ${CONFIG_DIR} but is not linked." else mkdir -p "${XDG_CONFIG}" ln -s "${CONFIG_DIR}" "${SYMLINK_TARGET}" ok "Symlinked ${SYMLINK_TARGET} → ${CONFIG_DIR}" fi fi # ── 8. Service ───────────────────────────────────────────────────────────────── nl if $IS_MAC; then step "Installing launchd agent..." mkdir -p "${LAUNCHAGENTS_DIR}" LOG_PATH="${HOME}/Library/Logs/streamdeck-go.log" # Substitute binary path and log path into the plist template. 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 ─────────────────────────────────────────────────────────────────────── nl echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e " ${GREEN}${BOLD}All done.${NC}" nl if [[ "${USE_DOTFILES}" == "true" ]]; then info "Your config lives in your dotfiles repo:" dim " ${CONFIG_DIR}/config.yaml" dim " ${CONFIG_DIR}/icons/" else info "Your config:" dim " ${CONFIG_DIR}/config.yaml" dim " ${CONFIG_DIR}/icons/" fi nl info "Edit and save config.yaml — the deck reloads automatically." info "For privileged commands (suspend, reboot, etc): ${BOLD}make install-helper${NC}" nl 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