From 04146ddf3776afeda6f2839a14e74651b88316da Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 8 Oct 2026 07:39:32 -0500 Subject: [PATCH 1/6] Ship a systemd user unit in the Linux payload For running as the invoking user and following the session's PipeWire or PulseAudio. It keeps the seccomp half of the system unit's hardening, which is what an unprivileged manager can apply. --- .github/workflows/build-armv6.yml | 5 ++++ .github/workflows/build.yml | 8 ++++++ CMakeLists.txt | 7 +++++ packaging/sendspin-cli.user.service.in | 36 ++++++++++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 packaging/sendspin-cli.user.service.in diff --git a/.github/workflows/build-armv6.yml b/.github/workflows/build-armv6.yml index 399240a..ade8350 100644 --- a/.github/workflows/build-armv6.yml +++ b/.github/workflows/build-armv6.yml @@ -248,6 +248,10 @@ jobs: /etc/sendspin-cli.conf; there is an annotated example beside the installed README, under usr/local/share/doc/sendspin-cli. Its ExecStart names /usr/local/bin/sendspin-cli absolutely, so a binary put anywhere else needs the unit changed to match. + + To run it as yourself instead, following your session's PipeWire or PulseAudio, there is a + user unit at usr/local/lib/systemd/user/sendspin-cli.service: systemctl --user enable --now + sendspin-cli, configured in ~/.config/sendspin-cli/config. Not both: they share a port. LINUX # Explains the build-host line, and reads the glibc floor off the binary. @@ -285,6 +289,7 @@ jobs: echo usr/local/bin/sendspin-cli echo usr/local/lib/sysusers.d/sendspin-cli.conf echo usr/local/lib/systemd/system/sendspin-cli.service + echo usr/local/lib/systemd/user/sendspin-cli.service echo usr/local/share/doc/sendspin-cli/LICENSE echo usr/local/share/doc/sendspin-cli/README.md echo usr/local/share/doc/sendspin-cli/contributors.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b0f3781..1ff0798 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -487,6 +487,10 @@ jobs: /etc/sendspin-cli.conf; there is an annotated example beside the installed README, under usr/local/share/doc/sendspin-cli. Its ExecStart names /usr/local/bin/sendspin-cli absolutely, so a binary put anywhere else needs the unit changed to match. + + To run it as yourself instead, following your session's PipeWire or PulseAudio, there is a + user unit at usr/local/lib/systemd/user/sendspin-cli.service: systemctl --user enable --now + sendspin-cli, configured in ~/.config/sendspin-cli/config. Not both: they share a port. LINUX fi @@ -530,6 +534,7 @@ jobs: if [ "$RUNNER_OS" = 'Linux' ]; then echo usr/local/lib/sysusers.d/sendspin-cli.conf echo usr/local/lib/systemd/system/sendspin-cli.service + echo usr/local/lib/systemd/user/sendspin-cli.service fi } | sort >"$expected" @@ -609,6 +614,9 @@ jobs: echo "What was installed as $unit:" cat "$unit" systemd-analyze verify "$unit" + # --user needs a runtime directory to initialise, which a runner step may not export. + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \ + systemd-analyze --user verify /usr/local/lib/systemd/user/sendspin-cli.service # Run the hardened unit as its account on a real systemd runner, via the same systemd-sysusers step as README. - name: Run the unit as its own user, with the hardening block diff --git a/CMakeLists.txt b/CMakeLists.txt index 8dce1ba..1246f4f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -385,6 +385,8 @@ if(SENDSPIN_CLI_IS_TOP_LEVEL) set(SENDSPIN_CLI_INSTALLED_BINARY "${CMAKE_INSTALL_FULL_BINDIR}/sendspin-cli") configure_file(packaging/sendspin-cli.service.in ${CMAKE_CURRENT_BINARY_DIR}/sendspin-cli.service @ONLY) + configure_file(packaging/sendspin-cli.user.service.in + ${CMAKE_CURRENT_BINARY_DIR}/sendspin-cli.user.service @ONLY) # lib/systemd/system, not the multiarch libdir, where systemd would not look. if(CMAKE_SYSTEM_NAME STREQUAL "Linux") @@ -392,6 +394,11 @@ if(SENDSPIN_CLI_IS_TOP_LEVEL) DESTINATION lib/systemd/system COMPONENT ${SENDSPIN_CLI_COMPONENT} ) + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/sendspin-cli.user.service + DESTINATION lib/systemd/user + RENAME sendspin-cli.service + COMPONENT ${SENDSPIN_CLI_COMPONENT} + ) # lib/sysusers.d, renamed to the one-.conf-per-package name. install(FILES packaging/sendspin-cli.sysusers.conf diff --git a/packaging/sendspin-cli.user.service.in b/packaging/sendspin-cli.user.service.in new file mode 100644 index 0000000..a2ca917 --- /dev/null +++ b/packaging/sendspin-cli.user.service.in @@ -0,0 +1,36 @@ +# sendspin-cli as a user service, following your session's PipeWire or PulseAudio: +# +# systemctl --user daemon-reload +# systemctl --user enable --now sendspin-cli +# sudo loginctl enable-linger "$USER" +# +# Configure it in ~/.config/sendspin-cli/config. Not beside the system unit: both take one port. + +[Unit] +Description=Sendspin audio player +Documentation=https://github.com/Sendspin/sendspin-cpp-cli +# Ordering only: whichever sound server this session has, if any. +After=pipewire.service pipewire-pulse.service pulseaudio.service + +[Service] +# Foreground; journald captures stderr. +Type=simple + +# The seccomp half of the system unit's hardening; the rest needs privileges a user manager lacks. +NoNewPrivileges=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +SystemCallArchitectures=native +SystemCallFilter=@system-service + +# $XDG_RUNTIME_DIR and $XDG_STATE_HOME exist here, so the socket and state take their defaults. +ExecStart=@SENDSPIN_CLI_INSTALLED_BINARY@ + +Restart=on-failure +RestartSec=5 + +[Install] +# A user manager has no multi-user.target. +WantedBy=default.target From 96aa5d1e985ef2fc02710a4d03bb856be29a5246 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 8 Oct 2026 07:39:32 -0500 Subject: [PATCH 2/6] Ask for a name and output in get_started_linux.sh and start the service The script now works piped into bash, with prompts read from the terminal and the body wrapped so a truncated download runs nothing. It offers to install missing runtime libraries and a --user-service mode. --yes with no --output on an unconfigured host still enables without starting. --- scripts/get_started_linux.sh | 1344 ++++++++++++++++++++++++---------- 1 file changed, 949 insertions(+), 395 deletions(-) diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index 475dfd8..9f82d62 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -14,24 +14,15 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# Installs a released sendspin-cli on a Linux host and enables its systemd unit. -# Verifies SHA256SUMS, creates the service account, and starts the player only once an `output` is configured. +# Installs a released sendspin-cli on a Linux host, asks for a name and an output device, and +# starts it as a systemd service. Verifies SHA256SUMS. # Every root command is printed first and confirmed, or pre-authorised with --yes. # -# Usage: scripts/get_started_linux.sh [--version ] [--yes] +# Usage: get_started_linux.sh [--version ] [--name ] [--output ] +# [--user-service | --system-service] [--yes] +# curl -fsSL | bash -s -- [the same flags] # -# --version install this release instead of the latest, e.g. --version v0.1.0 -# --yes do not prompt before the commands that need root. Required when stdin -# is not a terminal, since there is nobody there to ask -# -# Environment: -# -# SENDSPIN_CLI_TARBALL install this payload instead of downloading a release. The escape -# hatch for a locally built archive -- `DESTDIR` staged and tarred, -# exactly as CI publishes -- and the only way to exercise this script -# before a release exists. It is NOT checksummed, and the script says -# so loudly rather than letting a skipped integrity check pass for a -# successful one. +# --help describes each flag. set -euo pipefail @@ -40,12 +31,15 @@ readonly REPO='Sendspin/sendspin-cpp-cli' readonly UNIT='sendspin-cli' readonly UNIT_FILE='/usr/local/lib/systemd/system/sendspin-cli.service' +readonly USER_UNIT_FILE='/usr/local/lib/systemd/user/sendspin-cli.service' readonly SYSUSERS_FILE='/usr/local/lib/sysusers.d/sendspin-cli.conf' readonly SERVICE_USER='sendspin-cli' readonly BINARY='/usr/local/bin/sendspin-cli' -readonly CONFIG='/etc/sendspin-cli.conf' +readonly SYSTEM_CONFIG='/etc/sendspin-cli.conf' readonly CONFIG_EXAMPLE='/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example' readonly CONTROL_SOCKET='/run/sendspin-cli/control.sock' +# First line of a user unit this script wrote, so only its own file is ever replaced or removed. +readonly OWN_UNIT_MARK='# Written by get_started_linux.sh' fail() { printf 'get_started_linux: FAIL: %s\n' "$*" >&2 @@ -60,131 +54,37 @@ step() { printf '\n==> %s\n' "$*" } -# What was asked for - -VERSION_TAG='' -ASSUME_YES='no' - -while [ "$#" -gt 0 ]; do - case "$1" in - --version) - [ "$#" -ge 2 ] || fail '--version needs a tag, e.g. --version v0.1.0' - VERSION_TAG=$2 - shift 2 - ;; - --yes | -y) - ASSUME_YES='yes' - shift - ;; - -h | --help) - # Prints the header's usage block. - sed -n '/^# Usage:/,/^$/ s/^#\{1,2\} \{0,1\}//p' "$0" - exit 0 - ;; - *) - fail "unknown argument '$1' -- see --help" - ;; - esac -done - -readonly VERSION_TAG ASSUME_YES - -# Is this a host this can work on at all - -[ "$(uname -s)" = 'Linux' ] || - fail "Linux only -- this installs a systemd unit. On macOS take the installer .pkg from - https://github.com/$REPO/releases instead" - -for tool in tar sed grep; do - command -v "$tool" >/dev/null 2>&1 || - fail "'$tool' is not on \$PATH, and this cannot install anything without it" -done - -# Pick the archive by userland, not uname -m: a Pi with a 64-bit kernel can run a 32-bit userland. -MACHINE="$(uname -m)" - -if command -v dpkg >/dev/null 2>&1; then - USERLAND="$(dpkg --print-architecture)" -elif command -v getconf >/dev/null 2>&1; then - case "$(getconf LONG_BIT):$MACHINE" in - 64:x86_64 | 64:amd64) USERLAND='amd64' ;; - 64:aarch64 | 64:arm64) USERLAND='arm64' ;; - 32:aarch64 | 32:arm64 | 32:arm*) USERLAND='armhf' ;; - # Unidentified userlands are refused below, never guessed from the kernel. - *) USERLAND='' ;; - esac -else - fail "neither dpkg nor getconf is on \$PATH, and one of them is needed to tell a 32-bit - userland from the 64-bit kernel it may be running under" -fi - -# Archives are named for the CI leg that built them. -case "$USERLAND" in - amd64 | x86_64) - LEG='linux-x86_64' - ;; - arm64 | aarch64) - LEG='linux-arm64' - ;; - armhf) - # 32-bit ARM: the CPU decides between ARMv6 and ARMv7; anything older is refused. - case "$MACHINE" in - armv6*) - LEG='linux-armv6' - ;; - armv[0-5]* | arm) - fail "'$MACHINE' is older than ARMv6, or names no ARM architecture at all, and - the oldest archive built is linux-armv6 -- an ARM1176, which is a Pi Zero, a Pi Zero W or an - original Pi. Its instructions would be illegal here, so there is nothing to install. Build - from source instead: https://github.com/$REPO#build" - ;; - *) - LEG='linux-armv7' - ;; - esac - ;; - '') - fail "this host's userland could not be identified from a $MACHINE kernel alone, and - guessing at it is how a 32-bit userland ends up with a 64-bit binary. Install dpkg, or - build from source: https://github.com/$REPO#build" - ;; - *) - fail "no release is built for a '$USERLAND' userland -- the archives are linux-x86_64, - linux-arm64, linux-armv7 and linux-armv6. Build from source instead: - https://github.com/$REPO#build" - ;; -esac -readonly MACHINE USERLAND LEG - -# Booted systemd, not merely installed. -HAVE_SYSTEMD='no' -if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then - HAVE_SYSTEMD='yes' -fi -readonly HAVE_SYSTEMD - -# /proc/device-tree/model is NUL-terminated. -PI_MODEL='' -if [ -r /proc/device-tree/model ]; then - model="$(tr -d '\0' /dev/null 2>&1 || - fail 'this needs root to install into /usr/local and to drive systemctl, and there is - no sudo here -- re-run it as root' - SUDO='sudo' -fi -readonly SUDO - -# `sudo ` or nothing, so printed commands are not misindented when already root. -readonly SUDO_P="${SUDO:+$SUDO }" +usage() { + cat <<'USAGE' +Usage: get_started_linux.sh [--version ] [--name ] [--output ] + [--user-service | --system-service] [--yes] + + --version install this release instead of the latest, e.g. --version v0.3.0 + --name the name a server shows for this player, instead of being asked. + Ignored when the config already sets one + --output the output device, e.g. hw:1,0, instead of being asked. + Ignored when the config already sets one + --system-service run as the unprivileged 'sendspin-cli' account, configured in + /etc/sendspin-cli.conf. The default + --user-service run as you instead, as a systemd user service that follows your + session's PipeWire or PulseAudio, configured in + ~/.config/sendspin-cli/config + --yes do not ask anything: run the root commands as printed, and take the + name and the output only from the flags above. Required when there + is no terminal to ask on. Without --output and with none configured, + the service is enabled and not started + +Piped, flags follow `bash -s --`: + + curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_linux.sh | bash -s -- --yes + +Environment: + + SENDSPIN_CLI_TARBALL install this payload instead of downloading a release: a DESTDIR + staged `cmake --install` tree, tarred as CI publishes it. It is + NOT checksummed. +USAGE +} as_root() { if [ -n "$SUDO" ]; then @@ -194,20 +94,67 @@ as_root() { fi } -WORK_DIR="$(mktemp -d)" -readonly WORK_DIR -trap 'rm -rf "$WORK_DIR"' EXIT +# Runs as the account the user service belongs to, with its user manager reachable. +as_user() { + if [ "$(id -u)" -eq 0 ]; then + sudo -u "$TARGET_USER" env "XDG_RUNTIME_DIR=/run/user/$TARGET_UID" "$@" + else + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$TARGET_UID}" "$@" + fi +} -say 'sendspin-cli getting started' -say " host: Linux $MACHINE, $USERLAND userland${PI_MODEL:+ ($PI_MODEL)}" -say " release leg: $LEG" -if [ "$HAVE_SYSTEMD" = 'yes' ]; then - say ' systemd: yes' -else - say ' systemd: no -- the unit will be installed but nothing started' -fi +# As whoever owns the config for this mode. +as_owner() { + if [ "$MODE" = 'user' ]; then + as_user "$@" + else + as_root "$@" + fi +} + +# Answers come from fd 3: stdin, or the terminal when the script itself arrived on stdin. +open_prompt_fd() { + CAN_ASK='no' + if [ -t 0 ]; then + exec 3<&0 + CAN_ASK='yes' + elif { exec 3/dev/null; then + CAN_ASK='yes' + fi +} -# The payload +# ask [default]: the answer lands in REPLY. +ask() { + printf '%s' "$1" + # `|| fail`: set -e would otherwise exit silently on a closed terminal. + read -r -u 3 REPLY || fail 'the terminal closed before an answer arrived' + [ -n "$REPLY" ] || REPLY="${2:-}" +} + +confirm() { + ask "$1 [y/N] " + case "$REPLY" in + y | Y | yes | YES) return 0 ;; + *) return 1 ;; + esac +} + +# config_sets : does this mode's config set it. As the owner: the file may be unreadable. +config_sets() { + as_owner test -f "$CONFIG" && + as_owner grep -Eq "^[[:space:]]*$1[[:space:]]*=" "$CONFIG" +} + +# set_key : replaces the example's commented line, else appends. +set_key() { + # Through the environment: awk -v would interpret backslashes in the value. + VALUE=$3 awk -v key="$2" ' + !done && index($0, "#" key " = ") == 1 { print key " = " ENVIRON["VALUE"]; done = 1; next } + { print } + END { if (!done) print key " = " ENVIRON["VALUE"] } + ' "$1" >"$1.new" + mv "$1.new" "$1" +} # The newest release tag, read off the /releases/latest redirect: no jq, no API rate limit, # and it tells "no releases" apart from "no such repository". @@ -246,309 +193,916 @@ resolve_latest_tag() { payload of your own and point this script at it: DESTDIR=/tmp/stage cmake --install build --component sendspin-cli tar -czf /tmp/sendspin-cli.tar.gz -C /tmp stage - SENDSPIN_CLI_TARBALL=/tmp/sendspin-cli.tar.gz $0 --yes" + SENDSPIN_CLI_TARBALL=/tmp/sendspin-cli.tar.gz ./get_started_linux.sh --yes" ;; esac } -if [ -n "${SENDSPIN_CLI_TARBALL:-}" ]; then - step 'Using the payload you supplied' - [ -f "$SENDSPIN_CLI_TARBALL" ] || - fail "SENDSPIN_CLI_TARBALL names '$SENDSPIN_CLI_TARBALL', which is not a file" - # Absolute, since tar -C / would resolve a relative path against /. - TARBALL="$(cd "$(dirname "$SENDSPIN_CLI_TARBALL")" && pwd)/$(basename "$SENDSPIN_CLI_TARBALL")" - say " $TARBALL" +# package_for : this host's package for a library the binary links, or nothing. +package_for() { + local names + case "$1" in + libasound.so.*) names='libasound2 alsa-lib alsa-lib' ;; + libportaudio.so.*) names='libportaudio2 portaudio portaudio' ;; + libpulse.so.*) names='libpulse0 pulseaudio-libs libpulse' ;; + libpipewire-0.3.so.*) names='libpipewire-0.3-0 pipewire-libs libpipewire' ;; + libdns_sd.so.*) names='libavahi-compat-libdnssd1 avahi-compat-libdns_sd avahi' ;; + libatomic.so.*) names='libatomic1 libatomic libatomic' ;; + *) return 0 ;; + esac + # shellcheck disable=SC2086 # three words: apt, dnf, pacman + set -- $names + case "$PKG_MANAGER" in + apt-get) + # Since the 64-bit time_t transition some of these carry a t64 suffix, per release and arch. + if apt-cache show "${1}t64" >/dev/null 2>&1; then + printf '%s\n' "${1}t64" + else + printf '%s\n' "$1" + fi + ;; + dnf) printf '%s\n' "$2" ;; + pacman) printf '%s\n' "$3" ;; + esac +} + +# Fills PKG_INSTALL with the command that installs what the staged binary cannot resolve. +find_missing_packages() { + local ldd_out lib pkg + PKG_INSTALL=() + command -v ldd >/dev/null 2>&1 || return 0 + # ldd exits non-zero on a binary it cannot load at all; the text is what is read. + ldd_out="$(ldd "$STAGED_BINARY" 2>&1 || true)" + + if grep -q 'GLIBC_.*not found' <<<"$ldd_out"; then + fail "this build needs a newer glibc than this host has: +$(grep 'GLIBC_.*not found' <<<"$ldd_out" | sed 's/^[[:space:]]*/ /') + Nothing has been installed. Move to a newer release of your distribution, or build from + source: https://github.com/$REPO#build" + fi + + mapfile -t MISSING_LIBS < <(awk '/=> not found/ { print $1 }' <<<"$ldd_out" | sort -u) + [ "${#MISSING_LIBS[@]}" -gt 0 ] || return 0 + + PKG_MANAGER='' + for candidate in apt-get dnf pacman; do + if command -v "$candidate" >/dev/null 2>&1; then + PKG_MANAGER=$candidate + break + fi + done + [ -n "$PKG_MANAGER" ] || + fail "sendspin-cli cannot load on this host: ${MISSING_LIBS[*]} missing, and there is + no apt-get, dnf or pacman here to install them with. Nothing has been installed. Install + the packages that provide those libraries, then re-run this script" + + local packages=() + for lib in "${MISSING_LIBS[@]}"; do + pkg="$(package_for "$lib")" + [ -n "$pkg" ] || + fail "sendspin-cli cannot load on this host: $lib is missing, and this script does + not know which $PKG_MANAGER package provides it. Nothing has been installed. Install it, + then re-run this script" + packages+=("$pkg") + done + + case "$PKG_MANAGER" in + apt-get) PKG_INSTALL=(apt-get install -y "${packages[@]}") ;; + dnf) PKG_INSTALL=(dnf install -y "${packages[@]}") ;; + pacman) PKG_INSTALL=(pacman -S --needed --noconfirm "${packages[@]}") ;; + esac +} + +# The unit a payload older than the shipped user unit gets instead. +write_own_user_unit() { + as_user mkdir -p "$USER_UNIT_DIR" + as_user tee "$USER_UNIT_DIR/$UNIT.service" >/dev/null <: did this script write it. +is_own_unit() { + as_user test -f "$1" && as_user head -n 1 "$1" | grep -Fq "$OWN_UNIT_MARK" +} + +# ctl : this mode's service manager. +ctl() { + if [ "$MODE" = 'user' ]; then + as_user systemctl --user "$@" + else + as_root systemctl "$@" + fi +} + +show_devices() { + step 'What this host can play through' + say '' + say ' (ALSA and PortAudio narrate their own enumeration on stderr -- a "jack server is' + say " not running\" here is those libraries talking, not this player failing.)" + say '' + # Only the output half of the listing. + as_owner "$BINARY" -l 2>&1 | sed -e '/^Input devices/,$d' -e 's/^/ /' +} + +# Restarts the service and says whether it stayed up. +start_and_report() { + local journal + # restart, not start, so an upgrade replaces the running binary. + ctl restart "$UNIT" + + # Type=simple returns immediately; a failing device takes about a second to show. + sleep 2 + if ctl is-active --quiet "$UNIT"; then + step "$UNIT is running" + say " output = $(as_owner sed -n 's/^[[:space:]]*output[[:space:]]*=[[:space:]]*//p' "$CONFIG" | tail -n 1)" + return 0 + fi + + step "$UNIT was started and is NOT running" + say '' + say " $CONFIG names an output, so this is most likely that device failing to" + say ' open. What it said:' say '' - say ' !! NOT VERIFIED. This is your own archive, so there is no published SHA256SUMS to' - say ' !! check it against, and nothing here has established that it is what you think.' - say ' !! Take a release instead of passing SENDSPIN_CLI_TARBALL to get that check.' -else - step 'Finding the release to install' - for tool in curl sha256sum; do + # Empty output counts as failure: users outside systemd-journal get nothing and exit 0. + if [ "$MODE" = 'user' ]; then + journal="$(as_user journalctl --user -u "$UNIT" --no-pager -n 15 2>/dev/null || true)" + else + journal="$(as_root journalctl -u "$UNIT" --no-pager -n 15 2>/dev/null || true)" + fi + if [ -n "$journal" ]; then + printf '%s\n' "$journal" | sed 's/^/ /' + else + say " (nothing readable in the journal; try: $JOURNAL_P -n 50)" + fi + say '' + say " '$OWNER_P$BINARY -l' lists what this host really has. Change 'output' in" + say " $CONFIG, then: ${CTL_P} restart $UNIT" +} + +main() { + # What was asked for + + local version_tag='' assume_yes='no' want_name='' want_output='' want_mode='' + + while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || fail '--version needs a tag, e.g. --version v0.3.0' + version_tag=$2 + shift 2 + ;; + --name) + [ "$#" -ge 2 ] && [ -n "$2" ] || fail '--name needs a name, e.g. --name kitchen' + want_name=$2 + shift 2 + ;; + --output) + [ "$#" -ge 2 ] && [ -n "$2" ] || fail '--output needs a device, e.g. --output hw:1,0' + want_output=$2 + shift 2 + ;; + --user-service) + want_mode='user' + shift + ;; + --system-service) + want_mode='system' + shift + ;; + --yes | -y) + assume_yes='yes' + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + fail "unknown argument '$1' -- see --help" + ;; + esac + done + + case "$want_name$want_output" in + *$'\n'*) fail '--name and --output are one line each' ;; + esac + + # Is this a host this can work on at all + + [ "$(uname -s)" = 'Linux' ] || + fail "Linux only -- this installs a systemd unit. On macOS take the installer .pkg from + https://github.com/$REPO/releases instead" + + for tool in tar sed grep awk; do command -v "$tool" >/dev/null 2>&1 || - fail "'$tool' is not on \$PATH -- install it (apt install curl coreutils) and - re-run, or pass a payload with SENDSPIN_CLI_TARBALL" + fail "'$tool' is not on \$PATH, and this cannot install anything without it" done - if [ -n "$VERSION_TAG" ]; then - TAG=$VERSION_TAG + # Pick the archive by userland, not uname -m: a Pi with a 64-bit kernel can run a 32-bit userland. + MACHINE="$(uname -m)" + + if command -v dpkg >/dev/null 2>&1; then + USERLAND="$(dpkg --print-architecture)" + elif command -v getconf >/dev/null 2>&1; then + case "$(getconf LONG_BIT):$MACHINE" in + 64:x86_64 | 64:amd64) USERLAND='amd64' ;; + 64:aarch64 | 64:arm64) USERLAND='arm64' ;; + 32:aarch64 | 32:arm64 | 32:arm*) USERLAND='armhf' ;; + # Unidentified userlands are refused below, never guessed from the kernel. + *) USERLAND='' ;; + esac else - TAG="$(resolve_latest_tag)" + fail "neither dpkg nor getconf is on \$PATH, and one of them is needed to tell a 32-bit + userland from the 64-bit kernel it may be running under" fi - # The archives are named for the version, which is the tag without its `v`. - ARCHIVE="sendspin-cli-${TAG#v}-$LEG.tar.gz" - readonly TAG ARCHIVE - say " $TAG -> $ARCHIVE" - step 'Downloading and verifying' - BASE="https://github.com/$REPO/releases/download/$TAG" - curl -fSL --progress-bar -o "$WORK_DIR/$ARCHIVE" "$BASE/$ARCHIVE" || - fail "no $ARCHIVE in release $TAG of $REPO. Check the tag exists and carries a build + # Archives are named for the CI leg that built them. + case "$USERLAND" in + amd64 | x86_64) + LEG='linux-x86_64' + ;; + arm64 | aarch64) + LEG='linux-arm64' + ;; + armhf) + # 32-bit ARM: the CPU decides between ARMv6 and ARMv7; anything older is refused. + case "$MACHINE" in + armv6*) + LEG='linux-armv6' + ;; + armv[0-5]* | arm) + fail "'$MACHINE' is older than ARMv6, or names no ARM architecture at all, and + the oldest archive built is linux-armv6 -- an ARM1176, which is a Pi Zero, a Pi Zero W or an + original Pi. Its instructions would be illegal here, so there is nothing to install. Build + from source instead: https://github.com/$REPO#build" + ;; + *) + LEG='linux-armv7' + ;; + esac + ;; + '') + fail "this host's userland could not be identified from a $MACHINE kernel alone, and + guessing at it is how a 32-bit userland ends up with a 64-bit binary. Install dpkg, or + build from source: https://github.com/$REPO#build" + ;; + *) + fail "no release is built for a '$USERLAND' userland -- the archives are linux-x86_64, + linux-arm64, linux-armv7 and linux-armv6. Build from source instead: + https://github.com/$REPO#build" + ;; + esac + readonly MACHINE USERLAND LEG + + # Booted systemd, not merely installed. + HAVE_SYSTEMD='no' + if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + HAVE_SYSTEMD='yes' + fi + readonly HAVE_SYSTEMD + + # /proc/device-tree/model is NUL-terminated. + PI_MODEL='' + if [ -r /proc/device-tree/model ]; then + model="$(tr -d '\0' /dev/null 2>&1 || + fail 'this needs root to install into /usr/local and to drive systemctl, and there is + no sudo here -- re-run it as root' + SUDO='sudo' + fi + readonly SUDO + + # `sudo ` or nothing, so printed commands are not misindented when already root. + readonly SUDO_P="${SUDO:+$SUDO }" + + # Who a user service would run as: the caller, or whoever sudo'd to root. + TARGET_USER='' + if [ "$(id -u)" -ne 0 ]; then + TARGET_USER="$(id -un)" + elif [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != 'root' ]; then + TARGET_USER=$SUDO_USER + fi + TARGET_UID='' + TARGET_HOME='' + USER_P='' + if [ -n "$TARGET_USER" ]; then + TARGET_UID="$(id -u "$TARGET_USER")" + TARGET_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)" + [ "$(id -u)" -ne 0 ] || USER_P="sudo -u $TARGET_USER " + fi + readonly TARGET_USER TARGET_UID TARGET_HOME USER_P + readonly USER_CONFIG="$TARGET_HOME/.config/sendspin-cli/config" + readonly USER_UNIT_DIR="$TARGET_HOME/.config/systemd/user" + + open_prompt_fd + readonly CAN_ASK + + WORK_DIR="$(mktemp -d)" + readonly WORK_DIR + trap 'rm -rf "$WORK_DIR"' EXIT + + say 'sendspin-cli getting started' + say " host: Linux $MACHINE, $USERLAND userland${PI_MODEL:+ ($PI_MODEL)}" + say " release leg: $LEG" + if [ "$HAVE_SYSTEMD" = 'yes' ]; then + say ' systemd: yes' + else + say ' systemd: no -- the binary will be installed but no service set up' + fi + + # The payload + + if [ -n "${SENDSPIN_CLI_TARBALL:-}" ]; then + step 'Using the payload you supplied' + [ -f "$SENDSPIN_CLI_TARBALL" ] || + fail "SENDSPIN_CLI_TARBALL names '$SENDSPIN_CLI_TARBALL', which is not a file" + # Absolute, since tar -C / would resolve a relative path against /. + TARBALL="$(cd "$(dirname "$SENDSPIN_CLI_TARBALL")" && pwd)" + TARBALL="${TARBALL%/}/$(basename "$SENDSPIN_CLI_TARBALL")" + say " $TARBALL" + say '' + say ' !! NOT VERIFIED. This is your own archive, so there is no published SHA256SUMS to' + say ' !! check it against, and nothing here has established that it is what you think.' + say ' !! Take a release instead of passing SENDSPIN_CLI_TARBALL to get that check.' + else + step 'Finding the release to install' + for tool in curl sha256sum; do + command -v "$tool" >/dev/null 2>&1 || + fail "'$tool' is not on \$PATH -- install it (apt install curl coreutils) and + re-run, or pass a payload with SENDSPIN_CLI_TARBALL" + done + + if [ -n "$version_tag" ]; then + TAG=$version_tag + else + TAG="$(resolve_latest_tag)" + fi + # The archives are named for the version, which is the tag without its `v`. + ARCHIVE="sendspin-cli-${TAG#v}-$LEG.tar.gz" + readonly TAG ARCHIVE + say " $TAG -> $ARCHIVE" + + step 'Downloading and verifying' + BASE="https://github.com/$REPO/releases/download/$TAG" + curl -fSL --progress-bar -o "$WORK_DIR/$ARCHIVE" "$BASE/$ARCHIVE" || + fail "no $ARCHIVE in release $TAG of $REPO. Check the tag exists and carries a build for this architecture: https://github.com/$REPO/releases" - curl -fsSL -o "$WORK_DIR/SHA256SUMS" "$BASE/SHA256SUMS" || - fail "$TAG carries $ARCHIVE but no SHA256SUMS, so there is nothing to verify it + curl -fsSL -o "$WORK_DIR/SHA256SUMS" "$BASE/SHA256SUMS" || + fail "$TAG carries $ARCHIVE but no SHA256SUMS, so there is nothing to verify it against. Refusing to install an unverified binary" - # --ignore-missing would pass a SHA256SUMS that omits this archive, so check it is listed. - awk -v want="$ARCHIVE" '$2 == want { found = 1 } END { exit !found }' \ - "$WORK_DIR/SHA256SUMS" || - fail "the SHA256SUMS published with $TAG does not list $ARCHIVE, so there is no + # --ignore-missing would pass a SHA256SUMS that omits this archive, so check it is listed. + awk -v want="$ARCHIVE" '$2 == want { found = 1 } END { exit !found }' \ + "$WORK_DIR/SHA256SUMS" || + fail "the SHA256SUMS published with $TAG does not list $ARCHIVE, so there is no checksum to verify it against. Nothing has been installed" - (cd "$WORK_DIR" && sha256sum --ignore-missing -c SHA256SUMS) || - fail "$ARCHIVE does not match the checksum $TAG publishes for it. Nothing has been + (cd "$WORK_DIR" && sha256sum --ignore-missing -c SHA256SUMS) || + fail "$ARCHIVE does not match the checksum $TAG publishes for it. Nothing has been installed. Download it again; if it fails a second time, say so on the issue tracker rather than installing it anyway" - TARBALL="$WORK_DIR/$ARCHIVE" -fi -readonly TARBALL - -# What this is about to do as root + TARBALL="$WORK_DIR/$ARCHIVE" + fi + readonly TARBALL -# Listed once: `tar | grep -q` dies of SIGPIPE, which pipefail reports as failure. -ARCHIVE_LIST="$(tar -tzf "$TARBALL")" -readonly ARCHIVE_LIST + # Listed once: `tar | grep -q` dies of SIGPIPE, which pipefail reports as failure. + ARCHIVE_LIST="$(tar -tzf "$TARBALL")" + readonly ARCHIVE_LIST -mapfile -t ARCHIVE_ROOTS < <(cut -d/ -f1 <<<"$ARCHIVE_LIST" | sort -u) -[ "${#ARCHIVE_ROOTS[@]}" -eq 1 ] || - fail "'$TARBALL' has ${#ARCHIVE_ROOTS[@]} top-level entries, and a payload has one -- it + mapfile -t ARCHIVE_ROOTS < <(cut -d/ -f1 <<<"$ARCHIVE_LIST" | sort -u) + [ "${#ARCHIVE_ROOTS[@]}" -eq 1 ] || + fail "'$TARBALL' has ${#ARCHIVE_ROOTS[@]} top-level entries, and a payload has one -- it is a staged 'cmake --install' tree, not an archive of loose files" -PAYLOAD_ROOT="${ARCHIVE_ROOTS[0]}" -readonly PAYLOAD_ROOT + PAYLOAD_ROOT="${ARCHIVE_ROOTS[0]}" + readonly PAYLOAD_ROOT -grep -Fqx "$PAYLOAD_ROOT/usr/local/bin/sendspin-cli" <<<"$ARCHIVE_LIST" || - fail "'$TARBALL' holds no $PAYLOAD_ROOT/usr/local/bin/sendspin-cli. A payload is staged + grep -Fqx "$PAYLOAD_ROOT/usr/local/bin/sendspin-cli" <<<"$ARCHIVE_LIST" || + fail "'$TARBALL' holds no $PAYLOAD_ROOT/usr/local/bin/sendspin-cli. A payload is staged with DESTDIR from a build configured for the /usr/local prefix: DESTDIR=/tmp/stage cmake --install build --component sendspin-cli" -# Read off the archive so the printed plan matches what runs. -PAYLOAD_HAS_SYSUSERS='no' -if grep -Fqx "$PAYLOAD_ROOT/usr/local/lib/sysusers.d/sendspin-cli.conf" <<<"$ARCHIVE_LIST"; then - PAYLOAD_HAS_SYSUSERS='yes' -fi -readonly PAYLOAD_HAS_SYSUSERS - -# Only where systemd can use the account. -CREATE_USER='no' -if [ "$HAVE_SYSTEMD" = 'yes' ] && [ "$PAYLOAD_HAS_SYSUSERS" = 'yes' ]; then - CREATE_USER='yes' -fi -readonly CREATE_USER - -# An `output` in /etc/sendspin-cli.conf decides whether the player starts at the end. -# Read as root: the file may be unreadable, and grep's exit 2 would look like "no output". -CONFIG_HAS_OUTPUT='no' -if as_root test -f "$CONFIG" && - as_root grep -Eq '^[[:space:]]*output[[:space:]]*=' "$CONFIG"; then - CONFIG_HAS_OUTPUT='yes' -fi -readonly CONFIG_HAS_OUTPUT - -SEED_CONFIG='no' -if [ ! -e "$CONFIG" ]; then - SEED_CONFIG='yes' -fi -readonly SEED_CONFIG - -step 'These are the commands that need root' -say '' -say " ${SUDO_P}tar -xzf $TARBALL --strip-components=1 -C / $PAYLOAD_ROOT/usr" -if [ "$SEED_CONFIG" = 'yes' ]; then - say " ${SUDO_P}cp $CONFIG_EXAMPLE $CONFIG" -fi -if [ "$CREATE_USER" = 'yes' ]; then - say " ${SUDO_P}systemd-sysusers" -fi -if [ "$HAVE_SYSTEMD" = 'yes' ]; then - say " ${SUDO_P}systemctl daemon-reload" - say " ${SUDO_P}systemctl enable $UNIT" - if [ "$CONFIG_HAS_OUTPUT" = 'yes' ]; then - say " ${SUDO_P}systemctl restart $UNIT" + # Read off the archive so the printed plan matches what runs. + PAYLOAD_HAS_SYSUSERS='no' + if grep -Fqx "$PAYLOAD_ROOT/usr/local/lib/sysusers.d/sendspin-cli.conf" <<<"$ARCHIVE_LIST"; then + PAYLOAD_HAS_SYSUSERS='yes' + fi + readonly PAYLOAD_HAS_SYSUSERS + + # An unprivileged copy, so missing libraries are known before anything is installed. + mkdir "$WORK_DIR/staged" + tar -xzf "$TARBALL" --strip-components=1 -C "$WORK_DIR/staged" "$PAYLOAD_ROOT/usr" + readonly STAGED_BINARY="$WORK_DIR/staged$BINARY" + find_missing_packages + readonly PKG_INSTALL + + # Which service, if any + + # What an earlier run left enabled decides an upgrade's mode. + SYSTEM_UNIT_ON='no' + USER_UNIT_ON='no' + if [ "$HAVE_SYSTEMD" = 'yes' ]; then + if systemctl is-enabled --quiet "$UNIT" 2>/dev/null || + systemctl is-active --quiet "$UNIT" 2>/dev/null; then + SYSTEM_UNIT_ON='yes' + fi + if [ -n "$TARGET_USER" ] && + { as_user systemctl --user is-enabled --quiet "$UNIT" 2>/dev/null || + as_user systemctl --user is-active --quiet "$UNIT" 2>/dev/null; }; then + USER_UNIT_ON='yes' + fi + fi + readonly SYSTEM_UNIT_ON USER_UNIT_ON + + MODE=$want_mode + if [ "$HAVE_SYSTEMD" != 'yes' ]; then + [ "$MODE" != 'user' ] || + fail '--user-service needs a running systemd, and there is none on this host' + MODE='system' + elif [ -z "$MODE" ]; then + if [ "$USER_UNIT_ON" = 'yes' ] && [ "$SYSTEM_UNIT_ON" != 'yes' ]; then + MODE='user' + elif [ "$SYSTEM_UNIT_ON" = 'yes' ] || [ -z "$TARGET_USER" ] || + [ "$assume_yes" = 'yes' ] || [ "$CAN_ASK" != 'yes' ]; then + MODE='system' + else + step 'How should the player run?' + say '' + say " 1) As a system service under its own unprivileged '$SERVICE_USER' account." + say ' Plays straight to a sound card you name. Best for a headless box or a Pi.' + say " 2) As you ($TARGET_USER), as a user service. Follows your session's PipeWire or" + say " PulseAudio, so 'default' plays wherever your desktop does." + say '' + ask 'Choose 1 or 2 [1]: ' 1 + case "$REPLY" in + 1) MODE='system' ;; + 2) MODE='user' ;; + *) fail "'$REPLY' is neither 1 nor 2; nothing was installed" ;; + esac + fi + fi + if [ "$MODE" = 'user' ] && [ -z "$TARGET_USER" ]; then + fail '--user-service needs a user to run as, and this is root with no sudo caller. + Run it as that user instead' + fi + readonly MODE + + if [ "$MODE" = 'user' ]; then + CONFIG=$USER_CONFIG + OWNER_P=$USER_P + CTL_P="${USER_P}systemctl --user" + JOURNAL_P="${USER_P}journalctl --user -u $UNIT" else - say " ${SUDO_P}$BINARY -l" - fi -fi -say '' -say "Naming '$PAYLOAD_ROOT/usr' is what keeps the archive's BUILD-INFO.txt out of /." -if [ "$CREATE_USER" = 'yes' ]; then - say "'systemd-sysusers' creates the unprivileged '$SERVICE_USER' account the unit runs as," - say "reading the declaration the line above it installs at $SYSUSERS_FILE." - say 'It adds nothing else and is idempotent. Without it the unit does not start at all.' -fi -if [ "$SEED_CONFIG" = 'yes' ]; then - say "There is no $CONFIG yet, so the installed example is copied there for you to edit." - say 'Every line in it is commented out, so it chooses nothing on its own.' -fi -if [ "$HAVE_SYSTEMD" = 'yes' ] && [ "$CONFIG_HAS_OUTPUT" != 'yes' ]; then - say "No 'output' is set in $CONFIG, so the unit is enabled but NOT started: under" - say "systemd there is no PipeWire for ALSA's 'default' to follow, and starting it now" - say 'would usually mean a player failing and being retried every five seconds. The' - say 'device list is printed instead, and starting it is the last thing you do.' -fi -say 'Everything else this script does is reading.' - -if [ "$ASSUME_YES" != 'yes' ]; then - [ -t 0 ] || - fail 'stdin is not a terminal, so there is nobody to confirm those commands with. - Re-run with --yes if you have read them and want them run' - printf '\nRun them? [y/N] ' - # `|| fail`: set -e would otherwise exit silently on a closed stdin. - read -r answer || fail 'stdin closed before an answer arrived; nothing was installed' - case "$answer" in - y | Y | yes | YES) ;; - *) fail 'nothing was installed' ;; - esac -fi + CONFIG=$SYSTEM_CONFIG + OWNER_P=$SUDO_P + CTL_P="${SUDO_P}systemctl" + JOURNAL_P="${SUDO_P}journalctl -u $UNIT" + fi + readonly CONFIG OWNER_P CTL_P JOURNAL_P -# Install + # Only the system unit runs as the account. + CREATE_USER='no' + if [ "$MODE" = 'system' ] && [ "$HAVE_SYSTEMD" = 'yes' ] && [ "$PAYLOAD_HAS_SYSUSERS" = 'yes' ]; then + CREATE_USER='yes' + fi + readonly CREATE_USER -step 'Installing' -# Idempotent, so re-running upgrades. -as_root tar -xzf "$TARBALL" --strip-components=1 -C / "$PAYLOAD_ROOT/usr" + CONFIG_EXISTS='no' + if as_owner test -e "$CONFIG"; then + CONFIG_EXISTS='yes' + fi + readonly CONFIG_EXISTS + + # flag, ask, or none -- a key the config already sets is never asked for or replaced. + NAME_FROM='none' + OUTPUT_FROM='none' + CONFIG_HAS_OUTPUT='no' + if [ "$HAVE_SYSTEMD" = 'yes' ]; then + if config_sets output; then + CONFIG_HAS_OUTPUT='yes' + [ -z "$want_output" ] || say " note: $CONFIG already sets an output, so --output is ignored" + elif [ -n "$want_output" ]; then + OUTPUT_FROM='flag' + elif [ "$assume_yes" != 'yes' ]; then + OUTPUT_FROM='ask' + fi + if config_sets name; then + [ -z "$want_name" ] || say " note: $CONFIG already sets a name, so --name is ignored" + elif [ -n "$want_name" ]; then + NAME_FROM='flag' + # A host with an output is configured: upgrading it asks nothing. + elif [ "$OUTPUT_FROM" = 'ask' ]; then + NAME_FROM='ask' + fi + fi + readonly NAME_FROM OUTPUT_FROM CONFIG_HAS_OUTPUT -# A missing unit means a macOS archive; say so before the loader does. -[ -f "$UNIT_FILE" ] || - fail "the payload installed no unit at $UNIT_FILE -- a macOS archive on a Linux host would - look exactly like this. Take the $LEG one" + WRITE_CONFIG='no' + if [ "$CONFIG_EXISTS" != 'yes' ] || [ "$NAME_FROM" != 'none' ] || [ "$OUTPUT_FROM" != 'none' ]; then + WRITE_CONFIG='yes' + fi + readonly WRITE_CONFIG + readonly NEW_CONFIG="$WORK_DIR/sendspin-cli.conf" -say " $BINARY" -"$BINARY" --version | sed 's/^/ /' + # install for a new file so its mode is not the caller's umask; cp keeps an existing one's. + if [ "$CONFIG_EXISTS" = 'yes' ]; then + WRITE_SYSTEM_CONFIG=(cp "$NEW_CONFIG" "$SYSTEM_CONFIG") + else + WRITE_SYSTEM_CONFIG=(install -m 0644 "$NEW_CONFIG" "$SYSTEM_CONFIG") + fi + readonly WRITE_SYSTEM_CONFIG + + NEED_LINGER='no' + NEED_AUDIO='no' + if [ "$MODE" = 'user' ]; then + [ -e "/var/lib/systemd/linger/$TARGET_USER" ] || NEED_LINGER='yes' + if getent group audio >/dev/null; then + case " $(id -nG "$TARGET_USER") " in + *' audio '*) ;; + *) NEED_AUDIO='yes' ;; + esac + fi + fi + readonly NEED_LINGER NEED_AUDIO -if [ "$SEED_CONFIG" = 'yes' ]; then - as_root cp "$CONFIG_EXAMPLE" "$CONFIG" - say " $CONFIG (from the installed example; everything in it is commented out)" -fi + # What this is about to do as root -if [ "$HAVE_SYSTEMD" != 'yes' ]; then - step 'Not touching a service' - say ' systemd is not running here, so there is nothing to enable. The binary is' - say ' installed and runs in the foreground:' + step 'These are the commands that need root' say '' - say " $BINARY -l # what this host can play through" - say " $BINARY -o hw:1,0 -n \"\$(hostname)\"" - exit 0 -fi + if [ "${#PKG_INSTALL[@]}" -gt 0 ]; then + say " ${SUDO_P}${PKG_INSTALL[*]}" + fi + say " ${SUDO_P}tar -xzf $TARBALL --strip-components=1 -C / $PAYLOAD_ROOT/usr" + if [ "$HAVE_SYSTEMD" != 'yes' ]; then + if [ "$CONFIG_EXISTS" != 'yes' ]; then + say " ${SUDO_P}cp $CONFIG_EXAMPLE $SYSTEM_CONFIG" + fi + elif [ "$MODE" = 'system' ]; then + if [ "$CREATE_USER" = 'yes' ]; then + say " ${SUDO_P}systemd-sysusers" + fi + say " ${SUDO_P}systemctl daemon-reload" + say " ${SUDO_P}systemctl enable $UNIT" + if [ "$CONFIG_HAS_OUTPUT" != 'yes' ] && [ "$OUTPUT_FROM" != 'flag' ]; then + say " ${SUDO_P}$BINARY -l" + fi + if [ "$WRITE_CONFIG" = 'yes' ]; then + say " ${SUDO_P}${WRITE_SYSTEM_CONFIG[*]}" + fi + if [ "$CONFIG_HAS_OUTPUT" = 'yes' ] || [ "$OUTPUT_FROM" != 'none' ]; then + say " ${SUDO_P}systemctl restart $UNIT" + fi + else + say " ${SUDO_P}systemctl daemon-reload" + if [ "$SYSTEM_UNIT_ON" = 'yes' ]; then + say " ${SUDO_P}systemctl disable --now $UNIT" + fi + if [ "$NEED_LINGER" = 'yes' ]; then + say " ${SUDO_P}loginctl enable-linger $TARGET_USER" + fi + if [ "$NEED_AUDIO" = 'yes' ]; then + say " ${SUDO_P}usermod -aG audio $TARGET_USER" + fi + fi + say '' + if [ "${#PKG_INSTALL[@]}" -gt 0 ]; then + say "The first line installs the libraries sendspin-cli links and this host lacks:" + say "${MISSING_LIBS[*]}." + fi + say "Naming '$PAYLOAD_ROOT/usr' is what keeps the archive's BUILD-INFO.txt out of /." + if [ "$CREATE_USER" = 'yes' ]; then + say "'systemd-sysusers' creates the unprivileged '$SERVICE_USER' account the unit runs as," + say "from the declaration installed at $SYSUSERS_FILE." + fi + if [ "$HAVE_SYSTEMD" = 'yes' ] && [ "$MODE" = 'system' ]; then + if [ "$WRITE_CONFIG" = 'yes' ]; then + if [ "$CONFIG_EXISTS" = 'yes' ]; then + say "$NEW_CONFIG will be your $SYSTEM_CONFIG with" + else + say "$NEW_CONFIG will be the installed example config with" + fi + case "$NAME_FROM:$OUTPUT_FROM" in + ask:* | *:ask) + say 'the name and the output device you are asked for next filled in. The lines it' + say 'sets are printed before it is copied.' + ;; + none:none) say 'nothing changed: every line in it is commented out.' ;; + *) say 'the --name and --output you passed filled in.' ;; + esac + fi + if [ "$CONFIG_HAS_OUTPUT" != 'yes' ] && [ "$OUTPUT_FROM" = 'none' ]; then + say "No 'output' is set in $SYSTEM_CONFIG and none was passed, so the unit is enabled" + say "but NOT started: a system unit has no session for ALSA's 'default' to follow, and" + say 'it would fail and be retried every five seconds. The device list is printed instead.' + fi + if [ "$USER_UNIT_ON" = 'yes' ]; then + say "Your user service is stopped and disabled first, since both listen on one port:" + say " ${USER_P}systemctl --user disable --now $UNIT" + fi + elif [ "$MODE" = 'user' ]; then + say "The rest runs as $TARGET_USER, not root: the config at $CONFIG," + say "and '${USER_P}systemctl --user enable $UNIT' and 'restart'." + if [ "$SYSTEM_UNIT_ON" = 'yes' ]; then + say 'The system service is stopped and disabled, since both listen on one port.' + fi + if [ "$NEED_LINGER" = 'yes' ]; then + say "'enable-linger' keeps your user services running while you are logged out." + fi + if [ "$NEED_AUDIO" = 'yes' ]; then + say "The audio group is what opens a sound card directly (hw:...); it applies from your" + say 'next login. PipeWire and PulseAudio outputs do not need it.' + fi + fi + say 'Everything else this script does is reading.' + + if [ "$assume_yes" != 'yes' ]; then + [ "$CAN_ASK" = 'yes' ] || + fail 'there is no terminal to confirm those commands on. Re-run with --yes if you + have read them and want them run; nothing was installed' + say '' + if ! confirm 'Run them?'; then + [ "${#PKG_INSTALL[@]}" -eq 0 ] || say "sendspin-cli will not load here without: ${MISSING_LIBS[*]}" + fail 'nothing was installed' + fi + fi + + # Install + + if [ "${#PKG_INSTALL[@]}" -gt 0 ]; then + step 'Installing the missing libraries' + as_root "${PKG_INSTALL[@]}" + fi -step 'Setting the service up' + step 'Installing' + # Idempotent, so re-running upgrades. + as_root tar -xzf "$TARBALL" --strip-components=1 -C / "$PAYLOAD_ROOT/usr" -# Before daemon-reload: the unit's User= needs the account. -if [ "$CREATE_USER" = 'yes' ]; then - command -v systemd-sysusers >/dev/null 2>&1 || - fail "the unit runs as '$SERVICE_USER' and 'systemd-sysusers' is not on \$PATH to + # A missing unit means a macOS archive; say so before the loader does. + [ -f "$UNIT_FILE" ] || + fail "the payload installed no unit at $UNIT_FILE -- a macOS archive on a Linux host would + look exactly like this. Take the $LEG one" + + say " $BINARY" + "$BINARY" --version | sed 's/^/ /' + + if [ "$HAVE_SYSTEMD" != 'yes' ]; then + if [ "$CONFIG_EXISTS" != 'yes' ]; then + as_root cp "$CONFIG_EXAMPLE" "$SYSTEM_CONFIG" + say " $SYSTEM_CONFIG (from the installed example; everything in it is commented out)" + fi + step 'Not touching a service' + say ' systemd is not running here, so there is nothing to enable. The binary is' + say ' installed and runs in the foreground:' + say '' + say " $BINARY -l # what this host can play through" + say " $BINARY -o hw:1,0 -n \"\$(hostname)\"" + exit 0 + fi + + step 'Setting the service up' + + if [ "$MODE" = 'system' ]; then + # Before daemon-reload: the unit's User= needs the account. + if [ "$CREATE_USER" = 'yes' ]; then + command -v systemd-sysusers >/dev/null 2>&1 || + fail "the unit runs as '$SERVICE_USER' and 'systemd-sysusers' is not on \$PATH to create the account from $SYSUSERS_FILE. Create it with your own tooling instead -- '${SUDO_P}useradd --system --no-create-home -G audio $SERVICE_USER' is the equivalent README documents -- then re-run this script" - as_root systemd-sysusers + as_root systemd-sysusers - # sysusers exits 0 even if it read nothing, so check the account exists. - getent passwd "$SERVICE_USER" >/dev/null || - fail "'systemd-sysusers' ran and there is still no '$SERVICE_USER' account, so the unit + # sysusers exits 0 even if it read nothing, so check the account exists. + getent passwd "$SERVICE_USER" >/dev/null || + fail "'systemd-sysusers' ran and there is still no '$SERVICE_USER' account, so the unit would report 217/USER rather than starting. $SYSUSERS_FILE is what it should have read" - say " user: $SERVICE_USER (unprivileged; the unit's User=)" -else - # No fragment in the payload: make sure the installed unit names no User= we cannot create. - unit_user="$(sed -n 's/^[[:space:]]*User=[[:space:]]*//p' "$UNIT_FILE" | tail -n 1)" - if [ -n "$unit_user" ] && ! getent passwd "$unit_user" >/dev/null; then - fail "$UNIT_FILE runs as '$unit_user' and no such account exists, while this payload + say " user: $SERVICE_USER (unprivileged; the unit's User=)" + else + # No fragment in the payload: make sure the installed unit names no User= we cannot create. + unit_user="$(sed -n 's/^[[:space:]]*User=[[:space:]]*//p' "$UNIT_FILE" | tail -n 1)" + if [ -n "$unit_user" ] && ! getent passwd "$unit_user" >/dev/null; then + fail "$UNIT_FILE runs as '$unit_user' and no such account exists, while this payload carried no sysusers declaration to create one from. Create it -- '${SUDO_P}useradd --system --no-create-home -G audio $unit_user' is the equivalent README documents -- then re-run this script" - fi -fi + fi + fi -as_root systemctl daemon-reload -as_root systemctl enable "$UNIT" -say " enabled: $UNIT starts on boot" + as_root systemctl daemon-reload + if [ "$USER_UNIT_ON" = 'yes' ]; then + as_user systemctl --user disable --now "$UNIT" + say " stopped: $TARGET_USER's user service, which would hold the same port" + fi + as_root systemctl enable "$UNIT" + say " enabled: $UNIT starts on boot" + else + as_root systemctl daemon-reload + if [ "$SYSTEM_UNIT_ON" = 'yes' ]; then + as_root systemctl disable --now "$UNIT" + say ' stopped: the system service, which would hold the same port' + fi + if [ "$NEED_LINGER" = 'yes' ]; then + as_root loginctl enable-linger "$TARGET_USER" + say " linger: $TARGET_USER's services keep running after logout" + fi + if [ "$NEED_AUDIO" = 'yes' ]; then + as_root usermod -aG audio "$TARGET_USER" + say " audio: $TARGET_USER joins the audio group at the next login" + fi -# Start it, or say what is still owed + # Linger starts the user manager; give it a moment to answer. + tries=0 + until as_user systemctl --user daemon-reload 2>/dev/null; do + tries=$((tries + 1)) + [ "$tries" -lt 10 ] || + fail "$TARGET_USER's systemd user manager is not answering, so there is nothing to + enable the service in. Log in as $TARGET_USER once and re-run this script" + sleep 1 + done + + own_unit="$USER_UNIT_DIR/$UNIT.service" + if [ -f "$USER_UNIT_FILE" ]; then + # Ours would shadow the unit the payload now ships. + if is_own_unit "$own_unit"; then + as_user rm -f "$own_unit" + elif as_user test -e "$own_unit"; then + say " note: $own_unit is yours and takes precedence over the installed unit" + fi + elif is_own_unit "$own_unit" || ! as_user test -e "$own_unit"; then + write_own_user_unit + say " unit: $own_unit (this release ships none of its own)" + fi + as_user systemctl --user daemon-reload + as_user systemctl --user enable "$UNIT" + say " enabled: $UNIT starts with $TARGET_USER's user manager, at boot" + fi -if [ "$CONFIG_HAS_OUTPUT" = 'yes' ]; then - # restart, not start, so an upgrade replaces the running binary. - as_root systemctl restart "$UNIT" + # Name and output + + NAME='' + OUTPUT='' + case "$NAME_FROM" in + flag) NAME=$want_name ;; + ask) + step 'Naming the player' + say '' + say ' This is what a Sendspin server shows it as.' + say '' + ask " Name [$(hostname)]: " "$(hostname)" + NAME=$REPLY + ;; + esac + case "$OUTPUT_FROM" in + flag) OUTPUT=$want_output ;; + ask) + show_devices + say '' + if [ "$MODE" = 'user' ]; then + say " Type the name of the device to play through. 'default' follows your session." + say '' + ask ' Output [default]: ' default + else + say " Type the name of the device to play through, e.g. hw:1,0. 'default' usually" + say ' fails under a system service. Leave it empty to decide later.' + say '' + ask ' Output: ' + fi + OUTPUT=$REPLY + ;; + esac + readonly NAME OUTPUT - # Type=simple returns immediately; a failing device takes about a second to show. - sleep 2 - if systemctl is-active --quiet "$UNIT"; then - step "$UNIT is running" - say " output = $(sed -n 's/^[[:space:]]*output[[:space:]]*=[[:space:]]*//p' "$CONFIG" | tail -n 1)" - else - step "$UNIT was started and is NOT running" - say '' - say " $CONFIG names an output, so this is that device failing to open rather" - say " than the usual first-install case. What it said:" - say '' - # Empty output counts as failure: users outside systemd-journal get nothing and exit 0. - journal="$(as_root journalctl -u "$UNIT" --no-pager -n 15 2>/dev/null || true)" - if [ -n "$journal" ]; then - printf '%s\n' "$journal" | sed 's/^/ /' + if [ "$WRITE_CONFIG" = 'yes' ]; then + if [ "$CONFIG_EXISTS" = 'yes' ]; then + as_owner cat "$CONFIG" >"$NEW_CONFIG" else - say " (nothing readable in the journal; try: ${SUDO_P}journalctl -u $UNIT -n 50)" + cp "$CONFIG_EXAMPLE" "$NEW_CONFIG" fi + step "Writing $CONFIG" say '' - say " '${SUDO_P}$BINARY -l' lists what this host really has." + if [ -n "$NAME" ]; then + set_key "$NEW_CONFIG" name "$NAME" + say " name = $NAME" + fi + if [ -n "$OUTPUT" ]; then + set_key "$NEW_CONFIG" output "$OUTPUT" + say " output = $OUTPUT" + fi + if [ -z "$NAME$OUTPUT" ]; then + say ' (the annotated example; everything in it is commented out)' + fi + if [ "$MODE" = 'user' ]; then + as_user mkdir -p "$(dirname "$CONFIG")" + as_user tee "$CONFIG" <"$NEW_CONFIG" >/dev/null + else + say '' + say " ${SUDO_P}${WRITE_SYSTEM_CONFIG[*]}" + as_root "${WRITE_SYSTEM_CONFIG[@]}" + fi fi -else - step 'What this host can play through' - say '' - say ' (ALSA and PortAudio narrate their own enumeration on stderr -- a "jack server is' - say " not running\" here is those libraries talking, not this player failing.)" - say '' - as_root "$BINARY" -l 2>&1 | sed 's/^/ /' -fi -# What to do next + # Start it, or say what is still owed + + STARTED='no' + if [ "$CONFIG_HAS_OUTPUT" = 'yes' ] || [ -n "$OUTPUT" ]; then + STARTED='yes' + start_and_report + elif [ "$OUTPUT_FROM" = 'none' ]; then + show_devices + fi + readonly STARTED -step 'Next' -if [ "$CONFIG_HAS_OUTPUT" != 'yes' ]; then + step 'Next' say '' - say " 1. Pick a device from that list and put it in $CONFIG. Keys there are the" - say ' long flag names without their dashes, and the file is annotated:' + if [ "$STARTED" != 'yes' ]; then + say " 1. Pick a device from that list and put it in $CONFIG. Keys there are the" + say ' long flag names without their dashes, and the file is annotated:' + say '' + say " ${OWNER_P}nano $CONFIG # output = hw:1,0" + say '' + say ' 2. Start it:' + say '' + say " $CTL_P start $UNIT" + say '' + say ' 3. Then pair it, as below.' + say '' + fi + say ' Pair it with your Sendspin server. This prints the token to paste into the server:' + say '' + if [ "$MODE" = 'user' ]; then + say " ${USER_P}$BINARY pair-token" + else + say " ${SUDO_P}$BINARY pair-token --control-socket $CONTROL_SOCKET" + fi say '' - say " ${SUDO_P}nano $CONFIG # output = hw:1,0" + say ' The player advertises itself over mDNS, so the server finds it by itself; pairing is' + say " what lets it play. To dial a server instead, set 'server' in $CONFIG." say '' - say ' 2. Start it:' + say ' Watch it:' say '' - say " ${SUDO_P}systemctl start $UNIT" - say " systemctl status $UNIT" + say " $JOURNAL_P -f" say '' - say ' 3. Then:' -else + say ' Ask it what it is doing:' say '' -fi -say '' -say ' Watch it:' -say '' -say " journalctl -u $UNIT -f" -say '' -say ' Ask it what it is doing:' -say '' -say " ${SUDO_P}$BINARY status --control-socket $CONTROL_SOCKET" -say '' -say " The socket is mode 0600 and belongs to the '$SERVICE_USER' account the service" -say ' runs as, so reading it means being root -- which is not subject to the mode.' -say " Put 'control-socket = $CONTROL_SOCKET' in the config to stop" -say ' repeating the flag.' -say '' -say ' Nothing else. The player advertises itself over mDNS and waits for a Sendspin' -say ' server to find it, so it should appear in your controller once it is playing' -say " through a device. To dial a server instead, set 'server' in the config." - -if [ -n "$PI_MODEL" ]; then + if [ "$MODE" = 'user' ]; then + say " ${USER_P}$BINARY status" + else + say " ${SUDO_P}$BINARY status --control-socket $CONTROL_SOCKET" + say '' + say " The socket is mode 0600 and belongs to the '$SERVICE_USER' account, hence the sudo." + say " 'control-socket = $CONTROL_SOCKET' in the config saves repeating the flag." + fi + + if [ -n "$PI_MODEL" ]; then + say '' + say " On this $PI_MODEL:" + say '' + say ' - The headphone jack and each HDMI output are separate cards. The device list' + say " names them; 'output = hw:X,Y' picks one, with X and Y the numbers it printed." + if [ "$MODE" = 'system' ]; then + say " - 'output = default' is what usually leaves a system unit silent, because there" + say ' is no user session for it to follow. Name a card.' + say " - The service runs as the unprivileged '$SERVICE_USER' account, which is in the" + say ' audio group already, so it reaches /dev/snd with nothing for you to arrange.' + fi + fi + say '' - say " On this $PI_MODEL:" + say " To remove all of this again: https://github.com/$REPO#uninstall" say '' - say ' - The headphone jack and each HDMI output are separate cards. The list above' - say " names them; 'output = hw:X,Y' picks one, with X and Y the numbers it printed." - say " - 'output = default' is what usually leaves a system unit silent, because there" - say ' is no user session for it to follow. Name a card.' - say " - The service runs as the unprivileged '$SERVICE_USER' account, which the" - say ' declaration that created it also put in the audio group -- so it reaches /dev/snd' - say ' (root:audio 0660) with nothing for you to arrange.' - say ' - To run it from your own shell rather than as a service, put yourself in that' - say " group once and log back in: ${SUDO_P}usermod -aG audio \"\$USER\"" -fi - -say '' +} + +# Braces, so a download cut short anywhere is a syntax error and runs nothing. +{ main "$@"; } From ced72a30476abaa4a5ff778ea3c1cbc08cdf8181 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 8 Oct 2026 07:39:32 -0500 Subject: [PATCH 3/6] Add scripts/uninstall_linux.sh --- scripts/uninstall_linux.sh | 324 +++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100755 scripts/uninstall_linux.sh diff --git a/scripts/uninstall_linux.sh b/scripts/uninstall_linux.sh new file mode 100755 index 0000000..7e83c1c --- /dev/null +++ b/scripts/uninstall_linux.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +# +# Copyright 2026 sendspin-cpp-cli Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Removes what get_started_linux.sh installed: the service, in either mode, and the payload. +# Config, remembered state and the service account are kept unless --purge. +# Every command is printed first and confirmed, or pre-authorised with --yes. +# +# Usage: uninstall_linux.sh [--purge] [--yes] +# curl -fsSL | bash -s -- [the same flags] +# +# --help describes each flag. + +set -euo pipefail + +readonly UNIT='sendspin-cli' +readonly UNIT_FILE='/usr/local/lib/systemd/system/sendspin-cli.service' +readonly USER_UNIT_FILE='/usr/local/lib/systemd/user/sendspin-cli.service' +readonly SYSUSERS_FILE='/usr/local/lib/sysusers.d/sendspin-cli.conf' +readonly SERVICE_USER='sendspin-cli' +readonly BINARY='/usr/local/bin/sendspin-cli' +readonly DOC_DIR='/usr/local/share/doc/sendspin-cli' +readonly SYSTEM_CONFIG='/etc/sendspin-cli.conf' +readonly SYSTEM_STATE='/var/lib/sendspin-cli' +# Keep in step with get_started_linux.sh: only a user unit carrying it is removed. +readonly OWN_UNIT_MARK='# Written by get_started_linux.sh' + +fail() { + printf 'uninstall_linux: FAIL: %s\n' "$*" >&2 + exit 1 +} + +say() { + printf '%s\n' "$*" +} + +step() { + printf '\n==> %s\n' "$*" +} + +usage() { + cat <<'USAGE' +Usage: uninstall_linux.sh [--purge] [--yes] + + --purge also remove what is otherwise kept: /etc/sendspin-cli.conf and + ~/.config/sendspin-cli, the remembered state (pairings, volume) in + /var/lib/sendspin-cli and ~/.local/state/sendspin-cli, and the 'sendspin-cli' + account + --yes do not ask anything: run the commands as printed, and keep config, state and + the account unless --purge says otherwise. Required when there is no terminal + to ask on + +Piped, flags follow `bash -s --`: + + curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/uninstall_linux.sh | bash -s -- --yes + +Packages the installer added with apt, dnf or pacman are left installed. +USAGE +} + +as_root() { + if [ -n "$SUDO" ]; then + "$SUDO" "$@" + else + "$@" + fi +} + +# Runs as the account a user service belongs to, with its user manager reachable. +as_user() { + if [ "$(id -u)" -eq 0 ]; then + sudo -u "$TARGET_USER" env "XDG_RUNTIME_DIR=/run/user/$TARGET_UID" "$@" + else + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$TARGET_UID}" "$@" + fi +} + +# Answers come from fd 3: stdin, or the terminal when the script itself arrived on stdin. +open_prompt_fd() { + CAN_ASK='no' + if [ -t 0 ]; then + exec 3<&0 + CAN_ASK='yes' + elif { exec 3/dev/null; then + CAN_ASK='yes' + fi +} + +confirm() { + printf '%s [y/N] ' "$1" + # `|| fail`: set -e would otherwise exit silently on a closed terminal. + read -r -u 3 REPLY || fail 'the terminal closed before an answer arrived' + case "$REPLY" in + y | Y | yes | YES) return 0 ;; + *) return 1 ;; + esac +} + +# Queues a command for root, or for the user, to be printed and then run in order. +PLAN=() +plan_root() { + PLAN+=("root$(printf ' %q' "$@")") + NEED_ROOT='yes' +} +plan_user() { + PLAN+=("user$(printf ' %q' "$@")") +} + +main() { + local purge='ask' assume_yes='no' + + while [ "$#" -gt 0 ]; do + case "$1" in + --purge) + purge='yes' + shift + ;; + --yes | -y) + assume_yes='yes' + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + fail "unknown argument '$1' -- see --help" + ;; + esac + done + + [ "$(uname -s)" = 'Linux' ] || fail 'Linux only -- this removes a systemd service' + + # Booted systemd, not merely installed. + HAVE_SYSTEMD='no' + if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + HAVE_SYSTEMD='yes' + fi + readonly HAVE_SYSTEMD + + # Whose user service and config: the caller, or whoever sudo'd to root. + TARGET_USER='' + if [ "$(id -u)" -ne 0 ]; then + TARGET_USER="$(id -un)" + elif [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != 'root' ]; then + TARGET_USER=$SUDO_USER + fi + TARGET_UID='' + TARGET_HOME='' + USER_P='' + if [ -n "$TARGET_USER" ]; then + TARGET_UID="$(id -u "$TARGET_USER")" + TARGET_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)" + [ "$(id -u)" -ne 0 ] || USER_P="sudo -u $TARGET_USER " + fi + readonly TARGET_USER TARGET_UID TARGET_HOME USER_P + + SUDO='' + if [ "$(id -u)" -ne 0 ]; then + SUDO='sudo' + fi + readonly SUDO + readonly SUDO_P="${SUDO:+$SUDO }" + + open_prompt_fd + readonly CAN_ASK + + # What is here to keep or remove + local kept=() + [ ! -e "$SYSTEM_CONFIG" ] || kept+=("$SYSTEM_CONFIG") + [ ! -e "$SYSTEM_STATE" ] || kept+=("$SYSTEM_STATE") + if [ -n "$TARGET_USER" ]; then + for path in "$TARGET_HOME/.config/sendspin-cli" "$TARGET_HOME/.local/state/sendspin-cli"; do + ! as_user test -e "$path" || kept+=("$path") + done + fi + local have_account='no' + if getent passwd "$SERVICE_USER" >/dev/null; then + have_account='yes' + fi + + if [ "$purge" = 'ask' ]; then + purge='no' + if [ "$assume_yes" != 'yes' ] && [ "$CAN_ASK" = 'yes' ] && + { [ "${#kept[@]}" -gt 0 ] || [ "$have_account" = 'yes' ]; }; then + step 'Your config and what the player remembered' + say '' + for path in "${kept[@]}"; do + say " $path" + done + [ "$have_account" != 'yes' ] || say " the '$SERVICE_USER' account" + say '' + say ' These are kept by default, so installing again picks up where this left off.' + say '' + ! confirm ' Remove them too?' || purge='yes' + fi + fi + + NEED_ROOT='no' + + if [ "$HAVE_SYSTEMD" = 'yes' ]; then + if systemctl is-enabled --quiet "$UNIT" 2>/dev/null || + systemctl is-active --quiet "$UNIT" 2>/dev/null; then + plan_root systemctl disable --now "$UNIT" + fi + if [ -n "$TARGET_USER" ] && + { as_user systemctl --user is-enabled --quiet "$UNIT" 2>/dev/null || + as_user systemctl --user is-active --quiet "$UNIT" 2>/dev/null; }; then + plan_user systemctl --user disable --now "$UNIT" + fi + fi + + local removed_units='no' removed_user_unit='no' + for path in "$BINARY" "$UNIT_FILE" "$USER_UNIT_FILE" "$SYSUSERS_FILE"; do + if [ -e "$path" ]; then + plan_root rm -f "$path" + removed_units='yes' + fi + done + [ ! -e "$DOC_DIR" ] || plan_root rm -rf "$DOC_DIR" + + local own_unit="$TARGET_HOME/.config/systemd/user/$UNIT.service" foreign_unit='no' + if [ -n "$TARGET_USER" ] && as_user test -f "$own_unit"; then + if as_user head -n 1 "$own_unit" | grep -Fq "$OWN_UNIT_MARK"; then + plan_user rm -f "$own_unit" + removed_user_unit='yes' + else + foreign_unit='yes' + fi + fi + + if [ "$purge" = 'yes' ]; then + for path in "${kept[@]}"; do + case "$path" in + "$TARGET_HOME"/*) plan_user rm -rf "$path" ;; + *) plan_root rm -rf "$path" ;; + esac + done + [ "$have_account" != 'yes' ] || plan_root userdel "$SERVICE_USER" + fi + + if [ "$HAVE_SYSTEMD" = 'yes' ]; then + [ "$removed_units" != 'yes' ] || plan_root systemctl daemon-reload + # Only where a user manager is running to be told. + if { [ "$removed_units" = 'yes' ] || [ "$removed_user_unit" = 'yes' ]; } && + [ -n "$TARGET_USER" ] && as_user systemctl --user show-environment >/dev/null 2>&1; then + plan_user systemctl --user daemon-reload + fi + fi + + if [ "${#PLAN[@]}" -eq 0 ]; then + say 'sendspin-cli is not installed here; there is nothing to remove.' + if [ "${#kept[@]}" -gt 0 ]; then + say "Left in place (--purge removes them): ${kept[*]}" + fi + exit 0 + fi + + if [ "$NEED_ROOT" = 'yes' ] && [ -n "$SUDO" ]; then + command -v sudo >/dev/null 2>&1 || + fail 'removing files from /usr/local needs root, and there is no sudo here -- + re-run it as root' + fi + + step 'These are the commands this will run' + say '' + local entry + for entry in "${PLAN[@]}"; do + case "$entry" in + root\ *) say " ${SUDO_P}${entry#root }" ;; + user\ *) say " ${USER_P}${entry#user }" ;; + esac + done + say '' + [ "$have_account" != 'yes' ] || kept+=("the '$SERVICE_USER' account") + if [ "$purge" != 'yes' ] && [ "${#kept[@]}" -gt 0 ]; then + say "Kept: ${kept[*]}" + say '--purge removes those as well.' + fi + + if [ "$assume_yes" != 'yes' ]; then + [ "$CAN_ASK" = 'yes' ] || + fail 'there is no terminal to confirm those commands on. Re-run with --yes if you + have read them and want them run; nothing was removed' + say '' + confirm 'Run them?' || fail 'nothing was removed' + fi + + step 'Removing' + for entry in "${PLAN[@]}"; do + # The entries were quoted with %q when queued, so eval restores the exact words. + case "$entry" in + root\ *) eval "as_root ${entry#root }" ;; + user\ *) eval "as_user ${entry#user }" ;; + esac + done + say ' done' + + if [ "$foreign_unit" = 'yes' ]; then + say '' + say " $own_unit was not written by the installer, so it is left." + fi + if [ -n "$TARGET_USER" ] && [ -e "/var/lib/systemd/linger/$TARGET_USER" ]; then + say '' + say " $TARGET_USER still lingers, which the user service needed and other user services" + say " may too. To undo it: ${SUDO_P}loginctl disable-linger $TARGET_USER" + fi + say '' +} + +# Braces, so a download cut short anywhere is a syntax error and runs nothing. +{ main "$@"; } From e5b9a4bcca2d39e2430a1880f92e91a77984cad5 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 8 Oct 2026 07:39:32 -0500 Subject: [PATCH 4/6] Open the README with the quick start and document the guided installer --- README.md | 71 +++++++---- contributors.md | 4 +- docs/wiki/Getting-Started-on-Linux.md | 119 ++++++++++++------ .../wiki/Getting-Started-on-a-Raspberry-Pi.md | 24 ++-- docs/wiki/Home.md | 2 +- docs/wiki/Installation.md | 12 +- docs/wiki/Running-as-a-Service.md | 78 +++++++++--- 7 files changed, 215 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index a1f375b..88c1aa8 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,71 @@ # sendspin-cli -## What is it? - `sendspin-cli` is a headless audio player for the [Sendspin](https://github.com/Sendspin/spec) protocol. It appears on your network, a Sendspin server finds it, and it plays synchronized audio with the rest of your group. -It runs on Linux and Apple-silicon macOS. On Linux, it can also run as a system service. +It runs on Linux and Apple-silicon macOS. -## How can I run it? +## Quick start ### Linux and Raspberry Pi -The quickest way to install a released build is the guided Linux installer: +```bash +curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_linux.sh | bash +``` + +It downloads the release for your machine, verifies its checksum, asks for a player name +and an output device, and starts the player as a systemd service. Every command that needs +root is printed first and waits for your yes. + +Prefer to read it first? ```bash curl -fLO https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_linux.sh less get_started_linux.sh -chmod +x get_started_linux.sh -./get_started_linux.sh +bash get_started_linux.sh ``` -The script downloads the right release for your machine, verifies it, and guides you -through choosing an audio device. See the -[Linux quick start](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Getting-Started-on-Linux) -or [Raspberry Pi guide](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Getting-Started-on-a-Raspberry-Pi) -for the complete walkthrough. +`--help` lists the flags, among them `--yes --name --output ` for an +unattended install and `--user-service` to run as you and follow your desktop's PipeWire or +PulseAudio. The [Linux guide](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Getting-Started-on-Linux) +and the [Raspberry Pi guide](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Getting-Started-on-a-Raspberry-Pi) +have the full walkthrough. ### macOS Download the Apple-silicon `.pkg` installer or tarball from [Releases](https://github.com/Sendspin/sendspin-cpp-cli/releases), then follow the -[installation guide](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Installation#macos). +[installation guide](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Installation#macos) +and start a player from a terminal with `sendspin-cli -n living-room`. -### Start playing +### Pair it with your server -Once installed and configured, start a player from a terminal: +The player advertises itself on the local network and a Sendspin server finds it. +Connections are encrypted, and a server must pair with the player before it can play. +Print the token to paste into the server: ```bash -sendspin-cli -n living-room +# Linux service, as installed above +sudo sendspin-cli pair-token --control-socket /run/sendspin-cli/control.sock + +# --user-service, macOS, or a player started from your own shell +sendspin-cli pair-token ``` -It advertises itself on the local network and waits for a Sendspin server to connect. -Connections are encrypted, and a server must pair with the player before it can play: -`sendspin-cli pair-token` prints the token to paste into the server, and [Pairing a player](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Pairing-a-Player) covers that and the two code-based ways. `--allow-unpaired` lets any server on the network play without pairing. -For a Linux system-service installation, the quick-start guide explains how to enable -and check the service. + +## Everyday use + +The service reads its options from a +[config file](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Configuration); the same +options are flags when you run the player yourself: + +```bash +sendspin-cli -n living-room +``` The options most people need beyond a name: @@ -76,9 +93,6 @@ sendspin-cli -n den --input default | `--pairing-code <8 digits>` | A fixed code a server can pair with, confirmed with `sendspin-cli pair confirm`. Without it the player shows a fresh 6-digit code per attempt. | | `-s, --server ` | Connect out to a server rather than waiting to be discovered, or `mdns:[]` to discover one over mDNS. Turns off the mDNS advertisement. | -Any of these can go in a config file instead of on the command line — see -[Configuration](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Configuration). - Common local controls are available from the same host: ```bash @@ -87,6 +101,15 @@ sendspin-cli pause sendspin-cli vol 40 ``` +## Uninstall + +```bash +curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/uninstall_linux.sh | bash +``` + +It removes the service and the installed files, and keeps your config and the player's +remembered state unless you pass `--purge`. + ## Need more help? The [wiki](https://github.com/Sendspin/sendspin-cpp-cli/wiki) is the complete end-user diff --git a/contributors.md b/contributors.md index c17662b..69212d7 100644 --- a/contributors.md +++ b/contributors.md @@ -50,8 +50,8 @@ separate because it runs under emulation; it is required for releases. - `src/` contains the player, command-line interface, output backends, discovery, and local control implementation. - `tests/` contains the GoogleTest unit suite. -- `packaging/` contains the service, sysusers, and configuration-file payload. -- `scripts/` contains installer, packaging, and smoke-test scripts. +- `packaging/` contains the system and user service units, sysusers, and configuration-file payload. +- `scripts/` contains installer, uninstaller, packaging, and smoke-test scripts. - `docs/wiki/` contains the source for the published wiki. ## Releases diff --git a/docs/wiki/Getting-Started-on-Linux.md b/docs/wiki/Getting-Started-on-Linux.md index bb8712b..5d42373 100644 --- a/docs/wiki/Getting-Started-on-Linux.md +++ b/docs/wiki/Getting-Started-on-Linux.md @@ -9,33 +9,56 @@ OS), systemd, a sound card, and root. ## The short way +```bash +curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_linux.sh | bash +``` + +Or read it before it runs anything: + ```bash curl -fLO https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_linux.sh -less get_started_linux.sh # it is about to run things as root; read it -chmod +x get_started_linux.sh -./get_started_linux.sh +less get_started_linux.sh +bash get_started_linux.sh ``` -It installs the release for this machine's architecture and sets the service up. It does -not pipe into a shell, and it does not run anything as root without printing the exact -commands first and waiting for you to say yes: +Both behave the same. It installs the release for this machine's architecture, asks for a +player name and an output device, writes them to the config, and starts the service. Piped +into `bash`, its questions are still asked on your terminal. It does not run anything as +root without printing the exact commands first and waiting for you to say yes: ``` ==> These are the commands that need root - sudo tar -xzf /tmp/tmp.XXXX/sendspin-cli-0.1.0-linux-arm64.tar.gz --strip-components=1 -C / sendspin-cli-0.1.0-linux-arm64/usr - sudo cp /usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example /etc/sendspin-cli.conf + sudo tar -xzf /tmp/tmp.XXXX/sendspin-cli-0.3.0-linux-arm64.tar.gz --strip-components=1 -C / sendspin-cli-0.3.0-linux-arm64/usr sudo systemd-sysusers sudo systemctl daemon-reload sudo systemctl enable sendspin-cli sudo /usr/local/bin/sendspin-cli -l + sudo install -m 0644 /tmp/tmp.XXXX/sendspin-cli.conf /etc/sendspin-cli.conf + sudo systemctl restart sendspin-cli Run them? [y/N] ``` -`--yes` skips the prompt, and is required when stdin is not a terminal — a script that -cannot ask refuses rather than assuming. `--version v0.1.0` installs a specific release -instead of the newest. +The config it installs is composed in the temporary directory from the annotated example +plus your two answers, and the lines it sets are printed again just before it is copied. + +| Flag | What it does | +|---|---| +| `--version v0.3.0` | Install that release instead of the newest. | +| `--name ` | The player's name, instead of being asked. | +| `--output ` | The output device, instead of being asked. | +| `--user-service` | Run as you rather than as the `sendspin-cli` account — see [below](#as-a-system-service-or-as-you). | +| `--system-service` | The default; also switches a user-service install back. | +| `--yes` | Ask nothing: run the root commands as printed, and take the name and output only from the flags. | + +Piped, flags go after `bash -s --`: + +```bash +curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_linux.sh | bash -s -- --yes --name kitchen --output hw:1,0 +``` + +With no terminal to ask on and no `--yes`, it refuses and installs nothing. ### What it actually does @@ -47,33 +70,58 @@ instead of the newest. and older is refused with the reason rather than an "unsupported" shrug. 2. **Finds the newest release** and downloads that archive plus `SHA256SUMS`. 3. **Verifies the checksum**, and stops without installing anything if it does not match. -4. **Unpacks it into `/`** with the member-selected `tar` form, so `BUILD-INFO.txt` stays in +4. **Checks the binary can load here.** Shared libraries this host lacks are mapped to + their `apt`, `dnf` or `pacman` packages and the install command joins the list you + confirm. A library it cannot map, a host with none of those package managers, or a glibc + older than the build needs stops it there, naming what is missing, with nothing installed. +5. **Unpacks it into `/`** with the member-selected `tar` form, so `BUILD-INFO.txt` stays in the archive. -5. **Copies the annotated example config** to `/etc/sendspin-cli.conf` if you have none. - Every line in it is commented out, so it chooses nothing. 6. **Runs `systemd-sysusers`**, which creates the unprivileged `sendspin-cli` account the - unit runs as out of the declaration the payload just installed. This is the one step a - tarball cannot do for itself, and without it the unit does not start at all — see + unit runs as out of the declaration the payload just installed — see [Running as a Service](Running-as-a-Service#it-runs-as-its-own-account). -7. **Enables the unit — and starts it only if an output is already configured.** More on - that below; it is the one surprising thing the script does. -8. **Lists this host's sound devices** and prints the two commands that finish the job. +7. **Enables the unit.** +8. **Asks for a name and an output device**, after listing what this host can play + through, and writes `name` and `output` into `/etc/sendspin-cli.conf` — seeded from the + annotated example if you have no config yet. A key your config already sets is never + asked for and never replaced. +9. **Starts the service**, waits two seconds, and either confirms it is running or shows + the journal's account of why it is not. + +Re-running it is how you upgrade: it overwrites the same paths and restarts the service. +On a host whose config already names an `output` it asks nothing but the root confirmation. + +### As a system service, or as you + +On a fresh host the script asks which: + +- **A system service** (the default, `--system-service`). The hardened unit, running as the + unprivileged `sendspin-cli` account, configured in `/etc/sendspin-cli.conf`. It has no + desktop session, so name a card — `hw:1,0` — rather than `default`. Right for a headless + box or a Pi. +- **A user service** (`--user-service`). Runs as you under `systemctl --user`, configured in + `~/.config/sendspin-cli/config`, so `output = default` follows your session's PipeWire or + PulseAudio. The script enables lingering so it starts at boot and survives logout, and adds + you to the `audio` group for direct `hw:` devices. Subcommands need no `sudo` and no + flags: `sendspin-cli status`. -Re-running it is how you upgrade: it overwrites the same paths, and restarts the service if -step 7 finds an `output` configured — which after a first run it will. Both step 6 and step 7 -are idempotent, so nothing there minds being run twice. +Both listen on the same port, so choosing one stops and disables the other. See +[Running as a Service](Running-as-a-Service#a-user-unit-instead) for what the user unit is. -### Why it does not start the player +### When it does not start the player -A systemd **system** unit has no user session, so there is no PipeWire or PulseAudio for -ALSA's `default` PCM to follow — and the device that opens perfectly from your shell usually -will not open under `systemctl`. The unit is `Restart=on-failure` with `RestartSec=5`, so a -player started before you have named a card fails and is retried every five seconds forever, -while the script that started it prints congratulations. +One case: `--yes` with no `--output`, on a host whose config names none. There is nobody to +ask, and a system unit started without a card fails and is retried every five seconds — +`Restart=on-failure`, and no session for ALSA's `default` to follow. So the unit is enabled, +the device list is printed, and the two commands that finish the job are the last thing it +says. Answering the output question with nothing ends the same way. + +### Removing it + +```bash +curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/uninstall_linux.sh | bash +``` -So it enables the unit, shows you the devices, and leaves starting it to you. Once -`/etc/sendspin-cli.conf` names an `output`, the script starts the player itself on every -subsequent run. +See [Uninstalling](Running-as-a-Service#uninstalling). ## The long way @@ -130,8 +178,8 @@ Three forms are worth knowing, and there are more in | `default` | follow the host's own configuration — PipeWire, PulseAudio or bare hardware | `default` is the right answer from a login shell and usually the wrong one under a system -unit, for the reason above. Under a **user** unit (`systemctl --user`) it is right again, -because there the session and its sound server exist. +unit, which has no session to follow. Under the **user** service (`--user-service`) it is +right again, because there the session and its sound server exist. ## Check it worked @@ -163,8 +211,9 @@ see [Controlling the Player](Controlling-the-Player). ## Now find it from your server The player advertises `_sendspin._tcp` and waits. Open your Sendspin controller and it -should appear under the name it logged — which is `-n`, falling back to this host's name. -Nothing needs configuring on the server side. +should appear under the name it logged — which is `name`, falling back to this host's name. +A server has to pair with it before it may play; the script prints the `pair-token` command +for that as its last step, and [Pairing a Player](Pairing-a-Player) has the rest. To go the other way and have the player dial the server instead, set `server` in the config: diff --git a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md index 4c26b70..9907876 100644 --- a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -57,16 +57,16 @@ itself carries, where the other archives need `2.38`. So it loads on Raspberry P Exactly as on any Linux host: ```bash -curl -fLO https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_linux.sh -less get_started_linux.sh # read it before it runs things as root -chmod +x get_started_linux.sh -./get_started_linux.sh +curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_linux.sh | bash ``` -The script detects the Pi from `/proc/device-tree/model` and adds the notes below to what it -prints at the end. Everything on -[Getting Started on Linux](Getting-Started-on-Linux) — what it does, why it enables the unit -without starting it, and the by-hand equivalent — applies unchanged. +It asks for a name and an output device — section 3 is how to answer the second — and +starts the service. The script detects the Pi from `/proc/device-tree/model` and adds the +notes below to what it prints at the end. Everything on +[Getting Started on Linux](Getting-Started-on-Linux) — what it does, reading it before +running it, the flags for an unattended install, and the by-hand equivalent — applies +unchanged. A Pi OS Lite image usually lacks `libportaudio2` and +`libavahi-compat-libdnssd1`; the script offers to install them. ## 3. The Pi has several sound cards, and you must pick one @@ -85,7 +85,8 @@ $ sendspin-cli -l ... ``` -Then name it in `/etc/sendspin-cli.conf`: +The getting-started script shows this list and asks which; what you type lands in +`/etc/sendspin-cli.conf`: ```ini output = hw:1,0 @@ -121,8 +122,9 @@ The getting-started script does that for you. Skipping it is a unit that does no `systemctl status` reading `217/USER`; it is not a player that starts and stays silent. See [Running as a Service](Running-as-a-Service#it-runs-as-its-own-account). -**From your own shell, it is on you.** Put yourself in the `audio` group once, then log out -and back in — group membership is only picked up at login: +**From your own shell, it is on you** — as it is under the user service, where the script's +`--user-service` mode runs this for you. Put yourself in the `audio` group once, then log +out and back in — group membership is only picked up at login: ```bash sudo usermod -aG audio "$USER" diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 484ea8a..75a85d6 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -45,7 +45,7 @@ $ sendspin-cli vol 40 | Platform | Architecture | How | |---|---|---| -| Linux | `x86_64`, `arm64`, `armv7`, `armv6` | Release tarball, or `scripts/get_started_linux.sh` | +| Linux | `x86_64`, `arm64`, `armv7`, `armv6` | [`scripts/get_started_linux.sh`](Getting-Started-on-Linux), or the release tarball | | macOS | Apple silicon (`arm64`) | Release tarball or installer `.pkg` | | Raspberry Pi | `arm64`, or `armv7`/`armv6` on a 32-bit OS | The Linux tarball, same as any other Linux host | diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index dea28a2..926bc94 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -27,6 +27,7 @@ sendspin-cli-0.1.0-linux-arm64/ └── usr/local/ ├── bin/sendspin-cli ├── lib/systemd/system/sendspin-cli.service # Linux only + ├── lib/systemd/user/sendspin-cli.service # Linux only ├── lib/sysusers.d/sendspin-cli.conf # Linux only └── share/doc/sendspin-cli/ ├── README.md @@ -84,11 +85,16 @@ it with no file to verify. archive** instead of writing it to `/`. `--strip-components=1` drops the archive's own top level so the rest lands where it belongs. -Runtime packages, if the binary will not start: +Runtime packages, if the binary will not start — `ldd /usr/local/bin/sendspin-cli | grep 'not found'` +names what is missing, and the getting-started script installs these for you: ```bash -sudo apt install libasound2t64 libportaudio2 libavahi-compat-libdnssd1 # Debian / Ubuntu -sudo dnf install alsa-lib portaudio avahi-compat-libdns_sd # Fedora / RHEL +# Debian / Ubuntu; libasound2 and libpipewire-0.3-0 before the t64 renames +sudo apt install libasound2t64 libportaudio2 libpulse0 libpipewire-0.3-0t64 libavahi-compat-libdnssd1 +# Fedora / RHEL +sudo dnf install alsa-lib portaudio pulseaudio-libs pipewire-libs avahi-compat-libdns_sd +# Arch +sudo pacman -S --needed alsa-lib portaudio libpulse libpipewire avahi ``` To run it without installing anywhere, unpack it and use it in place: diff --git a/docs/wiki/Running-as-a-Service.md b/docs/wiki/Running-as-a-Service.md index e748f44..38c7410 100644 --- a/docs/wiki/Running-as-a-Service.md +++ b/docs/wiki/Running-as-a-Service.md @@ -1,6 +1,9 @@ # Running as a Service -One systemd unit ships in the Linux payload, with sane defaults and nothing to fill in. +Two systemd units ship in the Linux payload: a hardened system unit, which this page is +mostly about, and [a user unit](#a-user-unit-instead). The +[getting-started script](Getting-Started-on-Linux) sets either one up; by hand, the system +one is: ```bash sudo systemd-sysusers @@ -23,9 +26,8 @@ the unit runs as, out of a declaration installed beside the unit, and a tarball > seconds indefinitely — `Restart=on-failure` with `RestartSec=5`, and a system unit has no > session for ALSA's `default` PCM to follow. Run `sendspin-cli -l`, pick a card, and put > `output = hw:1,0` in `/etc/sendspin-cli.conf`. -> [Getting Started on Linux](Getting-Started-on-Linux) has the argument in full, and is why > [`scripts/get_started_linux.sh`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/scripts/get_started_linux.sh) -> enables this unit without starting it. +> asks for one before it starts the unit for exactly this reason. ## What the payload installs @@ -33,6 +35,7 @@ the unit runs as, out of a declaration installed beside the unit, and a tarball |---|---| | `/usr/local/bin/sendspin-cli` | the binary | | `/usr/local/lib/systemd/system/sendspin-cli.service` | the unit | +| `/usr/local/lib/systemd/user/sendspin-cli.service` | the user unit | | `/usr/local/lib/sysusers.d/sendspin-cli.conf` | the account the unit runs as, declared | | `/usr/local/share/doc/sendspin-cli/README.md` | quick-start and wiki links | | `/usr/local/share/doc/sendspin-cli/contributors.md` | how to build and contribute | @@ -230,39 +233,76 @@ and a second one would be noise. Only a `-f` logfile gets our own timestamp. See ## A user unit instead -If the player should follow your desktop session's sound server, a user unit is the better -fit — `$XDG_RUNTIME_DIR` and `$XDG_STATE_HOME` both exist there, so neither flag is needed -and `output = default` works as it does from your shell: +If the player should follow your desktop session's sound server, run it as a user service. +`$XDG_RUNTIME_DIR` and `$XDG_STATE_HOME` both exist there, so the control socket and the +state take their defaults, subcommands need neither `sudo` nor a flag, and +`output = default` works as it does from your shell. + +`get_started_linux.sh --user-service` does all of it. By hand: ```bash -mkdir -p ~/.config/systemd/user -cp /usr/local/lib/systemd/system/sendspin-cli.service ~/.config/systemd/user/ -# edit out the --control-socket and --state-dir arguments, the two Directory= lines, -# and the User= line -- a user unit cannot set one, and would refuse to start with it +sudo systemctl disable --now sendspin-cli # the two listen on the same port +mkdir -p ~/.config/sendspin-cli +cp /usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example ~/.config/sendspin-cli/config +nano ~/.config/sendspin-cli/config # output = default systemctl --user daemon-reload systemctl --user enable --now sendspin-cli -loginctl enable-linger "$USER" # so it runs when you are not logged in +sudo loginctl enable-linger "$USER" # start at boot, keep running after logout +journalctl --user -u sendspin-cli -f ``` -Your own account needs `audio` membership for `/dev/snd` here, since the `sendspin-cli` -account's membership does nothing for a unit that is not running as it: -`sudo usermod -aG audio "$USER"`, then log out and back in. +The unit is installed at `/usr/local/lib/systemd/user/sendspin-cli.service`, on the user +manager's own search path. It is not a copy of the system unit: + +- No `User=`, `RuntimeDirectory=` or `StateDirectory=`, and no `--control-socket` or + `--state-dir` on `ExecStart` — a user manager cannot set the first three and has no need + of the last two. +- `WantedBy=default.target`; a user manager has no `multi-user.target`. +- Of the hardening block it keeps what an unprivileged manager can apply: `NoNewPrivileges=`, + `LockPersonality=`, `MemoryDenyWriteExecute=`, `RestrictSUIDSGID=`, + `RestrictAddressFamilies=`, `SystemCallArchitectures=` and `SystemCallFilter=`. The + `Protect*=` and `Private*=` family needs privileges it does not have. + +Your own account needs `audio` membership to open a `hw:` device directly, since the +`sendspin-cli` account's membership does nothing for a unit that is not running as it: +`sudo usermod -aG audio "$USER"`, then log out and back in. PipeWire and PulseAudio outputs +do not need it. -The unit that ships is the system one; this is a recipe rather than something the project -installs or tests. +A release older than the user unit installs none. The script then writes an equivalent to +`~/.config/systemd/user/sendspin-cli.service`, marked as its own on the first line, and +removes it again on the first upgrade that ships one. ## Uninstalling +```bash +curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/uninstall_linux.sh | bash +``` + +[`scripts/uninstall_linux.sh`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/scripts/uninstall_linux.sh) +stops and disables the service in whichever mode it runs, and removes the binary, both +units, the account declaration and the installed documentation. Like the installer it prints +every command first and waits for a yes, and `--yes` skips the question. On a host with +nothing installed it says so and exits 0. + +**It keeps your config, the player's remembered state and the `sendspin-cli` account** unless +you answer yes when it asks, or pass `--purge`. Packages the installer added through `apt`, +`dnf` or `pacman` stay, and so does lingering for your user — other things may rely on both. + +By hand, the same thing: + ```bash sudo systemctl disable --now sendspin-cli +systemctl --user disable --now sendspin-cli # if you ran it as a user service sudo rm -f /usr/local/bin/sendspin-cli sudo rm -f /usr/local/lib/systemd/system/sendspin-cli.service +sudo rm -f /usr/local/lib/systemd/user/sendspin-cli.service sudo rm -f /usr/local/lib/sysusers.d/sendspin-cli.conf sudo rm -rf /usr/local/share/doc/sendspin-cli -sudo rm -rf /var/lib/sendspin-cli # what it remembered -sudo rm -f /etc/sendspin-cli.conf # your config sudo systemctl daemon-reload -sudo userdel sendspin-cli # the account, if you want it gone too +# what --purge adds: +sudo rm -rf /var/lib/sendspin-cli ~/.local/state/sendspin-cli # what it remembered +sudo rm -rf /etc/sendspin-cli.conf ~/.config/sendspin-cli # your config +sudo userdel sendspin-cli # the account ``` Removing the fragment does not remove the account — `systemd-sysusers` creates users and From 51d2dd8bc0f53656413f9fab34e90daec5a5afb9 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 8 Oct 2026 07:40:26 -0500 Subject: [PATCH 5/6] Queue purge removals for root when there is no invoking user --- scripts/get_started_linux.sh | 2 ++ scripts/uninstall_linux.sh | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index 9f82d62..e3fb7c4 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -882,6 +882,8 @@ main() { say " $SYSTEM_CONFIG (from the installed example; everything in it is commented out)" fi step 'Not touching a service' + [ -z "$want_name$want_output" ] || + say ' --name and --output configure the service, so they were not used.' say ' systemd is not running here, so there is nothing to enable. The binary is' say ' installed and runs in the foreground:' say '' diff --git a/scripts/uninstall_linux.sh b/scripts/uninstall_linux.sh index 7e83c1c..40f7e2c 100755 --- a/scripts/uninstall_linux.sh +++ b/scripts/uninstall_linux.sh @@ -243,10 +243,12 @@ main() { if [ "$purge" = 'yes' ]; then for path in "${kept[@]}"; do - case "$path" in - "$TARGET_HOME"/*) plan_user rm -rf "$path" ;; - *) plan_root rm -rf "$path" ;; - esac + # Only /etc and /var paths are in the list when there is no user. + if [ -n "$TARGET_USER" ] && [ "${path#"$TARGET_HOME"/}" != "$path" ]; then + plan_user rm -rf "$path" + else + plan_root rm -rf "$path" + fi done [ "$have_account" != 'yes' ] || plan_root userdel "$SERVICE_USER" fi From ece756ca3f3233223718709c17083981414c6f2a Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 8 Oct 2026 09:27:09 -0500 Subject: [PATCH 6/6] Group the --name and --output argument checks for shellcheck 0.9 --- scripts/get_started_linux.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index e3fb7c4..4c3659c 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -374,12 +374,12 @@ main() { shift 2 ;; --name) - [ "$#" -ge 2 ] && [ -n "$2" ] || fail '--name needs a name, e.g. --name kitchen' + { [ "$#" -ge 2 ] && [ -n "$2" ]; } || fail '--name needs a name, e.g. --name kitchen' want_name=$2 shift 2 ;; --output) - [ "$#" -ge 2 ] && [ -n "$2" ] || fail '--output needs a device, e.g. --output hw:1,0' + { [ "$#" -ge 2 ] && [ -n "$2" ]; } || fail '--output needs a device, e.g. --output hw:1,0' want_output=$2 shift 2 ;;