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/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 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 diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index 475dfd8..4c3659c 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,918 @@ 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' + [ -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 '' + 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 "$@"; } diff --git a/scripts/uninstall_linux.sh b/scripts/uninstall_linux.sh new file mode 100755 index 0000000..40f7e2c --- /dev/null +++ b/scripts/uninstall_linux.sh @@ -0,0 +1,326 @@ +#!/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 + # 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 + + 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 "$@"; }