From 4c08f20e3756f58ff0e86ac22db2301107761d78 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Wed, 19 Aug 2026 17:12:31 -0500 Subject: [PATCH 1/4] Add wiki documentation and a Linux getting-started script The pages are authored in docs/wiki/ and mirrored to the repository's GitHub wiki by a new workflow on every push to main. Authoring them in the tree is the point: a wiki push bypasses pull requests, review and CI, which is the wrong default for a document telling people what to run as root. scripts/get_started_linux.sh installs a release on any Linux host, a Raspberry Pi included, verifying against the release's SHA256SUMS and printing every privileged command before running it. It enables the systemd unit but starts the player only once an output is configured -- a system unit has no session for ALSA's default PCM to follow, so starting it first means a unit failing every five seconds. SENDSPIN_CLI_TARBALL installs a locally staged payload instead, which is how this was exercised before any release exists. --- .github/workflows/release.yml | 5 +- .github/workflows/wiki.yml | 152 +++++ README.md | 7 + docs/wiki/Configuration.md | 177 ++++++ docs/wiki/Controlling-the-Player.md | 188 ++++++ docs/wiki/Getting-Started-on-Linux.md | 167 ++++++ .../wiki/Getting-Started-on-a-Raspberry-Pi.md | 149 +++++ docs/wiki/Home.md | 91 +++ docs/wiki/Installation.md | 180 ++++++ docs/wiki/Running-as-a-Service.md | 202 +++++++ docs/wiki/Troubleshooting.md | 325 ++++++++++ docs/wiki/_Sidebar.md | 26 + scripts/get_started_linux.sh | 554 ++++++++++++++++++ 13 files changed, 2221 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/wiki.yml create mode 100644 docs/wiki/Configuration.md create mode 100644 docs/wiki/Controlling-the-Player.md create mode 100644 docs/wiki/Getting-Started-on-Linux.md create mode 100644 docs/wiki/Getting-Started-on-a-Raspberry-Pi.md create mode 100644 docs/wiki/Home.md create mode 100644 docs/wiki/Installation.md create mode 100644 docs/wiki/Running-as-a-Service.md create mode 100644 docs/wiki/Troubleshooting.md create mode 100644 docs/wiki/_Sidebar.md create mode 100755 scripts/get_started_linux.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e548bc1..f5bf32d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,8 +90,9 @@ jobs: needs: [preflight, build] runs-on: ubuntu-24.04 timeout-minutes: 10 - # Scoped to this job alone, the workflow default above staying read-only. This is the only - # job in either workflow that can write to the repository. + # Scoped to this job alone, the workflow default above staying read-only. The only other + # job anywhere here that can write is wiki.yml's, and it writes to the wiki repository + # rather than to this one. permissions: contents: write env: diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml new file mode 100644 index 0000000..0a98935 --- /dev/null +++ b/.github/workflows/wiki.yml @@ -0,0 +1,152 @@ +name: Wiki + +# Publishes docs/wiki/ to this repository's GitHub wiki. +# +# The pages are authored in the tree and mirrored *out* to the wiki, rather than edited in the +# wiki tab, because a wiki push bypasses everything: no pull request, no review, no required +# check, and — for a repository whose wiki is open to collaborators — no gate at all. Authoring +# them here makes a documentation change the same kind of change as a code change. The wiki tab +# is a rendering of `docs/wiki/`; it is not where anything is written. +# +# That direction is what makes the mirror one-way and destructive: a page edited in the wiki tab +# is overwritten on the next push to main, and a page deleted from docs/wiki/ is deleted from +# the wiki. wiki/Home.md says so, because a reader who edits a page and watches it revert +# deserves to have been told where to send the fix instead. +# +# `contents: write` is scoped to the one job that needs it, the workflow default above it being +# read-only — the same shape release.yml's publishing job uses. The wiki is a separate git +# repository (`.wiki.git`) but the same token governs both, so this cannot be given less. +on: + push: + branches: + - main + paths: + # The workflow itself included: a change to how the mirror runs should be exercised by + # the push that makes it, not left until the next page edit. + - 'docs/wiki/**' + - '.github/workflows/wiki.yml' + # A hand-run, for the first sync after the wiki is enabled — that is a repository setting + # rather than a commit, so nothing about flipping it triggers a push. + workflow_dispatch: + +# Queued rather than cancelled, which is the difference between this and ci.yml. Two pushes to +# main both clone, commit and push to one remote; cancelling the first would be fine, but +# `cancel-in-progress: true` cancels the *older* run, and the survivor may be the one carrying +# the older tree. Serialising them instead means the second run clones what the first pushed. +concurrency: + group: wiki + cancel-in-progress: false + +permissions: + contents: read + +defaults: + run: + # Named rather than left to default, because the default is `bash -e` with no pipefail. + shell: bash + +jobs: + sync: + name: sync + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + steps: + # Pinned to a commit rather than a tag, because a tag can be repointed at any time and + # this job runs with write access. The trailing comment is the version that commit was. + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Asked before anything is cloned, so the two ways there is nowhere to publish to are + # told apart from each other and from a clone that failed for some other reason. Both are + # repository settings only an owner can change, and neither is this push's fault: they + # skip with a notice. Everything else fails. + - name: Is there a wiki to publish to + id: wiki + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + has_wiki="$(gh api "repos/$GITHUB_REPOSITORY" --jq '.has_wiki')" + echo "has_wiki=$has_wiki" >>"$GITHUB_OUTPUT" + if [ "$has_wiki" != 'true' ]; then + echo "::notice::Wikis are turned off for $GITHUB_REPOSITORY, so docs/wiki was not published. Enable it under Settings -> General -> Features -> Wikis, then re-run this workflow." + fi + + # A wiki that is enabled but has never had a page created has no git repository behind it + # yet, and GitHub offers no API to create one — the first page has to be made in the wiki + # tab, once, by hand. That is the second setting-shaped skip, and it is recognised by + # git's own message rather than by "the clone failed", so a network failure, a revoked + # token or a rate limit still fails this job. The captured stderr is printed either way: + # a skip nobody can see the evidence for is a skip that hides a real error. + - name: Clone the wiki + id: clone + if: steps.wiki.outputs.has_wiki == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + git clone --depth 1 \ + "https://x-access-token:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.wiki.git" \ + wiki 2>clone.err + status=$? + set -e + + # Printed before it is judged, and with the token pattern that cannot appear in it + # anyway left alone: git reports the URL without credentials. + cat clone.err + + if [ "$status" -eq 0 ]; then + echo 'cloned=true' >>"$GITHUB_OUTPUT" + exit 0 + fi + + if grep -qiE 'repository .* not found|not found: .*\.wiki' clone.err; then + echo 'cloned=false' >>"$GITHUB_OUTPUT" + echo "::notice::$GITHUB_REPOSITORY has wikis enabled but no wiki repository yet, so docs/wiki was not published. Create any page once in the wiki tab — that is what GitHub creates the repository on — then re-run this workflow." + exit 0 + fi + + echo '::error::Could not clone the wiki, and not because it is missing. See the git output above.' + exit 1 + + # rsync --delete rather than a copy, because the tree is the source of truth in both + # directions: a page removed from docs/wiki/ has to leave the wiki, and one edited in the + # wiki tab has to go back to what the tree says. `.git` is excluded explicitly — deleting + # the clone's own history mid-sync would be a novel way to fail. + - name: Mirror docs/wiki into it + if: steps.clone.outputs.cloned == 'true' + run: | + rsync -a --delete --exclude '.git' docs/wiki/ wiki/ + git -C wiki status --porcelain + + # Nothing to say is the common case — this workflow also runs when only the workflow file + # changed — and an empty commit would put a row in the wiki's history for a push that + # changed no page. + # + # Never force. A rejected push means the wiki moved under this run, which is either the + # concurrent run above (impossible: the group serialises them) or somebody editing the + # wiki tab directly. That is precisely the bypass this whole arrangement exists to + # prevent, so it fails and names the cause instead of overwriting the evidence. + - name: Commit and push what changed + if: steps.clone.outputs.cloned == 'true' + run: | + cd wiki + if [ -z "$(git status --porcelain)" ]; then + echo "::notice::The wiki already matches docs/wiki at $GITHUB_SHA; nothing to push." + exit 0 + fi + + # An identity is required to commit at all, and github-actions[bot]'s is the one that + # attributes this to the workflow rather than to whoever pushed to main. + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m "Sync docs/wiki from $GITHUB_SHA" + + git push 2>push.err || { + cat push.err + echo '::error::Pushing to the wiki was rejected. The usual cause is a page edited in the wiki tab, which this mirror overwrites rather than merges — copy the edit into docs/wiki/ and open a pull request for it.' + exit 1 + } + echo "::notice::Published docs/wiki to the wiki at $GITHUB_SHA." diff --git a/README.md b/README.md index 72cbc2b..9f5ba70 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,13 @@ audio-streaming library, taking its command-line and control ergonomics from > The initial task brings up the build and boots a sendspin client; feature work > is tracked in [`docs/ROADMAP.md`](docs/ROADMAP.md). +**New here?** The [wiki](https://github.com/chrisuthe/sendspin-cpp-cli/wiki) is the +task-shaped version of this file — installing, a Raspberry Pi walkthrough, and +troubleshooting — and on Linux +[`scripts/get_started_linux.sh`](scripts/get_started_linux.sh) does the whole install in one +command. Those pages are authored in [`docs/wiki/`](docs/wiki) and mirrored to the wiki tab +on every push to `main`; this file stays the reference, and ships inside every archive. + ## What it is Like squeezelite is a headless endpoint for Lyrion/Logitech Media Server, diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md new file mode 100644 index 0000000..81361a5 --- /dev/null +++ b/docs/wiki/Configuration.md @@ -0,0 +1,177 @@ +# Configuration + +Two files. **One you write and the player only ever reads**, and **one the player writes and +you never need to touch.** The split is deliberate: a daemon that rewrote its own config +would destroy the comments and the ordering you put there, and a config the daemon could not +write would have nowhere to record a volume. + +## The config file + +Anything you would otherwise type on the command line. **Keys are the long flag names +without their dashes**, one `key = value` per line, and a value is exactly the string that +flag would have been given — so `sendspin-cli --help` is this file's reference rather than a +second document to keep in step with it. + +```ini +# /etc/sendspin-cli.conf +name = kitchen +output = hw:1,0 +buffer-ms = 250 +static-delay = 40 +control-socket = /run/sendspin-cli/control.sock +``` + +An annotated example is installed at +`/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example`, with every line commented +out. Copying it is the intended way to start: + +```bash +sudo cp /usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example /etc/sendspin-cli.conf +``` + +### Where it is looked for + +The **first of these that exists is read whole**, and nothing below it is merged over the +top: + +1. `--config ` — **fatal if it cannot be read**, because you named it. Falling back + would start a player on options nobody chose. +2. `$XDG_CONFIG_HOME/sendspin-cli/config` +3. `$HOME/.config/sendspin-cli/config` +4. `/etc/sendspin-cli.conf` + +Finding none is silent and normal. There is no `--no-config` flag — `--config /dev/null` +already does that. + +### Every key + +| Key | Same as | Value | Default | +|---|---|---|---| +| `output` | `-o`, `--output` | a device: `hw:1,0`, `default`, `portaudio:2`, `null`, `stdout` | `default` where ALSA is built in, else `portaudio`, else `null` | +| `name` | `-n`, `--name` | the friendly name a controller shows | this host's name | +| `server` | `-s`, `--server` | `[:]`, a `ws://` URL, or `mdns:[]` | none — wait to be discovered | +| `port` | `--port` | the port this player's own WebSocket server listens on | `8928` | +| `buffer-ms` | `--buffer-ms` | audio the output backend keeps queued, 10–2000 | `100` | +| `static-delay` | `--static-delay` | latency this endpoint's hardware adds after the audio port, 0–5000 | `0` | +| `no-mdns` | `--no-mdns` | `true`/`false` — do not advertise `_sendspin._tcp` | `false` | +| `mdns-name` | `--mdns-name` | the instance label to advertise, when it should differ from `name` | `name` | +| `control-socket` | `--control-socket` | the Unix socket the subcommands talk to | `$XDG_RUNTIME_DIR/sendspin-cli-.sock` | +| `no-control` | `--no-control` | `true`/`false` — bind no control socket at all | `false` | +| `state-dir` | `--state-dir` | where the player keeps what it remembers | `$XDG_STATE_HOME/sendspin-cli` | +| `log-level` | `-d`, `--log-level` | `none`, `error`, `warn`, `info`, `debug`, `verbose` | `info` | +| `logfile` | `-f`, `--logfile` | write the log here instead of to stderr | stderr | +| `pidfile` | `-P`, `--pidfile` | hold this path as a locked pidfile | none | + +The middle column is the point rather than a convenience: the key **is** the long flag name +minus its dashes, which is what makes `sendspin-cli --help` this file's reference. The six +that had a short flag first — `-o -n -s -P -f -d` — grew long spellings for exactly this +reason, so that one vocabulary covers both. + +Booleans take `true`/`yes`/`on`/`1` or `false`/`no`/`off`/`0`. `#` starts a comment at the +start of a line only — a name or a path is free to contain one. Where a key appears twice, +the last one wins. + +**Five things cannot come from a file**: `-l`, `-z`, `--config`, `--help` and `--version`. +Run shape stays on the command line, and a config naming one is refused as an unknown key. +Excluding them is reversible; debugging a `daemonize` that came out of a file under systemd +is not. + +### Precedence + +**Command line > config file > built-in default**, per option rather than per file. `-n +bathroom` on the command line of a player whose config also sets `buffer-ms` overrides only +the name. + +That is why the system unit's own two flags win: `ExecStart` passes `--control-socket` and +`--state-dir`, so those two keys in a config file are **silently ignored** by the service. +Setting `control-socket` to the unit's path is still worth doing — it is what lets a +*subcommand* find the socket with no flags. + +### It refuses rather than guessing + +A configured value is validated by exactly the code that validates a typed one, with the +same message and the line to go and fix: + +```console +$ sendspin-cli +error: /etc/sendspin-cli.conf:4: invalid --buffer-ms '5' -- expected 10-2000 +``` + +An unknown key, or a line that is not `key = value`, is refused the same way — and a file +that exists and does not parse stops the run rather than falling through to `/etc`. A +silently ignored typo is the failure mode this whole surface exists to avoid. `--help`, +`--version` and `-l` short-circuit above all of it, so a broken config cannot stop `--help` +from telling you how to fix it. + +Under systemd that means a bad config is a unit that fails and is retried every five +seconds, naming the file and the line in the journal each time. See +[Troubleshooting](Troubleshooting). + +The full argument, including why the search does not merge layers, is in +[The config file, and what the player remembers](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-config-file-and-what-the-player-remembers). + +## The state file + +The other half: what the player remembers **for itself**, across restarts. You do not write +this one. + +```ini +# Written by sendspin-cli. Edits are overwritten. +last-server = 7f3a… +last-server-hash = 3387423128 +static-delay-ms = 375 +volume = 42 +muted = true +``` + +| Key | What it is | +|---|---| +| `static-delay-ms` | this endpoint's static delay. The spec **requires** a client to persist it | +| `volume`, `muted` | the gain and mute the output was last told to apply. RECOMMENDED by the spec | +| `last-server` | the server id, which mDNS discovery uses to break a tie between candidates | +| `last-server-hash` | an opaque `uint32_t` the library asks us to keep so *it* can prefer the last-played server among inbound connections | + +The two server keys mean different things and are deliberately not reconciled with each +other. + +It lives at `$XDG_STATE_HOME/sendspin-cli/state`, then +`$HOME/.local/state/sendspin-cli/state`, and **`--state-dir ` overrides both** — a +systemd *system* unit has neither variable and is handed `/var/lib/sendspin-cli` by +`StateDirectory=`. With none of the three the player still runs and simply remembers +nothing. + +Writes go through a temporary, an `fsync` and a `rename` at mode `0600`, so a player that +loses power mid-write leaves either the old file or the new one and never half of either. + +**Two players on one host share this file** unless you give each its own `--state-dir`. They +already need different `--port`s; give them different state directories too, or the second +one to save its volume overwrites the first's. + +### `static-delay` versus a remembered `static-delay-ms` + +`static-delay` in the config is a **first-run default, not an override**. The library prefers +whatever the state store remembers and reads the config value only when there is nothing +remembered — exactly as a restored volume beats the sink's default. So once a server or +`sendspin-cli delay` has set one, the remembered value wins every run after and the config +key is inert. The startup log says which of the two it took. + +Three things can set it: a server's `set_static_delay`, the `delay` subcommand, and this key +on a first run with nothing yet remembered. To make the config value take again, remove the +state file. + +## Applying a change + +The player reads its config once, at startup. There is no reload signal: + +```bash +sudo systemctl restart sendspin-cli +``` + +`SIGHUP` reopens the `-f` logfile and nothing else — that is for `logrotate`, not for +configuration. + +## Next + +- [Controlling the Player](Controlling-the-Player) — what you can change without a restart +- [Running as a Service](Running-as-a-Service) — the two flags the unit passes, and why +- [Troubleshooting](Troubleshooting) diff --git a/docs/wiki/Controlling-the-Player.md b/docs/wiki/Controlling-the-Player.md new file mode 100644 index 0000000..ccd3084 --- /dev/null +++ b/docs/wiki/Controlling-the-Player.md @@ -0,0 +1,188 @@ +# Controlling the Player + +The player listens on a **Unix socket**, and the same binary is its own client. This is the +deliberate addition to the squeezelite model: `sendspin-cli pause` on the player's own host +drives it, with no server and no controller app in the loop. + +```console +$ sendspin-cli status +name: living-room +server: Music Assistant (connected) +state: playing +stream: receiving +track: Nils Frahm - Says +position: 2:05 / 9:03 +group volume: 55 +player volume: 80 +static delay: 0 ms +output: default (48000 Hz / 2 ch / 16-bit) + +$ sendspin-cli pause +$ sendspin-cli vol 40 +$ sendspin-cli seek-rel -30000 +$ sendspin-cli delay 250 +``` + +**The subcommand comes first, before any flag** — `sendspin-cli vol 50 --port 9000`, never +`sendspin-cli --port 9000 vol 50`. `argv[1]` is split off before the flag parser runs, which +is also what lets `seek-rel -5000` be an offset rather than a flag cluster. + +## Every subcommand + +| Command | Argument | What it does | +|---|---|---| +| `status` | | what this player and its group are doing — **answered locally** | +| `play` | | resume or start playback | +| `pause` | | pause playback | +| `stop` | | stop playback | +| `next` | | skip to the next track | +| `prev` | | skip to the previous track | +| `vol` | `<0-100>` | set the **group** volume | +| `mute` | `on\|off` | mute or unmute the group | +| `seek` | `` | seek to an absolute position | +| `seek-rel` | `<+/-ms>` | seek by an offset; negative goes backwards | +| `repeat` | `off\|one\|all` | set the repeat mode | +| `shuffle` | `on\|off` | turn shuffle on or off | +| `switch` | | move this player through the groups available to it | +| `delay` | `<0-5000>` | this endpoint's static delay — **answered locally** | + +Twelve of those go out to the server as `controller@v1` commands. Two never leave the host: +`status`, formatted from the daemon's own view, and `delay`, which drives this endpoint's +own player role. + +## Three that are easy to misread + +**`vol` is the *group* volume, not this box's output level.** It goes to the server, which +spreads it across every player in the group and clamps it per player. A squeezelite refugee +will expect `vol 50` to move *this* box, and it does not — which is why `status` prints +`group volume` and `player volume` as two named lines rather than one ambiguous `volume:`. + +**`switch` is not a source selector.** Per the spec's switch cycle it re-homes this client +between the groups available to it. It sits next to `play` and `pause` and means something +quite different. + +**`delay` really is this endpoint's own**, and its direction is the opposite of what the +name suggests. It is not "play this speaker later"; it is "my gear is *already* this far +behind". The sync task **subtracts** the figure from every chunk's timestamp, so the player +hands audio to the device that much **earlier** and the sound lands on the timestamp the +server meant. If this speaker sounds 250 ms late against the rest of the group: + +```console +$ sendspin-cli delay 250 # my amp adds 250 ms, so hand audio over 250 ms early +$ sendspin-cli status | grep 'static delay' +static delay: 250 ms +$ sendspin-cli delay 0 # off again +``` + +It works with **no server connected**, it is **remembered across restarts** (the spec +requires that of a client), and the player still tells the server, which needs it to work +out how far ahead to send audio. Out-of-range values are refused rather than clamped. +Changing it mid-stream re-times chunk scheduling, so expect a brief resync — set it while +stopped where you can. + +The long version of all three is in +[The local control channel](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-local-control-channel). + +## Reading `status` + +**Four fields are the server's word and can lag what is true**: `state`, `position`, +`repeat` and `shuffle` all come from the server's last report, and the spec does not oblige +it to resend them after acting. Observed against a real server, `shuffle` read `off` for +minutes while it was demonstrably shuffling. If you have just changed something and the +figure has not moved, that is the likely reason — not a failed command. The block ends with +a `note:` line saying so. + +- **`position` says `(estimated)` while playing**, because the library interpolates forward + from the last progress the server sent. After a seek the server does not re-report, the + estimate drifts by however far you jumped. Paused, it is the server's own snapshot and + carries no marker. +- **`player volume` is the gain this box's output is applying**, and says + `(default; no server has set it)` until a server sends a volume command — which is how you + tell "nobody has set this" from a server that chose full output. +- **`state` and `stream` are different facts.** `state` is the *group's* transport state. + `stream` is whether audio is arriving at *this* endpoint — a player dropped from the group + loses it while the group plays on. A `stream: receiving` line with **no format after the + device name** means the device refused the stream's format and its audio is being + discarded; the log says so loudly at the same moment. + +## Finding the socket + +The default is `$XDG_RUNTIME_DIR/sendspin-cli-.sock`, mode `0600`, where `` is +`--port`. The port is in the name so two players on one host each get their own — **so a +subcommand needs the same `--port` as the player**, or an explicit `--control-socket`: + +```bash +sendspin-cli --port 9000 & # this player's socket carries 9000 +sendspin-cli status --port 9000 # ...so its subcommands need it too +sendspin-cli status --control-socket /run/user/1000/sendspin-cli-9000.sock # or name it +``` + +**Under the systemd system unit**, both change. There is no `$XDG_RUNTIME_DIR` for root, so +the unit passes `--control-socket /run/sendspin-cli/control.sock`, and the socket is root's +at mode `0600`: + +```bash +sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +``` + +Put `control-socket = /run/sendspin-cli/control.sock` in `/etc/sendspin-cli.conf` and the +flag becomes unnecessary — the daemon ignores that key (its own command line wins) but the +subcommands read it. The `sudo` is still needed. + +**On macOS the default works with nothing set**, because there is no `$XDG_RUNTIME_DIR` +there either and the path comes from the per-user directory under `/var/folders` that +launchd already provides. There is deliberately **no `/tmp` fallback anywhere**: `/tmp` is +world-writable, and a socket there would let any local user pause your music. + +## Exit status is the interface for scripts + +| Status | Means | +|---|---| +| `0` | sent, or answered locally (`status`, `delay`) | +| `1` | the command line did not parse (`vol 500`, `delay 5001`) | +| `2` | the player refused the argument (a `seek` past the server's `seek_max_ms`) | +| `3` | **nothing is listening on that socket** — no player, or the wrong `--port` | +| `4` | the player is up but has **no server connection** | +| `5` | the server does not offer that command | +| `6` | the exchange broke down | + +`3`, `4` and `5` are kept apart because they call for three different actions: start the +daemon, connect it to a server, or stop asking for a command this server does not offer. `4` +and `5` especially — a dropped connection *empties* the server's advertised command list, so +collapsing them would answer "pause is not supported" when the truth is that nothing is +connected. + +`status` and `delay` are never refused by any of them: nothing about either is sent, so a +missing connection is no obstacle. A disconnected player is exactly when reading `status` is +worth doing. + +```bash +if ! sendspin-cli status >/dev/null 2>&1; then + case $? in + 3) echo 'no player running there' ;; + *) echo 'something else' ;; + esac +fi +``` + +## Driving it without this binary + +Connect, send one line, read until the player closes. The first line back is `ok` or +`error : `; a `status` payload follows the `ok`. One command per connection. + +```console +$ printf 'status\n' | socat - UNIX-CONNECT:/run/user/1000/sendspin-cli-8928.sock +ok +name: living-room +... +``` + +The socket is polled from the main loop rather than from a thread, so a request round-trips +in up to 10 ms. That is a deliberate trade — the library calls behind it are documented +main-thread-only, and a reader thread would be a data race. + +## Next + +- [Configuration](Configuration) — making `control-socket` stick +- [Running as a Service](Running-as-a-Service) +- [Troubleshooting](Troubleshooting) — what exit `3` usually means diff --git a/docs/wiki/Getting-Started-on-Linux.md b/docs/wiki/Getting-Started-on-Linux.md new file mode 100644 index 0000000..0e41796 --- /dev/null +++ b/docs/wiki/Getting-Started-on-Linux.md @@ -0,0 +1,167 @@ +# Getting Started on Linux + +From nothing to a player your Sendspin server can find. On a Raspberry Pi, read +[Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi) instead — it is this +page plus the handful of things a Pi does differently. + +**You need:** a 64-bit Linux host (`x86_64` or `arm64`), systemd, a sound card, and root. + +## The short way + +```bash +curl -fLO https://raw.githubusercontent.com/chrisuthe/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 +``` + +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: + +``` +==> 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 systemctl daemon-reload + sudo systemctl enable sendspin-cli + sudo /usr/local/bin/sendspin-cli -l + +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. + +### What it actually does + +1. **Checks the architecture.** `x86_64` and `aarch64` have builds. 32-bit ARM does not, and + is refused with the reason and the fix 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 + 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. **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. +7. **Lists this host's sound devices** and prints the two commands that finish the job. + +Re-running it is how you upgrade: it overwrites the same paths and restarts the service. + +### Why 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. + +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. + +## The long way + +If you would rather do it by hand, or the script refuses this host: + +```bash +# 1. Install — see the Installation page for the checksum step +sudo tar -xzf sendspin-cli-0.1.0-linux-arm64.tar.gz --strip-components=1 -C / \ + sendspin-cli-0.1.0-linux-arm64/usr + +# 2. Find a device +sendspin-cli -l + +# 3. Tell it which one +sudo cp /usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example /etc/sendspin-cli.conf +sudo nano /etc/sendspin-cli.conf # output = hw:1,0 + +# 4. Start it +sudo systemctl daemon-reload +sudo systemctl enable --now sendspin-cli +``` + +## Choosing an output + +`sendspin-cli -l` lists every device this host can play through, and for each one the rates, +formats and channel counts it really accepts: + +``` + hw:CARD=Headphones,DEV=0 + bcm2835 Headphones + rates: 8000 11025 16000 22050 32000 44100 48000 + formats: S16_LE + channels: 2 +``` + +Put the name in the config file as `output`, without the dashes of the flag it mirrors: + +```ini +output = hw:1,0 +``` + +Three forms are worth knowing, and there are more in +[Choosing an output](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#choosing-an-output): + +| Value | What it means | +|---|---| +| `hw:1,0` | that card directly, bypassing any sound server | +| `plughw:1,0` | the same card, letting ALSA convert rate and format for it | +| `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. + +## Check it worked + +```bash +systemctl status sendspin-cli +journalctl -u sendspin-cli -f +``` + +A healthy start looks like this: + +``` +I cli: sendspin-cli 0.1.0 listening on port 8928 as "kitchen" (output: hw:1,0) +I mdns: advertising _sendspin._tcp as "kitchen" on port 8928 (path /sendspin) +I control: Listening on /run/sendspin-cli/control.sock +``` + +Then ask the player itself: + +```bash +sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +``` + +`sudo`, and the flag, are both needed under the system unit: the socket is mode `0600` and +owned by root, and root has no `$XDG_RUNTIME_DIR` for the default path to come from. Adding +`control-socket = /run/sendspin-cli/control.sock` to the config saves repeating the flag — +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. + +To go the other way and have the player dial the server instead, set `server` in the config: + +```ini +server = 192.168.1.10 # a host, port 8927 assumed +server = mdns:Music Assistant # or discover one by its advertised name +``` + +Any `server` value turns the mDNS advertisement off. That is the spec's rule rather than a +preference here, and the two modes are mutually exclusive by design — see +[The two connection modes](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-two-connection-modes). + +## Next + +- [Configuration](Configuration) — every key, and what the player remembers by itself +- [Controlling the Player](Controlling-the-Player) — `pause`, `vol`, `delay` and the rest +- [Running as a Service](Running-as-a-Service) — drop-ins, and running as a non-root user +- [Troubleshooting](Troubleshooting) — when it starts and stays silent diff --git a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md new file mode 100644 index 0000000..09ba888 --- /dev/null +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -0,0 +1,149 @@ +# Getting Started on a Raspberry Pi + +A Pi makes an excellent Sendspin endpoint: it is quiet, it is cheap, and one per room is the +whole point of a synchronized multi-room protocol. Installing on one is +[Getting Started on Linux](Getting-Started-on-Linux) — a Pi is an arm64 Linux box and takes +the same `linux-arm64` archive an arm64 server does — plus the five things on this page. + +## 1. You need a 64-bit OS. This is not negotiable + +**The builds are `arm64` only. There is no 32-bit ARM build, and none is coming from CI.** +The matrix has no armv7 or 32-bit Pi leg, which is recorded in +[`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md), +item 12. + +Check what you are running before anything else: + +```console +$ uname -m +aarch64 +``` + +| It says | Where you are | +|---|---| +| `aarch64` | Good. Carry on. | +| `armv7l`, `armv6l` | A 32-bit userland. No archive will install. | + +A 32-bit answer on 64-bit hardware is the common case — Raspberry Pi OS shipped a 32-bit +userland by default for years, and plenty of installed cards still run it. The fix is to +reimage with **Raspberry Pi OS (64-bit)**, or *Raspberry Pi OS Lite (64-bit)* for a headless +player, which is what a Sendspin endpoint wants anyway. In Raspberry Pi Imager the 64-bit +builds are under **Raspberry Pi OS (other)**. + +| Model | 64-bit capable | +|---|---| +| Pi 5, Pi 4, Pi 400, Pi 3, Pi Zero 2 W, CM3/CM4/CM5 | Yes | +| Pi 2 (v1.1 and earlier), Pi 1, Pi Zero / Zero W | **No** — build from source, or use other hardware | + +The getting-started script refuses a 32-bit userland with this whole answer rather than an +"unsupported architecture", because it is the single most common way a Pi install goes +wrong. + +## 2. Install + +Exactly as on any Linux host: + +```bash +curl -fLO https://raw.githubusercontent.com/chrisuthe/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 +``` + +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. + +## 3. The Pi has several sound cards, and you must pick one + +The headphone jack and each HDMI output are separate ALSA cards, and there is no useful +"just play" default under a system unit. Ask: + +```console +$ sendspin-cli -l + hw:CARD=Headphones,DEV=0 + bcm2835 Headphones + rates: 8000 11025 16000 22050 32000 44100 48000 + formats: S16_LE + channels: 2 + hw:CARD=vc4hdmi0,DEV=0 + vc4-hdmi-0 + ... +``` + +Then name it in `/etc/sendspin-cli.conf`: + +```ini +output = hw:1,0 +``` + +`output = default` is what usually leaves a Pi silent as a service, for the reason on the +Linux page: no user session, so nothing for ALSA's `default` to follow. + +**On a USB DAC or a HAT**, the card is in that same list — a HiFiBerry, an IQaudIO, a +Pi-DAC and a plain USB DAC all appear as ordinary ALSA cards once their overlay is enabled +in `/boot/firmware/config.txt`. Enable the overlay, reboot, and run `-l` again; this player +has nothing Pi-HAT-specific to configure. + +**The 3.5 mm jack is not a good listening output.** It is a PWM output on the SoC and it +sounds like one. It is fine for proving the chain works. For anything else, use HDMI to a +receiver, a USB DAC, or an I²S HAT. + +## 4. `/dev/snd` belongs to root and the `audio` group + +The systemd unit runs as root, which already has the sound card, so a service install needs +nothing here. Two cases do: + +**Running it from your own shell.** 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" +``` + +Without it the player starts and opens nothing, because `/dev/snd/*` is `root:audio` mode +`0660`. + +**Running the service as a non-root user.** Both lines together, never one alone: + +```bash +sudo systemctl edit sendspin-cli +``` + +```ini +[Service] +User=sendspin +SupplementaryGroups=audio +``` + +A user with no `audio` membership is a player that starts and cannot open a device. Create +the user first (`sudo useradd --system --no-create-home sendspin`) and chown any existing +`/var/lib/sendspin-cli` to it. See +[Running as a Service](Running-as-a-Service). + +## 5. Things a Pi does that a server does not + +- **SD cards wear out.** The player rewrites its state file on volume changes, which it + already coalesces — it skips the write when nothing changed. That is the whole of the + write traffic. If you are running from SD and want even that gone, point `--state-dir` at + a tmpfs and accept that volume and static delay are forgotten across reboots. +- **Wi-Fi power saving breaks mDNS.** A Pi that vanishes from your controller after a few + idle minutes and comes back when you ping it is the wireless NIC sleeping, not this + player. `sudo iw dev wlan0 set power_save off` is the usual fix; Ethernet avoids it + entirely, and a fixed endpoint deserves a cable. +- **Underruns on a busy Pi** show as clicks or dropouts. Raise the buffer: + `buffer-ms = 250` in the config. The default is 100 ms and the range is 10–2000; see + [Buffering, and what gets advertised](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#buffering-and-what-gets-advertised). +- **`avahi-daemon` is what provides mDNS on a Pi**, and Raspberry Pi OS ships it running. If + you have turned it off, the player warns and retries rather than failing — but nothing will + discover it until it is back. +- **One player per Pi.** Two on one host need different `--port`, different `--state-dir` + and different control sockets. It works; it is just not what a Pi is usually for. + +## Next + +- [Configuration](Configuration) — every key the config file takes +- [Controlling the Player](Controlling-the-Player) — including `delay`, for a Pi feeding an + amplifier that adds latency of its own +- [Troubleshooting](Troubleshooting) — silent player, no discovery, and the rest diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..bc9087b --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,91 @@ +# sendspin-cli + +A headless **Sendspin audio player** for Linux and macOS — what squeezelite is to +Lyrion/Logitech Media Server, `sendspin-cli` is to the +[Sendspin](https://github.com/Sendspin/spec) protocol. It advertises itself over mDNS, +waits for a Sendspin server to find it, plays what it is sent in sync with every other +player in the group, and takes its flags and its ergonomics from squeezelite so that +muscle memory carries over. + +> **Status: early scaffold.** The player works; not everything on the roadmap is built. +> [`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) +> is the honest list of what is and is not done. + +## Start here + +| | | +|---|---| +| [Getting Started on Linux](Getting-Started-on-Linux) | One script, from nothing to a player on the network | +| [Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi) | The same script, plus what a Pi does differently | +| [Installation](Installation) | Every way in: release archive, macOS `.pkg`, source | +| [Configuration](Configuration) | The config file, and what the player remembers by itself | +| [Controlling the Player](Controlling-the-Player) | `sendspin-cli pause` and the other thirteen subcommands | +| [Running as a Service](Running-as-a-Service) | The systemd unit, drop-ins, and reading the log | +| [Troubleshooting](Troubleshooting) | It starts and makes no sound, and the rest | + +## What it does, in one screen + +```console +$ sendspin-cli -n living-room +I cli: sendspin-cli 0.1.0 listening on port 8928 as "living-room" (output: default) +I mdns: advertising _sendspin._tcp as "living-room" on port 8928 (path /sendspin) +``` + +That is the whole of the usual setup: nothing to configure on either end. A Sendspin +server discovers the advertisement and dials in. `-s ` inverts it and makes this +player the one dialling, which the protocol treats as the other of two mutually exclusive +modes — see +[The two connection modes](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-two-connection-modes). + +Audio goes out through ALSA (the Linux default) or PortAudio (the cross-platform one, and +the only way to make noise on macOS), with volume applied in software on a curve the spec +names. The player is also driven from its own host over a Unix socket: + +```console +$ sendspin-cli status +$ sendspin-cli pause +$ sendspin-cli vol 40 +``` + +## Supported platforms + +| Platform | Architecture | How | +|---|---|---| +| Linux | `x86_64`, `arm64` | Release tarball, or `scripts/get_started_linux.sh` | +| macOS 12+ | Apple silicon (`arm64`) | Release tarball or installer `.pkg` | +| Raspberry Pi | `arm64` only — **a 64-bit OS is required** | The Linux tarball, same as any arm64 host | + +There is no 32-bit ARM build and no Intel-Mac build. The CI matrix has no armv7, 32-bit Pi +or macOS `x86_64` leg, which is recorded in +[`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md), +item 12. +Anything else builds from source. + +## Where things live + +- **[`README.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md)** is + the reference, and ships inside every archive at + `usr/local/share/doc/sendspin-cli/README.md`. It explains *why* the player behaves as it + does — the two connection modes, how `-o` resolves its argument, why `vol` is the group's + volume and not this box's. These wiki pages link into it rather than restating it, so + there is one copy of each argument and it is the copy an offline tarball holder also has. +- **[`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md)** + is what is built, what is not, and what was actually tested rather than reasoned about. +- **`sendspin-cli --help`** is the flag reference, and the config file's reference too: + every config key is a long flag name minus its dashes. + +## Editing these pages + +**This wiki is generated. Do not edit it here — the edit will be overwritten.** + +The pages are authored in the repository at +[`docs/wiki/`](https://github.com/chrisuthe/sendspin-cpp-cli/tree/main/docs/wiki) and +mirrored here by +[`.github/workflows/wiki.yml`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/.github/workflows/wiki.yml) +on every push to `main`. A page removed there is removed here; a page changed here is put +back on the next push. + +That is deliberate rather than awkward. A wiki edit lands with no pull request, no review +and no CI, which for a document that tells people what commands to run as root is the wrong +default. Send a documentation fix as a pull request against `docs/wiki/`, the same way a +code fix goes, and it arrives here when it merges. diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md new file mode 100644 index 0000000..ef81845 --- /dev/null +++ b/docs/wiki/Installation.md @@ -0,0 +1,180 @@ +# Installation + +Four ways in, depending on what you have. If you are on Linux and want the short version, +[Getting Started on Linux](Getting-Started-on-Linux) does all of this in one command. + +| You have | Take | +|---|---| +| A Linux box or a Raspberry Pi | The `linux-x86_64` or `linux-arm64` tarball | +| An Apple-silicon Mac | The `macos-arm64` installer `.pkg`, or the tarball | +| Anything else | [Build from source](#build-from-source) | + +Everything published is on the +[Releases page](https://github.com/chrisuthe/sendspin-cpp-cli/releases). Per-commit builds +of unreleased work are under the repository's Actions tab and expire after 14 days — see +[CI](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#ci). + +> **No release exists yet.** `v0.1.0` has not been tagged at the time of writing, so until +> it is, the only routes are building from source or staging a payload yourself. The +> getting-started script says the same thing rather than failing obscurely. + +## What is in the archive + +Every tarball is a staged `cmake --install` payload, which means **every path under `usr/` +is the path the file installs to**: + +``` +sendspin-cli-0.1.0-linux-arm64/ +├── BUILD-INFO.txt # what this build is, and what it needs +└── usr/local/ + ├── bin/sendspin-cli + ├── lib/systemd/system/sendspin-cli.service # Linux only + └── share/doc/sendspin-cli/ + ├── README.md + ├── LICENSE + └── sendspin-cli.conf.example +``` + +The prefix is baked in at build time — the unit's `ExecStart` is an absolute +`/usr/local/bin/sendspin-cli` — so a binary moved somewhere else leaves the unit pointing at +nothing. Read `BUILD-INFO.txt` first; it names the runtime packages that build needs. + +## Linux + +```bash +# 1. Take the archive for this machine's architecture, and the checksums +VERSION=0.1.0 +ARCH=$(uname -m); [ "$ARCH" = aarch64 ] && LEG=linux-arm64 || LEG=linux-x86_64 +BASE=https://github.com/chrisuthe/sendspin-cpp-cli/releases/download/v$VERSION +curl -fLO "$BASE/sendspin-cli-$VERSION-$LEG.tar.gz" +curl -fLO "$BASE/SHA256SUMS" + +# 2. Verify before unpacking anything +sha256sum --ignore-missing -c SHA256SUMS + +# 3. Install +sudo tar -xzf "sendspin-cli-$VERSION-$LEG.tar.gz" --strip-components=1 -C / \ + "sendspin-cli-$VERSION-$LEG/usr" +sudo systemctl daemon-reload +``` + +`--ignore-missing` because `SHA256SUMS` covers every archive the release carries and you +have taken one of them; without it the other three are reported as failures. It is not a +way of passing with nothing checked — `sha256sum` still exits non-zero if the flag leaves +it with no file to verify. + +**Naming `/usr` as the member to extract is what leaves `BUILD-INFO.txt` in the +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: + +```bash +sudo apt install libasound2t64 libportaudio2 libavahi-compat-libdnssd1 # Debian / Ubuntu +sudo dnf install alsa-lib portaudio avahi-compat-libdns_sd # Fedora / RHEL +``` + +To run it without installing anywhere, unpack it and use it in place: + +```bash +tar -xzf sendspin-cli-0.1.0-linux-arm64.tar.gz +./sendspin-cli-0.1.0-linux-arm64/usr/local/bin/sendspin-cli --help +``` + +Then [Running as a Service](Running-as-a-Service) for the systemd half. + +## macOS + +The installer is the easier of the two: + +```bash +sudo installer -pkg sendspin-cli-0.1.0-macos-arm64.pkg -target / +sendspin-cli --version +``` + +It refuses a Mac it cannot run on — the architectures are read off the binary at build time +and declared in the package — so an Intel Mac is turned away rather than told the install +worked. To undo it: remove the four files and +`sudo pkgutil --forget io.github.chrisuthe.sendspin-cli`. + +Or take the tarball, and **unpack it from a terminal, not in Finder**: + +```bash +tar -xzf sendspin-cli-0.1.0-macos-arm64.tar.gz +./sendspin-cli-0.1.0-macos-arm64/usr/local/bin/sendspin-cli --version +``` + +**Neither the binary nor the `.pkg` is signed or notarized.** They are ad-hoc signed — the +minimum an arm64 Mach-O needs to execute at all — so Gatekeeper has no developer identity +to check. Whether you notice depends entirely on the quarantine flag, which `tar` and +`unzip` do not propagate and Finder's Archive Utility does. If macOS refuses it: + +```bash +xattr -d com.apple.quarantine ./sendspin-cli-0.1.0-macos-arm64/usr/local/bin/sendspin-cli +``` + +The full picture — including why `sudo installer` is not gated at all, and why the `.pkg` +exists despite not fixing Gatekeeper — is in +[macOS, and Gatekeeper](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#macos-and-gatekeeper) +and +[The macOS installer `.pkg`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-macos-installer-pkg). +A Developer ID signature and notarization are owed and tracked as roadmap item 10. + +There is no launchd job in the payload. On macOS the player runs from a shell or under a +launch agent you write. + +## Raspberry Pi + +The Pi takes the `linux-arm64` archive like any other arm64 Linux host, with one hard +requirement: **a 64-bit OS**. See +[Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi). + +## Build from source + +For an architecture with no release — 32-bit ARM, an Intel Mac, anything not in the matrix +— or to build against a different version of the library. + +```bash +sudo apt install pkg-config libasound2-dev portaudio19-dev libavahi-compat-libdnssd-dev # Debian / Ubuntu +sudo dnf install pkgconf alsa-lib-devel portaudio-devel avahi-compat-libdns_sd-devel # Fedora / RHEL +brew install portaudio pkgconf # macOS + +git clone https://github.com/chrisuthe/sendspin-cpp-cli.git +cd sendspin-cpp-cli +cmake -B build +cmake --build build +./build/sendspin-cli --help +``` + +Needs CMake ≥ 3.16, a C++20 compiler, and network access on the first configure — +sendspin-cpp is pulled in with `FetchContent` at a pinned tag and fetches its own +dependencies in turn. + +**The audio backends and mDNS are optional and auto-detected**, so read the configure +output rather than assuming; a missing `-dev` package does not fail a configure, it just +produces a binary that cannot do that thing: + +``` +-- sendspin-cli audio backends: null, stdout, alsa, portaudio +-- sendspin-cli mDNS: dns_sd (/usr/lib/aarch64-linux-gnu/libdns_sd.so) +``` + +To install what you built: + +```bash +cmake -B build -DCMAKE_INSTALL_PREFIX=/usr/local # the prefix is chosen HERE +cmake --build build +sudo cmake --install build --component sendspin-cli +``` + +`--component sendspin-cli` is not garnish — without it, `cmake --install` also stages 143 +files belonging to a fetched dependency. The prefix is fixed at *configure* time because +the unit's `ExecStart` names it absolutely, so reconfigure rather than passing +`--install --prefix`. Both points, at length, in +[Install](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#install). + +## Next + +- [Configuration](Configuration) — pick an output device and make it stick +- [Running as a Service](Running-as-a-Service) — the systemd unit +- [Controlling the Player](Controlling-the-Player) — drive it from its own host diff --git a/docs/wiki/Running-as-a-Service.md b/docs/wiki/Running-as-a-Service.md new file mode 100644 index 0000000..067f091 --- /dev/null +++ b/docs/wiki/Running-as-a-Service.md @@ -0,0 +1,202 @@ +# Running as a Service + +One systemd unit ships in the Linux payload, with sane defaults and nothing to fill in. + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now sendspin-cli +systemctl status sendspin-cli +journalctl -u sendspin-cli -f +``` + +The unit is installed at `/usr/local/lib/systemd/system/sendspin-cli.service`, which is +already on systemd's search path — nothing needs copying by hand. `daemon-reload` after +installing is what makes systemd notice it. + +> **Set an `output` before enabling it.** A system unit has no user session, so ALSA's +> `default` PCM has no PipeWire or PulseAudio to follow and usually will not open. The unit +> is `Restart=on-failure` with `RestartSec=5`, so a player that cannot open its device +> retries every five seconds indefinitely. Run `sendspin-cli -l`, pick a card, and put +> `output = hw:1,0` in `/etc/sendspin-cli.conf`. This is why +> [`scripts/get_started_linux.sh`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/scripts/get_started_linux.sh) +> enables the unit but does not start it until an output is configured. + +## What the payload installs + +| Path | What | +|---|---| +| `/usr/local/bin/sendspin-cli` | the binary | +| `/usr/local/lib/systemd/system/sendspin-cli.service` | the unit | +| `/usr/local/share/doc/sendspin-cli/README.md` | the reference | +| `/usr/local/share/doc/sendspin-cli/LICENSE` | Apache 2.0 | +| `/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example` | an annotated config | + +The unit goes in `lib/systemd/system` and not a multiarch `libdir` because a unit file is +architecture-independent, and systemd reads `/usr/lib/systemd/system` and +`/usr/local/lib/systemd/system` — never `lib/x86_64-linux-gnu/systemd/system`. + +**`ExecStart` names the binary absolutely**, at the prefix the build was configured for, so +a binary moved out of `/usr/local` leaves the unit pointing at nothing. + +## The shape of it + +`Type=simple`, running the player in the **foreground**, so the log goes to the journal +rather than to a file something has to rotate. `-z` and `-f` would both be working around +the supervisor. Two other shapes exist and are not what ships: `Type=forking` with +`PIDFile=` pointing at `-P` is right for a supervisor with no journal, and `Type=notify` is +unavailable because `sd_notify` is not wired up. + +`After=network.target sound.target` and `After=avahi-daemon.service` — ordering only, and +deliberately no `Wants=`. The player retries its advertisement and its outbound dial on a +backoff, so it comes up perfectly well ahead of the network, and a host whose operator +turned `avahi-daemon` off should stay that way rather than have this unit pull it back in. + +## The two flags on the `ExecStart` line + +Both are there because a system unit has neither of the environment variables the default +path would come from: + +| Unit directive | Flag it pairs with | Without the pair | +|---|---|---| +| `RuntimeDirectory=sendspin-cli` | `--control-socket /run/sendspin-cli/control.sock` | no `$XDG_RUNTIME_DIR`, so no control socket — one warning, and the player carries on | +| `StateDirectory=sendspin-cli` | `--state-dir /var/lib/sendspin-cli` | no `$XDG_STATE_HOME`, so volume, mute and the static delay are forgotten every restart | + +systemd creates and owns both directories, and removes the runtime one when the unit stops +— which is why this unit never meets a stale socket. + +**One consequence**, since the command line beats the config file per option: `state-dir` +and `control-socket` in `/etc/sendspin-cli.conf` are *silently ignored* by the service. +Setting `control-socket` to the same path is still worth doing — it is what lets a +*subcommand* find the socket with no flags. + +## Configure it in the config file, not the unit + +Every config key is a long flag name, so there is nothing the `ExecStart` line can say that +`/etc/sendspin-cli.conf` cannot. Editing the unit means merging your changes by hand on +every upgrade; editing the config does not. See [Configuration](Configuration). + +```bash +sudo nano /etc/sendspin-cli.conf +sudo systemctl restart sendspin-cli +``` + +A config file that does not parse exits non-zero, and `Restart=on-failure` retries it every +five seconds indefinitely. That is the wanted end of it rather than an oversight: the parse +error names the file and its line in the journal on every attempt, and an operator who fixes +the file gets a player back without also having to `systemctl reset-failed` a unit that gave +up. + +## The subcommands need `sudo` here + +The control socket is mode `0600` and the service is root's, so an unprivileged shell cannot +connect to it — and root has no `$XDG_RUNTIME_DIR` for the default path to come from: + +```bash +sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +``` + +Adding `control-socket = /run/sendspin-cli/control.sock` to the config removes the flag. The +`sudo` stays. See [Controlling the Player](Controlling-the-Player). + +## It runs as root, and that is a decision + +There is no `User=` and no hardening block, so the player runs as root — its WebSocket +server, which listens on the network, included. A tarball has no `postinst` to create a +dedicated user with, and root already has the sound card (`/dev/snd` is `root:audio` mode +`0660`), the mDNS daemon and `/run` with nothing to arrange first. + +> A change to that user model is in flight — a dedicated user with a hardening block is being +> worked on in a separate change. This page describes what `main` ships today. + +A drop-in is where to change it now: + +```bash +sudo systemctl edit sendspin-cli +``` + +```ini +[Service] +User=sendspin +SupplementaryGroups=audio +``` + +**Both lines together, and neither alone**: a user with no membership of `audio` is a player +that starts and cannot open a device. Create the user first and hand it the state directory +once, since systemd owns the directory it creates but not one a root-run player left behind: + +```bash +sudo useradd --system --no-create-home sendspin +sudo chown -R sendspin /var/lib/sendspin-cli +``` + +An `ExecStart=` in a drop-in has to be cleared first — `ExecStart=` on its own line, then +the replacement — which is systemd's rule for every list-valued directive rather than +anything about this unit. + +## Reading the log + +Everything goes to the journal, and every line carries a level letter and a subsystem tag: + +```console +$ journalctl -u sendspin-cli -f +I cli: sendspin-cli 0.1.0 listening on port 8928 as "kitchen" (output: hw:1,0) +I mdns: advertising _sendspin._tcp as "kitchen" on port 8928 (path /sendspin) +I sendspin.ws_server: Starting server on port: 8928 (max connections: 4) +``` + +The third line is the library's. That is the point of the format — it is the shape +sendspin-cpp's own logging already emits, so one `grep` reaches either half: + +```bash +journalctl -u sendspin-cli | grep ' mdns:' # this player's mDNS lines +journalctl -u sendspin-cli | grep ' sendspin\.' # the library's, all of them +journalctl -u sendspin-cli -p err # only failures +``` + +Ours are `cli`, `audio`, `mdns`, `discovery`, `outbound`, `player`, `metadata` and +`control`; the library's are all `sendspin.`. + +Turn it up with `log-level = debug` in the config. One level covers this player and the +library together — deliberately, so a single key turns up everything about one run. Fatal +startup errors are **not** gated by it: `none` means "do not narrate", not "exit without +saying why". + +Lines are not timestamped by the player under systemd, because journald already stamps them +and a second one would be noise. Only a `-f` logfile gets our own timestamp. See +[Logging](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#logging). + +## 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: + +```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, and the two Directory= lines +systemctl --user daemon-reload +systemctl --user enable --now sendspin-cli +loginctl enable-linger "$USER" # so it runs when you are not logged in +``` + +The unit that ships is the system one; this is a recipe rather than something the project +installs or tests. + +## Uninstalling + +```bash +sudo systemctl disable --now sendspin-cli +sudo rm -f /usr/local/bin/sendspin-cli +sudo rm -f /usr/local/lib/systemd/system/sendspin-cli.service +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 +``` + +## Next + +- [Configuration](Configuration) +- [Controlling the Player](Controlling-the-Player) +- [Troubleshooting](Troubleshooting) diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md new file mode 100644 index 0000000..a0c2429 --- /dev/null +++ b/docs/wiki/Troubleshooting.md @@ -0,0 +1,325 @@ +# Troubleshooting + +Every failure in this player is meant to say what it is in one line, in the log. Start +there: + +```bash +journalctl -u sendspin-cli -n 50 --no-pager # under systemd +sendspin-cli -d debug # or run it in the foreground, loudly +``` + +## It starts, and there is no sound + +The three common causes, in the order to check them. + +### The device refused the stream's format + +The loudest failure there is, and the one that most looks like health from the outside: the +player is connected, the server thinks it is playing, and the audio is being thrown away. + +``` +E audio: alsa: 'hw:1,0' rejected S24_3LE for 96000 Hz / 2 ch / 24-bit: Invalid argument +E audio: alsa: 'hw:1,0' is not open -- discarding audio until a stream reconfigures it +``` + +`status` shows the same thing more quietly — **a `stream: receiving` line with no format +after the device name**: + +```console +$ sendspin-cli status | grep -E 'stream|output' +stream: receiving +output: hw:1,0 +``` + +Fix it by giving ALSA permission to convert, which is what the `plug` layer is for: + +```ini +output = plughw:1,0 +``` + +`hw:` is the card exactly as it is; `plughw:` is the same card with rate and format +conversion in front of it. Use `sendspin-cli -l` to see what the bare device really takes — +only the four formats this player can emit are listed, since anything else is unreachable +anyway. + +### `output = default` under a system unit + +The single most common first-install problem, and it usually shows as the unit failing +rather than as silence: + +``` +E audio: alsa: cannot open 'default': No such file or directory +``` + +A systemd **system** unit has no user session, so there is no PipeWire or PulseAudio for +ALSA's `default` PCM to follow. Name a card instead: + +```bash +sendspin-cli -l # find it +sudo nano /etc/sendspin-cli.conf # output = hw:1,0 +sudo systemctl restart sendspin-cli +``` + +Under a **user** unit, or from your own shell, `default` is right again. + +### The player has the wrong device, or none + +```console +$ sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +output: null +``` + +`output: null` means audio is being discarded on purpose — either `output = null` in the +config, or a build with no audio backend at all. Check what the binary has: + +```console +$ sendspin-cli -l +Output devices (-o): + null discard audio; needs no sound card at all + ... +``` + +A build with no `alsa` in that list was configured without `libasound2-dev` present. See +[Installation](Installation). + +## The unit keeps restarting + +```console +$ systemctl status sendspin-cli + Active: activating (auto-restart) (Result: exit-code) +``` + +`Restart=on-failure` with `RestartSec=5` retries indefinitely, so the journal has the reason +repeated once every five seconds. The two usual ones: + +**A config file that does not parse.** It names the file and the line, every attempt: + +``` +error: /etc/sendspin-cli.conf:4: invalid --buffer-ms '5' -- expected 10-2000 +``` + +Fix the line and the player comes back on its own — there is no `systemctl reset-failed` to +do, which is deliberate. + +**A device that will not open.** See above. + +## Nothing discovers it + +### Check it is advertising + +```console +$ journalctl -u sendspin-cli | grep mdns +I mdns: advertising _sendspin._tcp as "kitchen" on port 8928 (path /sendspin) +``` + +If that line is absent, one of three things is true. + +**`server` is set.** Any `-s` or `server =` makes this player the one dialling, which +suppresses the advertisement — the spec forbids advertising while this end initiates, and +the run says so: + +``` +Not advertising _sendspin._tcp: -s makes this player the one initiating the connection, +and the Sendspin spec forbids advertising while it is +``` + +There is deliberately no flag that turns both modes on together. See +[The two connection modes](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-two-connection-modes). + +**`no-mdns` is set.** Remove it. + +**There is no mDNS daemon.** On Linux that is `avahi-daemon`, reached through +`libavahi-compat-libdnssd`. The player warns and retries on a backoff rather than failing, +because a player nobody can find is still a player: + +```bash +systemctl status avahi-daemon +sudo apt install avahi-daemon libavahi-compat-libdnssd1 +``` + +**Or this build has no mDNS at all**, which it says at parse time rather than starting and +quietly finding nothing: + +```console +$ sendspin-cli +This build has no mDNS support, so it cannot be discovered: point a server at +ws://:8928/sendspin, or dial one with -s. +``` + +Rebuild with `libavahi-compat-libdnssd-dev` present, or point the server at the URL by hand. + +### It advertises and still nothing finds it + +- **mDNS does not cross subnets or most VLANs.** The server and the player must be on the + same broadcast domain, or you need an mDNS reflector on the router. +- **Wi-Fi power saving** puts the NIC to sleep and the advertisement with it. Common on a + Raspberry Pi: `sudo iw dev wlan0 set power_save off`, or use Ethernet. +- **Docker's default bridge network does not carry mDNS.** Use `--network host`. +- **A firewall blocking UDP 5353** blocks discovery, and TCP on `--port` (8928 by default) + blocks the connection that follows it. + +Verify the advertisement independently: + +```bash +avahi-browse -rt _sendspin._tcp # Linux +dns-sd -B _sendspin._tcp # macOS +``` + +## The subcommands cannot find the player + +```console +$ sendspin-cli status ; echo $? +error: no sendspin-cli is listening on /run/user/1000/sendspin-cli-8928.sock. Start one, or +point this at the right socket with --control-socket -- and note that a non-default --port +moves the default path, so the same --port has to be given here +3 +``` + +Exit `3` is "nothing is listening on that socket", which is almost always one of: + +**The player is on a different `--port`.** The socket path carries the port, so a subcommand +needs the same one: + +```bash +sendspin-cli status --port 9000 +``` + +**It is the systemd system unit**, whose socket is somewhere else and is root's: + +```bash +sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +``` + +**There is no control socket at all.** A system unit with no `RuntimeDirectory=` pairing +warns once and carries on: + +``` +W control: No control socket: $XDG_RUNTIME_DIR is not set, so there is no user-private +directory to put a control socket in. Give --control-socket to choose one, or +--no-control to stop asking +``` + +**Or `no-control` is set**, which turns the channel off deliberately. + +The other statuses are on [Controlling the Player](Controlling-the-Player). The one worth +repeating here: **`4` is "no server connection" and `5` is "the server does not offer that +command"**, and they are kept apart because a dropped connection empties the server's +command list, so collapsing them would send you to read your server's capabilities when the +truth is that nothing is connected. + +## "another sendspin-cli is already running" + +```console +$ sendspin-cli +E control: another sendspin-cli is already running -- it holds the lock on +/run/user/1000/sendspin-cli-8928.sock.lock +``` + +Exactly what it says: a live player holds an exclusive lock. **Leftover files are not the +cause and need no cleanup** — the lock is a `flock()` held for the process's life, so a +player killed with `SIGKILL` has its descriptor closed by the kernel and its leftover socket +is simply taken over by the next start. Nothing ever parses a stale file. + +The same wording, from the same helper, covers `-P`: + +```console +$ sendspin-cli -z -P /run/sendspin-cli.pid +error: another sendspin-cli is already running -- it holds the lock on /run/sendspin-cli.pid +``` + +Find it and decide: + +```bash +systemctl status sendspin-cli +pgrep -a sendspin-cli +``` + +To run a second player on purpose, give it its own `--port`, its own `--control-socket` and +its own `--state-dir` — they share the state file otherwise, and the second to save its +volume overwrites the first's. + +## `status` is telling me something odd + +**`position` runs to the end of the track in seconds.** That is `output = null`. The null +sink consumes instantly and paces nothing, so the server sends the whole track as fast as it +can and `status` faithfully reports a server that believes it has finished. Behind a real +device it advances at 1×. Not a bug, and not visible on any sink with a clock. + +**`state`, `position`, `repeat` and `shuffle` are stale.** All four are the server's last +word, and the spec does not oblige it to resend them after acting — `shuffle` reading `off` +while it is demonstrably shuffling has been observed against a real server. If you have just +changed something and the figure has not moved, that is the likely reason rather than a +failed command. The block's own `note:` line says so. + +**`position` says `(estimated)`.** Expected while playing: the library interpolates forward +from the last progress the server sent, so after a seek the server does not re-report, the +estimate drifts by however far you jumped. + +**`state: unknown`.** The server has sent no progress yet. Nothing is wrong. + +## Audio drops out, or clicks + +Raise the buffer. The default is 100 ms, and the range is 10–2000: + +```ini +buffer-ms = 250 +``` + +That is one figure for every backend — ALSA divides it into periods, PortAudio makes it the +ring size, and a device-less sink ignores it. A figure smaller than one device buffer is +raised to the floor and says so at `debug`. See +[Buffering, and what gets advertised](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#buffering-and-what-gets-advertised). + +If it is one speaker out of sync with the others rather than dropping out, that is +`delay`, not `buffer-ms` — see [Controlling the Player](Controlling-the-Player). + +## macOS: "cannot be opened because the developer cannot be verified" + +The binaries are **ad-hoc signed** — the minimum an arm64 Mach-O needs to execute at all — +so they carry no developer identity and Gatekeeper has nothing to check them against. +Whether you meet it depends entirely on the quarantine flag, which `tar` and `unzip` do not +propagate and Finder's Archive Utility does. + +```bash +xattr -d com.apple.quarantine ./sendspin-cli-0.1.0-macos-arm64/usr/local/bin/sendspin-cli +``` + +Unpacking from a terminal avoids it in the first place. `sudo installer -pkg … -target /` +makes no Gatekeeper assessment at all, and files it puts on disk are never quarantined. + +A Developer ID signature and notarization are owed, and tracked as roadmap item 10. The full +account of all four ways you can come by these files is in +[macOS, and Gatekeeper](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#macos-and-gatekeeper). + +## The daemon exits and says nothing + +`-z` with no `-f` is the one case where a failure can be genuinely invisible: the parent +returns `0` immediately, and everything after the fork — the output device, the WebSocket +server, mDNS — can only report into a log that is going to `/dev/null`. The player warns +about exactly this at startup. + +Give it a logfile, or do not daemonize: + +```bash +sendspin-cli -z -f /var/log/sendspin-cli.log -P /run/sendspin-cli.pid +``` + +Under systemd, neither flag belongs: `Type=simple` in the foreground puts everything in the +journal. See +[Running as a daemon](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#running-as-a-daemon). + +## Still stuck + +Collect this and open an issue at +[the repository](https://github.com/chrisuthe/sendspin-cpp-cli/issues): + +```bash +sendspin-cli --version +uname -srm +sendspin-cli -l +sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock +journalctl -u sendspin-cli -n 100 --no-pager +``` + +Say which of the two connection modes you are in — waiting to be discovered, or dialling +with `server` — because almost everything about the failure differs between them. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md new file mode 100644 index 0000000..af3efa1 --- /dev/null +++ b/docs/wiki/_Sidebar.md @@ -0,0 +1,26 @@ +### sendspin-cli + +- [Home](Home) + +**Get it running** + +- [Getting Started on Linux](Getting-Started-on-Linux) +- [Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi) +- [Installation](Installation) + +**Use it** + +- [Configuration](Configuration) +- [Controlling the Player](Controlling-the-Player) +- [Running as a Service](Running-as-a-Service) + +**When it misbehaves** + +- [Troubleshooting](Troubleshooting) + +--- + +- [Repository](https://github.com/chrisuthe/sendspin-cpp-cli) +- [README](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md) +- [Roadmap](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) +- [Releases](https://github.com/chrisuthe/sendspin-cpp-cli/releases) diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh new file mode 100755 index 0000000..c26abd9 --- /dev/null +++ b/scripts/get_started_linux.sh @@ -0,0 +1,554 @@ +#!/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. +# +# Installs a released sendspin-cli on a Linux host and sets its systemd unit up. +# +# One script for every Linux host, a Raspberry Pi included, because a Pi *is* an arm64 +# Linux box here: it takes the same `linux-arm64` archive an arm64 server takes and +# installs it the same way. What is genuinely Pi-specific is advice -- the `audio` group, +# and that the headphone jack and HDMI are separate cards -- and that is printed at the end +# when a Pi is what this is running on. A second script would have been this one with two +# paragraphs changed, and the two would have drifted. +# +# The archive is GitHub's, verified against the release's own SHA256SUMS, and unpacked with +# the member-selected `tar` form README documents -- naming `/usr` is what leaves +# BUILD-INFO.txt in the archive instead of writing it to `/`. +# +# WHAT IT DELIBERATELY DOES NOT DO IS START A PLAYER THAT CANNOT PLAY. 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 fine from your shell usually will not open under +# `systemctl` -- README's "The systemd unit" says to expect exactly that. The unit's +# `Restart=on-failure`/`RestartSec=5` would then retry it every five seconds, forever, while +# this script printed congratulations. So the unit is *enabled* but only *started* once an +# `output` has been chosen: with one already in /etc/sendspin-cli.conf this installs and +# restarts, and without one it stops after listing the host's devices and prints the two +# commands that finish the job. That is this repo's refuse-rather-than-limp habit -- the same +# reason a bad `-s` port stops the player instead of dialling the default. +# +# Nothing here runs as root without saying so first: every privileged command is printed in +# full, and then either confirmed at a terminal or authorised up front with --yes. +# +# One asymmetry worth naming, in the spirit of the one .github/workflows/ci.yml names about +# itself: the `shellcheck` job there lints every script under scripts/, and the other two are +# also *run* on every build -- smoke_test.sh on each publishing leg, build_macos_pkg.sh on the +# macOS one. This is the first script CI lints but never executes. A CI leg for it would want +# a runner willing to take a payload into `/` and a sound card to then not find, so what it +# has instead is the container run recorded in the pull request that added it. +# +# Usage: scripts/get_started_linux.sh [--version ] [--yes] +# +# --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. + +set -euo pipefail + +# The repository releases are taken from. A constant rather than an environment knob: a +# get-started script that can be pointed at any repository is a get-started script that can +# be pointed at somebody else's binary. +readonly REPO='chrisuthe/sendspin-cpp-cli' + +readonly UNIT='sendspin-cli' +readonly UNIT_FILE='/usr/local/lib/systemd/system/sendspin-cli.service' +readonly BINARY='/usr/local/bin/sendspin-cli' +readonly 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' + +fail() { + printf 'get_started_linux: FAIL: %s\n' "$*" >&2 + exit 1 +} + +say() { + printf '%s\n' "$*" +} + +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) + # The usage block above, printed rather than restated -- a second copy is one to + # forget. Everything from the `# Usage:` line to the end of the header. + 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 + +# The release archives are named for the CI leg that built them rather than for `uname -m`, +# so both spellings of 64-bit ARM map onto the one leg that exists. +MACHINE="$(uname -m)" +case "$MACHINE" in + x86_64 | amd64) + LEG='linux-x86_64' + ;; + aarch64 | arm64) + LEG='linux-arm64' + ;; + armv6l | armv7l | armhf | arm) + # The single most common way a Pi install goes wrong, so it gets the whole answer + # rather than "unsupported architecture". There is no 32-bit build to fall back to -- + # docs/ROADMAP.md item 12 records that the matrix has no armv7 or 32-bit Pi leg -- and + # the fix is a 64-bit OS on hardware that is almost certainly already 64-bit capable. + fail "this is a 32-bit ARM userland ($MACHINE), and the builds are arm64 only. + docs/ROADMAP.md item 12 records why: the CI matrix has no armv7 or 32-bit Pi leg, so no + such archive exists to install. A Raspberry Pi 3 or newer is 64-bit hardware, so the fix + is a 64-bit OS: install Raspberry Pi OS (64-bit), or 'Raspberry Pi OS Lite (64-bit)' for + a headless player, and check with 'uname -m' after -- it must say aarch64. A Pi Zero (the + original), a Pi 1 or a Pi 2 cannot run 64-bit at all, and needs a build from source" + ;; + *) + fail "no release is built for '$MACHINE' -- the archives are linux-x86_64 and + linux-arm64. Build from source instead: https://github.com/$REPO#build" + ;; +esac +readonly MACHINE LEG + +# systemd being *booted* rather than merely installed, which is what decides whether there is +# anything to enable. A container or a chroot without it still gets the binary. +HAVE_SYSTEMD='no' +if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + HAVE_SYSTEMD='yes' +fi +readonly HAVE_SYSTEMD + +# A Pi answers here and nothing else does. `/proc/device-tree/model` is a NUL-terminated +# string out of the device tree, so the NUL is stripped rather than carried into a message. +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 + +# What a printed command line is prefixed with: `sudo ` where one is needed, and nothing at +# all where this is already root -- an empty $SUDO expanded inline leaves every printed +# command indented one space further than the last, and a sentence ending "that needs ." +readonly SUDO_P="${SUDO:+$SUDO }" + +as_root() { + if [ -n "$SUDO" ]; then + "$SUDO" "$@" + else + "$@" + fi +} + +WORK_DIR="$(mktemp -d)" +readonly WORK_DIR +trap 'rm -rf "$WORK_DIR"' EXIT + +say 'sendspin-cli getting started' +say " host: Linux $MACHINE${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 + +# ============================================================================== +# The payload +# ============================================================================== + +# The tag of the newest release, on stdout, or a diagnostic naming which of the ways this can +# come back empty it was. +# +# Read off the `/releases/latest` redirect rather than out of the JSON API, which matters for +# three reasons. It needs no `jq`, absent on a fresh Raspberry Pi OS Lite. It is not subject +# to the API's 60-an-hour unauthenticated rate limit, which is per source address and so is +# shared by everything behind one NAT. And it is the only form that can tell "this repository +# has published no releases" -- which redirects to the releases index -- apart from "there is +# no such repository", which is a 404: the API answers 404 to both. +resolve_latest_tag() { + local headers status location + headers="$WORK_DIR/latest.headers" + + status="$(curl -sSI -o "$headers" -w '%{http_code}' \ + "https://github.com/$REPO/releases/latest")" || + fail "could not reach github.com -- check this host's network and try again" + + case "$status" in + 30[0-9]) ;; + 404) + fail "github.com has no repository at $REPO, or it is not public. Nothing has + been installed" + ;; + *) + fail "github.com answered $status asking for the latest release of $REPO" + ;; + esac + + # curl folds the header name to lower case for `-w`, but the raw header file keeps + # whatever the server sent, so the match is case-insensitive. + location="$(grep -i '^location:' "$headers" | tail -n 1 | tr -d '\r' | awk '{print $2}')" + [ -n "$location" ] || + fail "github.com answered $status with no Location header, which should not happen" + + case "$location" in + */releases/tag/*) + printf '%s\n' "${location##*/releases/tag/}" + ;; + *) + # The state this repository is actually in at the time of writing, so it gets a + # real answer rather than a shrug. + fail "$REPO has published no releases yet, so there is nothing to download. + Until it has, either build from source -- https://github.com/$REPO#build -- or stage a + 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" + ;; + 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" + # Made absolute before anything else, because `tar -C /` below would otherwise resolve a + # relative path against `/`. + TARBALL="$(cd "$(dirname "$SENDSPIN_CLI_TARBALL")" && pwd)/$(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 + # Taken as given rather than looked up: a tag that does not exist fails at the + # download below, naming the file it could not find, which is the same answer one + # round trip earlier would have given. + 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 + against. Refusing to install an unverified binary" + + # Asserted rather than assumed, in the way build.yml asserts the payload's own file list: + # `--ignore-missing` below skips a listed file that is absent, so a SHA256SUMS that never + # mentions this archive at all would leave `sha256sum` with nothing to check. It does exit + # non-zero when that leaves it with no file verified -- but "the checksum file does not + # cover this download" deserves to be said in those words rather than as `-c` failing. + grep -q " $ARCHIVE\$" "$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" + + # --ignore-missing because SHA256SUMS covers every archive the release carries and this + # host has taken one of them. + (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 +# ============================================================================== + +# Read off the archive rather than derived from its filename: a payload staged by hand is +# named whatever its author called it, and the member `tar` is asked to extract has to be one +# that is really in there. +# +# Listed once into a variable, and every question below asked of *that* rather than of a +# second `tar`. Not tidiness: `tar -tzf … | grep -q` is a pipeline whose reader exits on the +# first match, which leaves tar killed by SIGPIPE -- and under `set -o pipefail` that is a +# failed check on an archive that is perfectly fine. +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 + is a staged 'cmake --install' tree, not an archive of loose files" +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 + with DESTDIR from a build configured for the /usr/local prefix: + DESTDIR=/tmp/stage cmake --install build --component sendspin-cli" + +# An `output` already chosen is what decides whether the player is started at the end, so it +# is settled here -- before anything is installed -- and reported in the plan below. +# +# /etc/sendspin-cli.conf alone, and not the two $HOME paths that also come first in the search +# order: this is about the *system* unit, whose process gets whatever HOME systemd hands root, +# and guessing at that would be worse than naming the one file the unit's own documentation +# tells an operator to edit. A line is a comment when its first non-blank character is `#`, so +# an indented `#output = …` is correctly not a match. +CONFIG_HAS_OUTPUT='no' +if [ -f "$CONFIG" ] && 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 [ "$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" + 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 [ "$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] ' + read -r answer + case "$answer" in + y | Y | yes | YES) ;; + *) fail 'nothing was installed' ;; + esac +fi + +# ============================================================================== +# Install +# ============================================================================== + +step 'Installing' +# Idempotent by construction: this overwrites whatever is at those paths, so re-running the +# script is how you upgrade. `--strip-components=1` drops the archive's own top level, so +# every remaining path is the path the file installs to. +as_root tar -xzf "$TARBALL" --strip-components=1 -C / "$PAYLOAD_ROOT/usr" +say " $BINARY" +"$BINARY" --version | sed 's/^/ /' + +if [ "$SEED_CONFIG" = 'yes' ]; then + as_root cp "$CONFIG_EXAMPLE" "$CONFIG" + say " $CONFIG (from the installed example; everything in it is commented out)" +fi + +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:' + say '' + say " $BINARY -l # what this host can play through" + say " $BINARY -o hw:1,0 -n \"\$(hostname)\"" + exit 0 +fi + +[ -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" + +step 'Setting the service up' +as_root systemctl daemon-reload +as_root systemctl enable "$UNIT" +say " enabled: $UNIT starts on boot" + +# ============================================================================== +# Start it, or say what is still owed +# ============================================================================== + +if [ "$CONFIG_HAS_OUTPUT" = 'yes' ]; then + # `restart` and not `start`: this is also the upgrade path, and an already-running player + # would otherwise keep serving the binary that has just been replaced underneath it. + as_root systemctl restart "$UNIT" + + # Asked once rather than polled: the unit is Type=simple, so `restart` returns before + # systemd has decided anything, and a device that will not open takes about a second to + # say so. + 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 '' + journalctl -u "$UNIT" --no-pager -n 15 2>/dev/null | sed 's/^/ /' || + say " (could not read the journal; try: journalctl -u $UNIT -n 50)" + say '' + say " '${SUDO_P}$BINARY -l' lists what this host really has." + 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 +# ============================================================================== + +step 'Next' +if [ "$CONFIG_HAS_OUTPUT" != 'yes' ]; then + 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:' + say '' + say " ${SUDO_P}nano $CONFIG # output = hw:1,0" + say '' + say ' 2. Start it:' + say '' + say " ${SUDO_P}systemctl start $UNIT" + say " systemctl status $UNIT" + say '' + say ' 3. Then:' +else + 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 the service is root's, so reading it needs to be" +say " root too. Put 'control-socket = $CONTROL_SOCKET' in the config to" +say ' stop 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 + say '' + say " On this $PI_MODEL:" + 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 root, which already has /dev/snd. If you change that with a' + say " 'systemctl edit $UNIT' drop-in, the new user needs the audio group in the same" + say " drop-in ('SupplementaryGroups=audio') -- without it the player starts and opens" + say ' nothing.' + 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 '' From 15e479792ff57774b04b85d82e66829e267f8674 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Wed, 19 Aug 2026 17:14:37 -0500 Subject: [PATCH 2/4] Drop two claims the repo cannot support, and the rsync dependency No minimum macOS version is declared anywhere in the tree, so Home.md no longer invents one; what the .pkg really checks is the architecture. The Pi model table stops enumerating edge cases and points at uname -m, which is the only test that decides anything. The wiki mirror empties and refills the clone with find and cp rather than rsync, which is one fewer thing to assume is on the runner image. --- .github/workflows/wiki.yml | 18 +++++++++++++----- docs/wiki/Getting-Started-on-a-Raspberry-Pi.md | 5 ++++- docs/wiki/Home.md | 6 +++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 0a98935..95b9bdf 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -110,14 +110,22 @@ jobs: echo '::error::Could not clone the wiki, and not because it is missing. See the git output above.' exit 1 - # rsync --delete rather than a copy, because the tree is the source of truth in both - # directions: a page removed from docs/wiki/ has to leave the wiki, and one edited in the - # wiki tab has to go back to what the tree says. `.git` is excluded explicitly — deleting - # the clone's own history mid-sync would be a novel way to fail. + # Emptied and refilled rather than copied over, because the tree is the source of truth in + # both directions: a page removed from docs/wiki/ has to leave the wiki, and one edited in + # the wiki tab has to go back to what the tree says. A plain `cp` would only ever add. + # + # `find`/`cp` rather than `rsync --delete`, which would do this in one line: rsync is not + # something this workflow should have to assume is on the runner image, and the two + # commands below say what is happening more plainly than a flag does. + # + # `! -name .git` is load-bearing — deleting the clone's own history mid-sync would be a + # novel way to fail. The mirror is deliberately flat: a GitHub wiki page is one file at + # the wiki root, so docs/wiki/ holds no subdirectories and this copies none. - name: Mirror docs/wiki into it if: steps.clone.outputs.cloned == 'true' run: | - rsync -a --delete --exclude '.git' docs/wiki/ wiki/ + find wiki -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + cp docs/wiki/*.md wiki/ git -C wiki status --porcelain # Nothing to say is the common case — this workflow also runs when only the workflow file diff --git a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md index 09ba888..9c5a0d3 100644 --- a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -33,7 +33,10 @@ builds are under **Raspberry Pi OS (other)**. | Model | 64-bit capable | |---|---| | Pi 5, Pi 4, Pi 400, Pi 3, Pi Zero 2 W, CM3/CM4/CM5 | Yes | -| Pi 2 (v1.1 and earlier), Pi 1, Pi Zero / Zero W | **No** — build from source, or use other hardware | +| Pi 1, Pi Zero, Pi Zero W, and Pi 2 boards before v1.2 | **No** — build from source, or use other hardware | + +If your board is not on either row, do not go looking it up: install a 64-bit image and run +`uname -m`. That answer is the only one that decides anything here. The getting-started script refuses a 32-bit userland with this whole answer rather than an "unsupported architecture", because it is the single most common way a Pi install goes diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index bc9087b..d2dd955 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -52,9 +52,13 @@ $ sendspin-cli vol 40 | Platform | Architecture | How | |---|---|---| | Linux | `x86_64`, `arm64` | Release tarball, or `scripts/get_started_linux.sh` | -| macOS 12+ | Apple silicon (`arm64`) | Release tarball or installer `.pkg` | +| macOS | Apple silicon (`arm64`) | Release tarball or installer `.pkg` | | Raspberry Pi | `arm64` only — **a 64-bit OS is required** | The Linux tarball, same as any arm64 host | +The macOS builds are made on the `macos-14` CI runner and declare no minimum OS version; +what the installer `.pkg` does check is the architecture, read off the binary with `lipo` at +build time, so it turns an Intel Mac away rather than reporting success. + There is no 32-bit ARM build and no Intel-Mac build. The CI matrix has no armv7, 32-bit Pi or macOS `x86_64` leg, which is recorded in [`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md), From eaf34fcf3622cb1ff7e987553793d70f7fa648c5 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Wed, 19 Aug 2026 17:27:24 -0500 Subject: [PATCH 3/4] Correct the docs against the source, and fix a silent exit in the script Review found sample output that was composed rather than captured. The startup log line carries an mDNS field; the status block always emits repeat, shuffle and a note line, and output is last, not note; nothing logs which source a static delay came from; the state file is a std::map and so is written alphabetically; a line whose first non-blank character is # is a comment, indented or not; the device-open failure an operator meets is the startup probe's, not the per-stream one. The SD-wear note stopped claiming a debounce the state store does not have. In the script, resolve_latest_tag() could exit 1 with nothing said at all: with no Location header, grep exits 1, pipefail carries it out of the pipeline, and set -e killed the run before the guard that explains it. The config probe now reads through as_root, so a 0600 /etc/sendspin-cli.conf no longer reads as 'no output configured' and leave a configured player stopped; the journal tail does too, and treats no output as failure rather than printing a blank block. The SHA256SUMS check matches the name exactly instead of as a regex, the unit-file assertion runs before the binary does, and a closed stdin at the prompt says so. The wiki workflow fails on a has_wiki that is neither true nor false rather than reporting it as 'wikis are off' and going green forever, and mirrors docs/wiki whole so a page can carry an image beside it. Three arguments that were stated in full on several pages now have one owner each and links from the rest. --- .github/workflows/wiki.yml | 48 +++++++++++++++---- README.md | 9 ++-- docs/wiki/Configuration.md | 11 +++-- docs/wiki/Controlling-the-Player.md | 13 +++-- docs/wiki/Getting-Started-on-Linux.md | 5 +- .../wiki/Getting-Started-on-a-Raspberry-Pi.md | 21 ++++++-- docs/wiki/Home.md | 2 +- docs/wiki/Running-as-a-Service.md | 22 ++++----- docs/wiki/Troubleshooting.md | 28 +++++------ scripts/get_started_linux.sh | 48 ++++++++++++++----- 10 files changed, 136 insertions(+), 71 deletions(-) diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 95b9bdf..d94d996 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -69,16 +69,36 @@ jobs: run: | has_wiki="$(gh api "repos/$GITHUB_REPOSITORY" --jq '.has_wiki')" echo "has_wiki=$has_wiki" >>"$GITHUB_OUTPUT" - if [ "$has_wiki" != 'true' ]; then - echo "::notice::Wikis are turned off for $GITHUB_REPOSITORY, so docs/wiki was not published. Enable it under Settings -> General -> Features -> Wikis, then re-run this workflow." - fi + + # Only `false` is a reason to skip. An empty answer, a `null`, or anything else this + # field might become is a question that was not answered -- and reporting that as + # "wikis are turned off" would stop publishing indefinitely without ever failing a + # run. The notice below is honest for exactly one value, so only that value gets it. + case "$has_wiki" in + true) ;; + false) + echo "::notice::Wikis are turned off for $GITHUB_REPOSITORY, so docs/wiki was not published. Enable it under Settings -> General -> Features -> Wikis, then re-run this workflow." + ;; + *) + echo "::error::The GitHub API answered '$has_wiki' for .has_wiki, which is neither true nor false. Refusing to guess whether there is a wiki to publish to." + exit 1 + ;; + esac # A wiki that is enabled but has never had a page created has no git repository behind it # yet, and GitHub offers no API to create one — the first page has to be made in the wiki # tab, once, by hand. That is the second setting-shaped skip, and it is recognised by - # git's own message rather than by "the clone failed", so a network failure, a revoked - # token or a rate limit still fails this job. The captured stderr is printed either way: - # a skip nobody can see the evidence for is a skip that hides a real error. + # git's own message rather than by "the clone failed", so a network failure or a rate + # limit still fails this job. The captured stderr is printed either way: a skip nobody + # can see the evidence for is a skip that hides a real error. + # + # One case this cannot separate, stated rather than glossed: GitHub answers a token that + # is not allowed to see the repository with the same "repository not found" it uses for + # one that does not exist, so a permission failure would classify as "no wiki yet" and + # skip green. It is checked above as far as it can be -- `has_wiki` came back from the + # same token one step earlier, so a token that reached the API and then cannot reach the + # wiki is a narrow case -- but the pattern below cannot tell the two apart, and the + # printed stderr is what a reader would have to judge it on. - name: Clone the wiki id: clone if: steps.wiki.outputs.has_wiki == 'true' @@ -119,13 +139,23 @@ jobs: # commands below say what is happening more plainly than a flag does. # # `! -name .git` is load-bearing — deleting the clone's own history mid-sync would be a - # novel way to fail. The mirror is deliberately flat: a GitHub wiki page is one file at - # the wiki root, so docs/wiki/ holds no subdirectories and this copies none. + # novel way to fail. + # + # The whole directory rather than just `*.md`, so that a page needing an image can keep it + # in docs/wiki/ beside itself and have it published too. The other half of the same rule: + # this removes anything the wiki holds that docs/wiki/ does not, an image uploaded through + # the wiki tab included. That follows from the model rather than being an oversight — a + # file that is not in the tree is a file this wiki does not have. + # + # A GitHub wiki page is one file at the wiki root, so docs/wiki/ is kept flat; `-R` is + # here to carry a directory of assets if one is ever added, not as an invitation to nest + # pages, which the wiki would not render as pages anyway. - name: Mirror docs/wiki into it if: steps.clone.outputs.cloned == 'true' run: | find wiki -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + - cp docs/wiki/*.md wiki/ + cp -R docs/wiki/. wiki/ + echo 'What the wiki holds that differs from docs/wiki:' git -C wiki status --porcelain # Nothing to say is the common case — this workflow also runs when only the workflow file diff --git a/README.md b/README.md index 9f5ba70..1a68a1c 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,10 @@ audio-streaming library, taking its command-line and control ergonomics from > is tracked in [`docs/ROADMAP.md`](docs/ROADMAP.md). **New here?** The [wiki](https://github.com/chrisuthe/sendspin-cpp-cli/wiki) is the -task-shaped version of this file — installing, a Raspberry Pi walkthrough, and -troubleshooting — and on Linux -[`scripts/get_started_linux.sh`](scripts/get_started_linux.sh) does the whole install in one -command. Those pages are authored in [`docs/wiki/`](docs/wiki) and mirrored to the wiki tab -on every push to `main`; this file stays the reference, and ships inside every archive. +task-shaped version of this file — installing, a Raspberry Pi walkthrough, troubleshooting — +and on Linux [`scripts/get_started_linux.sh`](scripts/get_started_linux.sh) does the install +in one command. Those pages are authored in [`docs/wiki/`](docs/wiki) and mirrored to the +wiki tab on every push to `main`. ## What it is diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 81361a5..dc9b084 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -67,9 +67,9 @@ minus its dashes, which is what makes `sendspin-cli --help` this file's referenc that had a short flag first — `-o -n -s -P -f -d` — grew long spellings for exactly this reason, so that one vocabulary covers both. -Booleans take `true`/`yes`/`on`/`1` or `false`/`no`/`off`/`0`. `#` starts a comment at the -start of a line only — a name or a path is free to contain one. Where a key appears twice, -the last one wins. +Booleans take `true`/`yes`/`on`/`1` or `false`/`no`/`off`/`0`. A line whose first non-blank +character is `#` is a comment; a `#` anywhere else is not, so a name or a path is free to +contain one. Where a key appears twice, the last one wins. **Five things cannot come from a file**: `-l`, `-z`, `--config`, `--help` and `--version`. Run shape stays on the command line, and a config naming one is refused as an unknown key. @@ -119,9 +119,9 @@ this one. # Written by sendspin-cli. Edits are overwritten. last-server = 7f3a… last-server-hash = 3387423128 +muted = true static-delay-ms = 375 volume = 42 -muted = true ``` | Key | What it is | @@ -153,7 +153,8 @@ one to save its volume overwrites the first's. whatever the state store remembers and reads the config value only when there is nothing remembered — exactly as a restored volume beats the sink's default. So once a server or `sendspin-cli delay` has set one, the remembered value wins every run after and the config -key is inert. The startup log says which of the two it took. +key is inert. Nothing in the log names which of the two won, so `sendspin-cli status` is +where you read the value actually in force. Three things can set it: a server's `set_static_delay`, the `delay` subcommand, and this key on a first run with nothing yet remembered. To make the config value take again, remove the diff --git a/docs/wiki/Controlling-the-Player.md b/docs/wiki/Controlling-the-Player.md index ccd3084..ba61dad 100644 --- a/docs/wiki/Controlling-the-Player.md +++ b/docs/wiki/Controlling-the-Player.md @@ -11,10 +11,13 @@ server: Music Assistant (connected) state: playing stream: receiving track: Nils Frahm - Says -position: 2:05 / 9:03 +position: 2:05 / 9:03 (estimated) group volume: 55 +repeat: off +shuffle: off player volume: 80 static delay: 0 ms +note: state, position, repeat and shuffle are the server's last report; a server that does not resend them after a change will show stale values here output: default (48000 Hz / 2 ch / 16-bit) $ sendspin-cli pause @@ -89,8 +92,8 @@ The long version of all three is in `repeat` and `shuffle` all come from the server's last report, and the spec does not oblige it to resend them after acting. Observed against a real server, `shuffle` read `off` for minutes while it was demonstrably shuffling. If you have just changed something and the -figure has not moved, that is the likely reason — not a failed command. The block ends with -a `note:` line saying so. +figure has not moved, that is the likely reason — not a failed command. The `note:` line +above `output:` says so, and appears whenever a server is connected. - **`position` says `(estimated)` while playing**, because the library interpolates forward from the last progress the server sent. After a seek the server does not re-report, the @@ -126,8 +129,8 @@ sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock ``` Put `control-socket = /run/sendspin-cli/control.sock` in `/etc/sendspin-cli.conf` and the -flag becomes unnecessary — the daemon ignores that key (its own command line wins) but the -subcommands read it. The `sudo` is still needed. +flag becomes unnecessary. The `sudo` is still needed. Why the daemon ignores that same key +while its subcommands read it is [Configuration](Configuration)'s precedence rule. **On macOS the default works with nothing set**, because there is no `$XDG_RUNTIME_DIR` there either and the path comes from the per-user directory under `/var/folders` that diff --git a/docs/wiki/Getting-Started-on-Linux.md b/docs/wiki/Getting-Started-on-Linux.md index 0e41796..88c0ae2 100644 --- a/docs/wiki/Getting-Started-on-Linux.md +++ b/docs/wiki/Getting-Started-on-Linux.md @@ -49,7 +49,8 @@ instead of the newest. that below; it is the one surprising thing the script does. 7. **Lists this host's sound devices** and prints the two commands that finish the job. -Re-running it is how you upgrade: it overwrites the same paths and restarts the service. +Re-running it is how you upgrade: it overwrites the same paths, and restarts the service if +step 6 finds an `output` configured — which after a first run it will. ### Why it does not start the player @@ -126,7 +127,7 @@ journalctl -u sendspin-cli -f A healthy start looks like this: ``` -I cli: sendspin-cli 0.1.0 listening on port 8928 as "kitchen" (output: hw:1,0) +I cli: sendspin-cli 0.1.0 listening on port 8928 as "kitchen" (output: hw:1,0, mDNS: dns_sd (avahi-compat)) I mdns: advertising _sendspin._tcp as "kitchen" on port 8928 (path /sendspin) I control: Listening on /run/sendspin-cli/control.sock ``` diff --git a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md index 9c5a0d3..4fe99fb 100644 --- a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -127,10 +127,23 @@ the user first (`sudo useradd --system --no-create-home sendspin`) and chown any ## 5. Things a Pi does that a server does not -- **SD cards wear out.** The player rewrites its state file on volume changes, which it - already coalesces — it skips the write when nothing changed. That is the whole of the - write traffic. If you are running from SD and want even that gone, point `--state-dir` at - a tmpfs and accept that volume and static delay are forgotten across reboots. +- **SD cards wear out**, and the state file is rewritten whole on every *distinct* volume a + server sends. A repeat of the current value is skipped, but a slider drag is one rewrite + per step; debouncing is a known gap, listed under + [`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) + item 8. If that worries you, point the state at the runtime directory, which is already a + tmpfs — and accept that volume, mute and the static delay are then forgotten across + reboots. It has to be a **drop-in** rather than a config key, because the unit passes + `--state-dir` itself and the command line wins (`sudo systemctl edit sendspin-cli`): + + ```ini + [Service] + ExecStart= + ExecStart=/usr/local/bin/sendspin-cli --control-socket /run/sendspin-cli/control.sock --state-dir /run/sendspin-cli + ``` + + `ExecStart=` on its own line first, which is systemd's rule for clearing any list-valued + directive. - **Wi-Fi power saving breaks mDNS.** A Pi that vanishes from your controller after a few idle minutes and comes back when you ping it is the wireless NIC sleeping, not this player. `sudo iw dev wlan0 set power_save off` is the usual fix; Ethernet avoids it diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index d2dd955..f5ac297 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -27,7 +27,7 @@ muscle memory carries over. ```console $ sendspin-cli -n living-room -I cli: sendspin-cli 0.1.0 listening on port 8928 as "living-room" (output: default) +I cli: sendspin-cli 0.1.0 listening on port 8928 as "living-room" (output: default, mDNS: dns_sd (avahi-compat)) I mdns: advertising _sendspin._tcp as "living-room" on port 8928 (path /sendspin) ``` diff --git a/docs/wiki/Running-as-a-Service.md b/docs/wiki/Running-as-a-Service.md index 067f091..843239c 100644 --- a/docs/wiki/Running-as-a-Service.md +++ b/docs/wiki/Running-as-a-Service.md @@ -13,13 +13,13 @@ The unit is installed at `/usr/local/lib/systemd/system/sendspin-cli.service`, w already on systemd's search path — nothing needs copying by hand. `daemon-reload` after installing is what makes systemd notice it. -> **Set an `output` before enabling it.** A system unit has no user session, so ALSA's -> `default` PCM has no PipeWire or PulseAudio to follow and usually will not open. The unit -> is `Restart=on-failure` with `RestartSec=5`, so a player that cannot open its device -> retries every five seconds indefinitely. Run `sendspin-cli -l`, pick a card, and put -> `output = hw:1,0` in `/etc/sendspin-cli.conf`. This is why +> **Set an `output` before enabling it**, or this unit will fail and be retried every five +> 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/chrisuthe/sendspin-cpp-cli/blob/main/scripts/get_started_linux.sh) -> enables the unit but does not start it until an output is configured. +> enables this unit without starting it. ## What the payload installs @@ -64,10 +64,10 @@ path would come from: systemd creates and owns both directories, and removes the runtime one when the unit stops — which is why this unit never meets a stale socket. -**One consequence**, since the command line beats the config file per option: `state-dir` -and `control-socket` in `/etc/sendspin-cli.conf` are *silently ignored* by the service. -Setting `control-socket` to the same path is still worth doing — it is what lets a -*subcommand* find the socket with no flags. +**One consequence**: `state-dir` and `control-socket` in `/etc/sendspin-cli.conf` are +*silently ignored* by the service, because the command line beats the file per option — see +[Configuration](Configuration). Setting `control-socket` to the same path is still worth +doing, since it is what lets a *subcommand* find the socket with no flags. ## Configure it in the config file, not the unit @@ -139,7 +139,7 @@ Everything goes to the journal, and every line carries a level letter and a subs ```console $ journalctl -u sendspin-cli -f -I cli: sendspin-cli 0.1.0 listening on port 8928 as "kitchen" (output: hw:1,0) +I cli: sendspin-cli 0.1.0 listening on port 8928 as "kitchen" (output: hw:1,0, mDNS: dns_sd (avahi-compat)) I mdns: advertising _sendspin._tcp as "kitchen" on port 8928 (path /sendspin) I sendspin.ws_server: Starting server on port: 8928 (max connections: 4) ``` diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index a0c2429..11a285a 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -48,11 +48,12 @@ The single most common first-install problem, and it usually shows as the unit f rather than as silence: ``` -E audio: alsa: cannot open 'default': No such file or directory +E audio: cannot open ALSA device 'default': No such file or directory -- run with -l to list +this host's PCMs ``` -A systemd **system** unit has no user session, so there is no PipeWire or PulseAudio for -ALSA's `default` PCM to follow. Name a card instead: +Name a card instead of `default`, which under a system unit has no session to follow — +[Getting Started on Linux](Getting-Started-on-Linux) has the whole of why: ```bash sendspin-cli -l # find it @@ -140,10 +141,9 @@ sudo apt install avahi-daemon libavahi-compat-libdnssd1 **Or this build has no mDNS at all**, which it says at parse time rather than starting and quietly finding nothing: -```console -$ sendspin-cli -This build has no mDNS support, so it cannot be discovered: point a server at -ws://:8928/sendspin, or dial one with -s. +``` +I mdns: This build has no mDNS support, so it cannot be discovered: point a server at +ws://:8928/sendspin, or dial one with -s. See docs/ROADMAP.md. ``` Rebuild with `libavahi-compat-libdnssd-dev` present, or point the server at the URL by hand. @@ -275,21 +275,15 @@ If it is one speaker out of sync with the others rather than dropping out, that ## macOS: "cannot be opened because the developer cannot be verified" -The binaries are **ad-hoc signed** — the minimum an arm64 Mach-O needs to execute at all — -so they carry no developer identity and Gatekeeper has nothing to check them against. -Whether you meet it depends entirely on the quarantine flag, which `tar` and `unzip` do not -propagate and Finder's Archive Utility does. +Clear the quarantine flag: ```bash xattr -d com.apple.quarantine ./sendspin-cli-0.1.0-macos-arm64/usr/local/bin/sendspin-cli ``` -Unpacking from a terminal avoids it in the first place. `sudo installer -pkg … -target /` -makes no Gatekeeper assessment at all, and files it puts on disk are never quarantined. - -A Developer ID signature and notarization are owed, and tracked as roadmap item 10. The full -account of all four ways you can come by these files is in -[macOS, and Gatekeeper](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#macos-and-gatekeeper). +Unpacking from a terminal avoids it in the first place, and `sudo installer -pkg … -target /` +is not gated at all. Why — and which of the four ways you can come by these files you are in +— is on [Installation](Installation). ## The daemon exits and says nothing diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index c26abd9..aa7f22b 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -252,9 +252,15 @@ resolve_latest_tag() { ;; esac - # curl folds the header name to lower case for `-w`, but the raw header file keeps - # whatever the server sent, so the match is case-insensitive. - location="$(grep -i '^location:' "$headers" | tail -n 1 | tr -d '\r' | awk '{print $2}')" + # curl folds the header name to lower case for `-w`, but the raw header file keeps whatever + # the server sent, so the match is case-insensitive. + # + # `|| true` is load-bearing rather than sloppy: with no `location:` line at all, `grep` exits + # 1, `pipefail` carries that out of the pipeline, and `set -e` would kill the script *before* + # the guard below could say why. A redirect with no Location is exactly the case that guard + # exists for, so it must survive long enough to run. + location="$( (grep -i '^location:' "$headers" || true) | tail -n 1 | tr -d '\r' | + awk '{print $2}')" [ -n "$location" ] || fail "github.com answered $status with no Location header, which should not happen" @@ -322,7 +328,8 @@ else # mentions this archive at all would leave `sha256sum` with nothing to check. It does exit # non-zero when that leaves it with no file verified -- but "the checksum file does not # cover this download" deserves to be said in those words rather than as `-c` failing. - grep -q " $ARCHIVE\$" "$WORK_DIR/SHA256SUMS" || + 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" @@ -372,8 +379,12 @@ grep -Fqx "$PAYLOAD_ROOT/usr/local/bin/sendspin-cli" <<<"$ARCHIVE_LIST" || # and guessing at that would be worse than naming the one file the unit's own documentation # tells an operator to edit. A line is a comment when its first non-blank character is `#`, so # an indented `#output = …` is correctly not a match. +# Read through as_root because /etc/sendspin-cli.conf need not be world-readable: an +# unprivileged `grep` on an unreadable file exits 2, which would read here as "no output is +# configured" and quietly leave a properly configured player stopped. CONFIG_HAS_OUTPUT='no' -if [ -f "$CONFIG" ] && grep -Eq '^[[:space:]]*output[[:space:]]*=' "$CONFIG"; then +if as_root test -f "$CONFIG" && + as_root grep -Eq '^[[:space:]]*output[[:space:]]*=' "$CONFIG"; then CONFIG_HAS_OUTPUT='yes' fi readonly CONFIG_HAS_OUTPUT @@ -418,7 +429,9 @@ if [ "$ASSUME_YES" != 'yes' ]; then 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] ' - read -r answer + # `|| fail` for SC1's reason: a closed stdin makes `read` exit non-zero, and `set -e` would + # otherwise end the run with no word about why nothing was installed. + read -r answer || fail 'stdin closed before an answer arrived; nothing was installed' case "$answer" in y | Y | yes | YES) ;; *) fail 'nothing was installed' ;; @@ -434,6 +447,14 @@ step 'Installing' # script is how you upgrade. `--strip-components=1` drops the archive's own top level, so # every remaining path is the path the file installs to. as_root tar -xzf "$TARBALL" --strip-components=1 -C / "$PAYLOAD_ROOT/usr" + +# Checked before the binary is run rather than after: every Linux payload carries the unit, so +# its absence means a macOS archive was unpacked here -- and running the binary first would +# answer that with the dynamic loader's message instead of this one. +[ -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/^/ /' @@ -452,10 +473,6 @@ if [ "$HAVE_SYSTEMD" != 'yes' ]; then exit 0 fi -[ -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" - step 'Setting the service up' as_root systemctl daemon-reload as_root systemctl enable "$UNIT" @@ -483,8 +500,15 @@ if [ "$CONFIG_HAS_OUTPUT" = 'yes' ]; then 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 '' - journalctl -u "$UNIT" --no-pager -n 15 2>/dev/null | sed 's/^/ /' || - say " (could not read the journal; try: journalctl -u $UNIT -n 50)" + # Through as_root like every other privileged read, and emptiness treated as failure: + # a user outside `systemd-journal` gets no lines and exit 0, which would print a blank + # block at the exact moment the operator most needs to be told something. + journal="$(as_root journalctl -u "$UNIT" --no-pager -n 15 2>/dev/null || true)" + if [ -n "$journal" ]; then + printf '%s\n' "$journal" | sed 's/^/ /' + else + say " (nothing readable in the journal; try: ${SUDO_P}journalctl -u $UNIT -n 50)" + fi say '' say " '${SUDO_P}$BINARY -l' lists what this host really has." fi From 512a4b9d00c0c549edc8e02abea3b7d1527d5b07 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Wed, 19 Aug 2026 18:24:12 -0500 Subject: [PATCH 4/4] Write the docs against the unit's own account, now that it has one The pages were written against a unit that ran the player as root, with one line saying a change to that was in flight. It has merged, so the note is gone and everything it was hedging is now stated: the unit names User=sendspin-cli, the account is declared in lib/sysusers.d beside it, and `systemd-sysusers` is the one step installing cannot do for itself. That command is now in every install recipe on the wiki -- Running as a Service, Installation's by-hand Linux steps, both getting-started walkthroughs -- and 217/USER is named as what an operator gets for skipping it rather than left to be discovered. Running as a Service carries the rest: how the account comes to exist and why its audio membership ships in the same file, what DynamicUser= would cost, the migration path for a root-run deployment (nothing, since StateDirectory= chowns a directory it finds), and the three operator-visible edges of the hardening block -- the systemd 247 floor, the two directories that stay writable, and the four /dev/snd-gating directives deliberately absent. It summarizes and links to README's section rather than forking its text. Swept the rest for the old model: the control socket belongs to the service account and root reads it by not being subject to the mode (Troubleshooting, Controlling the Player, Getting Started on Linux); the Pi page's audio-group advice now separates what the declaration arranges from what a shell run still owes; the payload listings carry the sysusers fragment; uninstalling removes it and says why the account outlives it. Two failures the hardening block introduces are new Troubleshooting entries, both reproduced rather than reasoned about: 217/USER, and a logfile or pidfile refused as a read-only file system under ProtectSystem=strict. The script runs `systemd-sysusers` between unpacking and daemon-reload, printed in the plan like every other privileged command, and only when the payload it is installing actually carries the fragment -- an announced command that turns out not to be needed is one an operator cannot account for. Two guards, because enabling a unit that cannot start is worse than refusing: no systemd-sysusers on $PATH names the useradd README documents as the equivalent, and a payload with no fragment whose unit still names a User= that does not exist is refused rather than left to report 217/USER after an install that said it worked. --- docs/wiki/Configuration.md | 12 ++ docs/wiki/Controlling-the-Player.md | 7 +- docs/wiki/Getting-Started-on-Linux.md | 25 +++- .../wiki/Getting-Started-on-a-Raspberry-Pi.md | 34 +++-- docs/wiki/Home.md | 2 +- docs/wiki/Installation.md | 8 ++ docs/wiki/Running-as-a-Service.md | 121 ++++++++++++++---- docs/wiki/Troubleshooting.md | 36 +++++- scripts/get_started_linux.sh | 86 ++++++++++++- 9 files changed, 269 insertions(+), 62 deletions(-) diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index dc9b084..4ff59ac 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -107,6 +107,12 @@ Under systemd that means a bad config is a unit that fails and is retried every seconds, naming the file and the line in the journal each time. See [Troubleshooting](Troubleshooting). +**Two keys parse and then fail under the shipped unit.** `logfile` and `pidfile` pointing +anywhere but `/run/sendspin-cli` or `/var/lib/sendspin-cli` are refused by +`ProtectSystem=strict` — `Read-only file system`, loudly, on every restart. Neither is the +shape for a unit whose stderr journald already has. See +[Running as a Service](Running-as-a-Service#what-is-hardened). + The full argument, including why the search does not merge layers, is in [The config file, and what the player remembers](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-config-file-and-what-the-player-remembers). @@ -140,6 +146,12 @@ systemd *system* unit has neither variable and is handed `/var/lib/sendspin-cli` `StateDirectory=`. With none of the three the player still runs and simply remembers nothing. +Under the unit the directory and the file belong to the unprivileged `sendspin-cli` account, +and a `/var/lib/sendspin-cli` left root-owned by an earlier root-run version needs nothing +done to it — `StateDirectory=` chowns a directory it finds as well as one it creates, so what +was remembered carries over. See +[Running as a Service](Running-as-a-Service#upgrading-from-a-version-that-ran-as-root). + Writes go through a temporary, an `fsync` and a `rename` at mode `0600`, so a player that loses power mid-write leaves either the old file or the new one and never half of either. diff --git a/docs/wiki/Controlling-the-Player.md b/docs/wiki/Controlling-the-Player.md index ba61dad..a548740 100644 --- a/docs/wiki/Controlling-the-Player.md +++ b/docs/wiki/Controlling-the-Player.md @@ -120,9 +120,10 @@ sendspin-cli status --port 9000 # ...so its subcomman sendspin-cli status --control-socket /run/user/1000/sendspin-cli-9000.sock # or name it ``` -**Under the systemd system unit**, both change. There is no `$XDG_RUNTIME_DIR` for root, so -the unit passes `--control-socket /run/sendspin-cli/control.sock`, and the socket is root's -at mode `0600`: +**Under the systemd system unit**, both change. A system unit has no `$XDG_RUNTIME_DIR`, so +the unit passes `--control-socket /run/sendspin-cli/control.sock`, and the socket belongs to +the unprivileged `sendspin-cli` account the unit runs as, at mode `0600`. Root is what +connects to it, not being subject to the mode: ```bash sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock diff --git a/docs/wiki/Getting-Started-on-Linux.md b/docs/wiki/Getting-Started-on-Linux.md index 88c0ae2..8c1d59a 100644 --- a/docs/wiki/Getting-Started-on-Linux.md +++ b/docs/wiki/Getting-Started-on-Linux.md @@ -24,6 +24,7 @@ commands first and waiting for you to say yes: 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 systemd-sysusers sudo systemctl daemon-reload sudo systemctl enable sendspin-cli sudo /usr/local/bin/sendspin-cli -l @@ -45,12 +46,17 @@ instead of the newest. 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. **Enables the unit — and starts it only if an output is already configured.** More on +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 + [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. -7. **Lists this host's sound devices** and prints the two commands that finish the job. +8. **Lists this host's sound devices** and prints the two commands that finish the job. Re-running it is how you upgrade: it overwrites the same paths, and restarts the service if -step 6 finds an `output` configured — which after a first run it will. +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. ### Why it does not start the player @@ -80,11 +86,16 @@ sendspin-cli -l sudo cp /usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example /etc/sendspin-cli.conf sudo nano /etc/sendspin-cli.conf # output = hw:1,0 -# 4. Start it +# 4. Create the account the unit runs as, then start it +sudo systemd-sysusers sudo systemctl daemon-reload sudo systemctl enable --now sendspin-cli ``` +`systemd-sysusers` is not optional and is not a `useradd` you have to compose — the payload +installs the declaration, and this reads it. Leave it out and `systemctl status` reports +`217/USER`. + ## Choosing an output `sendspin-cli -l` lists every device this host can play through, and for each one the rates, @@ -139,7 +150,8 @@ sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock ``` `sudo`, and the flag, are both needed under the system unit: the socket is mode `0600` and -owned by root, and root has no `$XDG_RUNTIME_DIR` for the default path to come from. Adding +belongs to the `sendspin-cli` account, and root has no `$XDG_RUNTIME_DIR` for the default path +to come from. Root can read the socket regardless, not being subject to the mode. Adding `control-socket = /run/sendspin-cli/control.sock` to the config saves repeating the flag — see [Controlling the Player](Controlling-the-Player). @@ -164,5 +176,6 @@ preference here, and the two modes are mutually exclusive by design — see - [Configuration](Configuration) — every key, and what the player remembers by itself - [Controlling the Player](Controlling-the-Player) — `pause`, `vol`, `delay` and the rest -- [Running as a Service](Running-as-a-Service) — drop-ins, and running as a non-root user +- [Running as a Service](Running-as-a-Service) — the account it runs as, drop-ins, and what + the hardening block takes away - [Troubleshooting](Troubleshooting) — when it starts and stays silent diff --git a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md index 4fe99fb..443053f 100644 --- a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -95,35 +95,31 @@ receiver, a USB DAC, or an I²S HAT. ## 4. `/dev/snd` belongs to root and the `audio` group -The systemd unit runs as root, which already has the sound card, so a service install needs -nothing here. Two cases do: +`/dev/snd/*` is `root:audio` mode `0660`, so `audio` membership is what decides whether a +player can open a card at all. Whoever runs it needs it. -**Running it from your own shell.** Put yourself in the `audio` group once, then log out and -back in — group membership is only picked up at login: +**As a service, this is already arranged.** The unit runs as an unprivileged `sendspin-cli` +account, and the declaration the payload installs puts that account in `audio` in the same +file that creates it — the two are owed together, and arrive together. What you do have to do +once is turn the declaration into an account: ```bash -sudo usermod -aG audio "$USER" +sudo systemd-sysusers ``` -Without it the player starts and opens nothing, because `/dev/snd/*` is `root:audio` mode -`0660`. +The getting-started script does that for you. Skipping it is a unit that does not start and a +`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). -**Running the service as a non-root user.** Both lines together, never one alone: +**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: ```bash -sudo systemctl edit sendspin-cli -``` - -```ini -[Service] -User=sendspin -SupplementaryGroups=audio +sudo usermod -aG audio "$USER" ``` -A user with no `audio` membership is a player that starts and cannot open a device. Create -the user first (`sudo useradd --system --no-create-home sendspin`) and chown any existing -`/var/lib/sendspin-cli` to it. See -[Running as a Service](Running-as-a-Service). +Without it the player starts and opens nothing. The `sendspin-cli` account's membership does +nothing for a player running as you. ## 5. Things a Pi does that a server does not diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index f5ac297..0284c92 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -20,7 +20,7 @@ muscle memory carries over. | [Installation](Installation) | Every way in: release archive, macOS `.pkg`, source | | [Configuration](Configuration) | The config file, and what the player remembers by itself | | [Controlling the Player](Controlling-the-Player) | `sendspin-cli pause` and the other thirteen subcommands | -| [Running as a Service](Running-as-a-Service) | The systemd unit, drop-ins, and reading the log | +| [Running as a Service](Running-as-a-Service) | The systemd unit, the account it runs as, drop-ins, and reading the log | | [Troubleshooting](Troubleshooting) | It starts and makes no sound, and the rest | ## What it does, in one screen diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index ef81845..8e24b19 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -29,6 +29,7 @@ sendspin-cli-0.1.0-linux-arm64/ └── usr/local/ ├── bin/sendspin-cli ├── lib/systemd/system/sendspin-cli.service # Linux only + ├── lib/sysusers.d/sendspin-cli.conf # Linux only └── share/doc/sendspin-cli/ ├── README.md ├── LICENSE @@ -55,9 +56,16 @@ sha256sum --ignore-missing -c SHA256SUMS # 3. Install sudo tar -xzf "sendspin-cli-$VERSION-$LEG.tar.gz" --strip-components=1 -C / \ "sendspin-cli-$VERSION-$LEG/usr" +sudo systemd-sysusers sudo systemctl daemon-reload ``` +**`systemd-sysusers` is the one thing unpacking cannot do for itself.** The unit runs as an +unprivileged `sendspin-cli` account, the archive carries the declaration for it at +`usr/local/lib/sysusers.d/sendspin-cli.conf`, and a tarball has no `postinst` to turn one into +the other. It is idempotent, and without it the unit reports `217/USER` rather than starting. +See [Running as a Service](Running-as-a-Service#it-runs-as-its-own-account). + `--ignore-missing` because `SHA256SUMS` covers every archive the release carries and you have taken one of them; without it the other three are reported as failures. It is not a way of passing with nothing checked — `sha256sum` still exits non-zero if the flag leaves diff --git a/docs/wiki/Running-as-a-Service.md b/docs/wiki/Running-as-a-Service.md index 843239c..dae5b21 100644 --- a/docs/wiki/Running-as-a-Service.md +++ b/docs/wiki/Running-as-a-Service.md @@ -3,6 +3,7 @@ One systemd unit ships in the Linux payload, with sane defaults and nothing to fill in. ```bash +sudo systemd-sysusers sudo systemctl daemon-reload sudo systemctl enable --now sendspin-cli systemctl status sendspin-cli @@ -13,6 +14,11 @@ The unit is installed at `/usr/local/lib/systemd/system/sendspin-cli.service`, w already on systemd's search path — nothing needs copying by hand. `daemon-reload` after installing is what makes systemd notice it. +**`systemd-sysusers` is the line that is not optional.** It creates the unprivileged account +the unit runs as, out of a declaration installed beside the unit, and a tarball has no +`postinst` to run it for you. Skip it and the unit does not start at all — see +[It runs as its own account](#it-runs-as-its-own-account). + > **Set an `output` before enabling it**, or this unit will fail and be retried every five > 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 @@ -27,13 +33,17 @@ installing is what makes systemd notice it. |---|---| | `/usr/local/bin/sendspin-cli` | the binary | | `/usr/local/lib/systemd/system/sendspin-cli.service` | the unit | +| `/usr/local/lib/sysusers.d/sendspin-cli.conf` | the account the unit runs as, declared | | `/usr/local/share/doc/sendspin-cli/README.md` | the reference | | `/usr/local/share/doc/sendspin-cli/LICENSE` | Apache 2.0 | | `/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example` | an annotated config | The unit goes in `lib/systemd/system` and not a multiarch `libdir` because a unit file is architecture-independent, and systemd reads `/usr/lib/systemd/system` and -`/usr/local/lib/systemd/system` — never `lib/x86_64-linux-gnu/systemd/system`. +`/usr/local/lib/systemd/system` — never `lib/x86_64-linux-gnu/systemd/system`. The account +declaration is in `lib/sysusers.d` for both of the same reasons: a list of users has no +architecture either, and `systemd-sysusers` searches `/usr/local/lib/sysusers.d` alongside +`/usr/lib/sysusers.d`. **`ExecStart` names the binary absolutely**, at the prefix the build was configured for, so a binary moved out of `/usr/local` leaves the unit pointing at nothing. @@ -88,8 +98,9 @@ up. ## The subcommands need `sudo` here -The control socket is mode `0600` and the service is root's, so an unprivileged shell cannot -connect to it — and root has no `$XDG_RUNTIME_DIR` for the default path to come from: +The control socket is mode `0600` and belongs to the service account, so an unprivileged +shell cannot connect to it — while root can, because root is not subject to the mode. And +root has no `$XDG_RUNTIME_DIR` for the default path to come from either: ```bash sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock @@ -98,35 +109,86 @@ sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock Adding `control-socket = /run/sendspin-cli/control.sock` to the config removes the flag. The `sudo` stays. See [Controlling the Player](Controlling-the-Player). -## It runs as root, and that is a decision - -There is no `User=` and no hardening block, so the player runs as root — its WebSocket -server, which listens on the network, included. A tarball has no `postinst` to create a -dedicated user with, and root already has the sound card (`/dev/snd` is `root:audio` mode -`0660`), the mDNS daemon and `/run` with nothing to arrange first. +## It runs as its own account -> A change to that user model is in flight — a dedicated user with a hardening block is being -> worked on in a separate change. This page describes what `main` ships today. +The unit names `User=sendspin-cli` — an unprivileged system account with no home and no +shell — so the player, its network-facing WebSocket server included, is not root. -A drop-in is where to change it now: +**Creating that account is the one step installing cannot do for you.** A tarball has no +`postinst`, so the declaration ships beside the unit as `lib/sysusers.d/sendspin-cli.conf` +and one idempotent command turns it into an account: -```bash -sudo systemctl edit sendspin-cli +```console +$ sudo systemd-sysusers +Creating group 'sendspin-cli' with GID 997. +Creating user 'sendspin-cli' (Sendspin audio player) with UID 997 and GID 997. ``` -```ini -[Service] -User=sendspin -SupplementaryGroups=audio +Skip it and the unit does not start at all, which `systemctl status` says in the words that +name the cause rather than hiding it: + +``` +sendspin-cli.service: Main process exited, code=exited, status=217/USER ``` -**Both lines together, and neither alone**: a user with no membership of `audio` is a player -that starts and cannot open a device. Create the user first and hand it the state directory -once, since systemd owns the directory it creates but not one a root-run player left behind: +The fragment carries two lines and they are owed **together**: the account, and its +membership of `audio`. A `sendspin-cli` in no `audio` group is a player that starts and +cannot open a device, because `/dev/snd` is `root:audio` mode `0660` — which is why this is a +shipped declaration rather than a `useradd` line in a document. If you manage accounts with +your own tooling, the equivalent is +`useradd --system --no-create-home -G audio sendspin-cli`. + +`DynamicUser=` looks like it would avoid all of this and does not: it hands the player a uid +in no supplementary group at all, which deafens the ALSA backend. + +### Upgrading from a version that ran as root + +**Nothing needs doing to `/var/lib/sendspin-cli`.** `StateDirectory=` chowns the directory it +finds as well as the one it creates, recursively, so a root-owned state file from an earlier +install becomes the new account's on the first start and the remembered volume, mute and +static delay carry over. + +Two things are worth checking before the upgrade, and both come from the hardening block: + +- **A `logfile` or `pidfile` in `/etc/sendspin-cli.conf`** pointing anywhere but + `/run/sendspin-cli` or `/var/lib/sendspin-cli` now fails under `ProtectSystem=strict` — + `cannot open logfile /var/log/sendspin-cli.log: Read-only file system`, loudly and on every + restart, rather than a player logging nowhere in silence. Neither key is the shape for this + unit anyway, since journald already has stderr. A drop-in with `ReadWritePaths=/var/log` is + the way back if you want one regardless. +- **A drop-in of your own that set `User=` and `SupplementaryGroups=audio`** — the recipe for + getting off root when the unit had no account of its own — is now overriding a unit that + already names one. Remove the drop-in and take the shipped account instead; `systemctl + revert sendspin-cli` drops every drop-in at once. + +### What is hardened + +The unit carries `ProtectSystem=strict`, `NoNewPrivileges=`, an empty +`CapabilityBoundingSet=`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, +`SystemCallFilter=@system-service` and the `Protect*=` family, each commented where it sits. +Read the installed unit for the full block. Three operator-visible edges: + +- The whole block wants systemd **247**; the unit itself still starts on **236**. Below 247 + systemd warns `Unknown key name 'ProtectProc' … ignoring` and runs the unit with one + directive fewer. +- `RuntimeDirectory=` and `StateDirectory=` stay writable under `ProtectSystem=strict`, which + is what leaves the control socket and the state file somewhere to be. `/etc` is only ever + read. +- Four directives that would gate what the ALSA backend reaches are deliberately *absent* — + `PrivateDevices=`, `DeviceAllow=`, `ProcSubset=pid` and `RestrictRealtime=` — because they + pass every check a machine with no sound card can make, and tracked as + [`docs/ROADMAP.md`](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) + item 10. + +The full argument for every directive, and the `systemd-analyze security` figures, are in +[The systemd unit](https://github.com/chrisuthe/sendspin-cpp-cli/blob/main/README.md#the-systemd-unit). + +### Changing any of it + +Use a drop-in rather than editing the installed unit, which an upgrade overwrites: ```bash -sudo useradd --system --no-create-home sendspin -sudo chown -R sendspin /var/lib/sendspin-cli +sudo systemctl edit sendspin-cli ``` An `ExecStart=` in a drop-in has to be cleared first — `ExecStart=` on its own line, then @@ -174,12 +236,17 @@ and `output = default` works as it does from your shell: ```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, and the two Directory= lines +# 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 systemctl --user daemon-reload systemctl --user enable --now sendspin-cli loginctl enable-linger "$USER" # so it runs when you are not logged in ``` +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 that ships is the system one; this is a recipe rather than something the project installs or tests. @@ -189,12 +256,18 @@ installs or tests. sudo systemctl disable --now sendspin-cli 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/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 ``` +Removing the fragment does not remove the account — `systemd-sysusers` creates users and +never deletes them — so `userdel` is a separate line, and an optional one: a system account +with no home, no shell and nothing running as it costs a passwd entry. + ## Next - [Configuration](Configuration) diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 11a285a..2877e2f 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -91,7 +91,25 @@ $ systemctl status sendspin-cli ``` `Restart=on-failure` with `RestartSec=5` retries indefinitely, so the journal has the reason -repeated once every five seconds. The two usual ones: +repeated once every five seconds. The usual ones: + +**`status=217/USER` — the account the unit runs as does not exist.** The first thing to check +on a fresh install, and the only failure here where the player never runs at all: + +``` +sendspin-cli.service: Main process exited, code=exited, status=217/USER +``` + +The unit names `User=sendspin-cli`, and creating that account is the one step unpacking a +tarball cannot do for itself. One idempotent command fixes it: + +```bash +sudo systemd-sysusers +sudo systemctl restart sendspin-cli +``` + +The getting-started script runs it for you; a by-hand install has to. See +[Running as a Service](Running-as-a-Service#it-runs-as-its-own-account). **A config file that does not parse.** It names the file and the line, every attempt: @@ -102,6 +120,18 @@ error: /etc/sendspin-cli.conf:4: invalid --buffer-ms '5' -- expected 10-2000 Fix the line and the player comes back on its own — there is no `systemctl reset-failed` to do, which is deliberate. +**`Read-only file system` on a `logfile` or `pidfile`.** The unit runs under +`ProtectSystem=strict`, so a path outside `/run/sendspin-cli` and `/var/lib/sendspin-cli` is +refused rather than silently unwritten: + +``` +error: cannot open logfile /var/log/sendspin-cli.log: Read-only file system +``` + +Neither key is the shape for this unit — journald already has stderr, and `-z`/`-f` are for a +supervisor without one — so removing the key is usually the answer. If you want a logfile +regardless, a drop-in with `ReadWritePaths=/var/log` is the way back. + **A device that will not open.** See above. ## Nothing discovers it @@ -184,7 +214,9 @@ needs the same one: sendspin-cli status --port 9000 ``` -**It is the systemd system unit**, whose socket is somewhere else and is root's: +**It is the systemd system unit**, whose socket is somewhere else and belongs to the +`sendspin-cli` account at mode `0600` — so root, which is not subject to the mode, is what +reads it: ```bash sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index aa7f22b..1ecf460 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -27,6 +27,14 @@ # the member-selected `tar` form README documents -- naming `/usr` is what leaves # BUILD-INFO.txt in the archive instead of writing it to `/`. # +# The unit runs the player as an unprivileged `sendspin-cli` account, which the payload +# *declares* in usr/local/lib/sysusers.d/sendspin-cli.conf and cannot *create* -- a tarball has +# no postinst. So `systemd-sysusers` is run here, which is the one command README, BUILD-INFO.txt +# and the release notes all tell an operator to run once, reading the fragment this script has +# just installed. It is idempotent, so a re-run costs nothing. Skipping it would leave a unit +# that does not start at all: `systemctl status` reports 217/USER, and a get-started script that +# enables a unit it has made unstartable is not one. +# # WHAT IT DELIBERATELY DOES NOT DO IS START A PLAYER THAT CANNOT PLAY. 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 fine from your shell usually will not open under @@ -72,6 +80,8 @@ readonly REPO='chrisuthe/sendspin-cpp-cli' readonly UNIT='sendspin-cli' readonly UNIT_FILE='/usr/local/lib/systemd/system/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 CONFIG_EXAMPLE='/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example' @@ -371,6 +381,24 @@ grep -Fqx "$PAYLOAD_ROOT/usr/local/bin/sendspin-cli" <<<"$ARCHIVE_LIST" || with DESTDIR from a build configured for the /usr/local prefix: DESTDIR=/tmp/stage cmake --install build --component sendspin-cli" +# Read off the archive rather than assumed present, so the plan printed below is the plan that +# runs. Every Linux payload the CI publishes carries the fragment beside the unit, but a payload +# staged from an older tree does not, and a `systemd-sysusers` announced and then not needed +# would be the one command in that list an operator could not account for. +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 there is a systemd to have an account for. The account is the unit's requirement +# and nothing else here needs it, so a container without systemd gets the binary and no user. +CREATE_USER='no' +if [ "$HAVE_SYSTEMD" = 'yes' ] && [ "$PAYLOAD_HAS_SYSUSERS" = 'yes' ]; then + CREATE_USER='yes' +fi +readonly CREATE_USER + # An `output` already chosen is what decides whether the player is started at the end, so it # is settled here -- before anything is installed -- and reported in the plan below. # @@ -401,6 +429,9 @@ 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" @@ -412,6 +443,11 @@ if [ "$HAVE_SYSTEMD" = 'yes' ]; then 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.' @@ -474,6 +510,42 @@ if [ "$HAVE_SYSTEMD" != 'yes' ]; then fi step 'Setting the service up' + +# Before daemon-reload and enable, because this is what makes the unit startable at all: it +# names User=sendspin-cli, and 217/USER is what an operator gets instead of a player if the +# account is missing. `systemd-sysusers` with no argument reads every fragment on the search +# path, /usr/local/lib/sysusers.d included, so it needs no path to the file just installed. +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 + + # Asserted rather than assumed: sysusers exits 0 with nothing done if it read no fragment, + # and the failure that follows would be 217/USER at the end of an install that said it + # worked. `getent passwd` and not `id`, which on some hosts answers out of a cache. + 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 + # The payload carried no fragment, which is an older tree -- and an older tree's unit runs + # as root and names no User=. Read the installed unit rather than trusting that pairing: a + # unit naming an account nothing here can create is 217/USER after an install that said it + # worked, and this is the one place left to catch it. + 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 + as_root systemctl daemon-reload as_root systemctl enable "$UNIT" say " enabled: $UNIT starts on boot" @@ -551,9 +623,10 @@ 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 the service is root's, so reading it needs to be" -say " root too. Put 'control-socket = $CONTROL_SOCKET' in the config to" -say ' stop repeating the flag.' +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' @@ -567,10 +640,9 @@ if [ -n "$PI_MODEL" ]; then 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 root, which already has /dev/snd. If you change that with a' - say " 'systemctl edit $UNIT' drop-in, the new user needs the audio group in the same" - say " drop-in ('SupplementaryGroups=audio') -- without it the player starts and opens" - say ' nothing.' + 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