镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ endif()

# sendspin-cpp

set(SENDSPIN_GIT_TAG "b0fcf5bb369b470ddc4987280c19f0919e9a9084" CACHE STRING
set(SENDSPIN_GIT_TAG "3a3722a8d2771526e68be3cccf5b99712c36ae91" CACHE STRING
"sendspin-cpp git tag or commit to build against")

# player, metadata, controller (group transport verbs) and source; no FORCE, so -D overrides still win.
Expand Down Expand Up @@ -294,6 +294,7 @@ add_library(sendspin-cli-core STATIC
src/null_sink.cpp
src/null_source.cpp
src/outbound.cpp
src/pairing_endpoint.cpp
src/pcm_ring.cpp
src/pcm_volume.cpp
src/player_listener.cpp
Expand Down Expand Up @@ -500,6 +501,7 @@ if(SENDSPIN_CLI_BUILD_TESTS)
tests/discovery_test.cpp
tests/hooks_test.cpp
tests/null_sink_test.cpp
tests/pairing_endpoint_test.cpp
tests/pcm_ring_test.cpp
tests/pcm_volume_test.cpp
tests/pipewire_sink_test.cpp
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ passing such a URL today.
### 3. Upstream gaps in sendspin-cpp

Each needs a library change; nothing this repo can call fixes them. Re-check on every
`SENDSPIN_GIT_TAG` bump (currently `b0fcf5bb`, past v0.9.5).
`SENDSPIN_GIT_TAG` bump (currently `3a3722a8`, past v0.9.5).

- **No log sink hook.** The host `SS_LOG*` macros are bare `fprintf(stderr, ...)` gated on one
global level. So library lines carry no timestamp under `-f`, cannot be filtered per tag
Expand Down
38 changes: 38 additions & 0 deletions docs/wiki/Advanced-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,44 @@ $ journalctl -u sendspin-cli | grep 'Pairing token'
It stops being logged once a server is paired, and it works whichever code method the player
offers.

### Pairing with a server on the same host

`--pairing-token-endpoint <address>:<port>` (or `pairing-token-endpoint =`) serves the token
over HTTP so a server on the same host can fetch it and pair without anyone copying it. On
Home Assistant OS that address is the hassio network's `172.30.32.1`:

```
pairing-token-endpoint = 172.30.32.1:8930
```

The player answers `GET /` with its token as plain text:

```
SP:0TNMH6…
```

and lists the role `_ohf_local_pairing@v1`, with `{"port": 8930}`, in its `client/hello` so a
server knows the endpoint is there and which port to ask. The role name is provisional.
Without the option nothing listens and no such role is advertised.

How it stays local:

- **It listens on one address only.** The address is required and must be a numeric IP; a
wildcard (`0.0.0.0`, `::`) or a bare port is refused at startup. Pick an address only the
server's host can reach, never the LAN address.
- **Anything that reaches it can pair.** On Home Assistant OS that is every app on the
hassio network, so turn the option on only where those are trusted.
- **A request must name it.** The `Host` header has to be the endpoint's own address and
port, so a web page that rebinds its hostname to that address gets no token. It serves at
most four connections at once and closes any beyond them.
- **The server decides where to fetch from.** It acts only for an unpaired player on an
encrypted connection, fetches from the internal address it knows rather than one the player
names, and refuses a token issued for a different client id.
- **The token stays out of the log.** Requests are logged at `debug` without it, and the
startup line that otherwise prints it points at `sendspin-cli pair-token` instead.

If the address cannot be bound the player exits rather than advertising a port nobody serves.

## Stream hooks

`--hook-start` and `--hook-stop` run a shell command when a stream starts or stops,
Expand Down
1 change: 1 addition & 0 deletions docs/wiki/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ already does that.
| `fixed-volume` | `--fixed-volume` | `true`/`false` — play at full volume, unmuted, and offer the server neither volume nor mute, for an output whose level is set elsewhere (a line-out into an amplifier). A remembered volume is kept but ignored, and returns once the key is dropped | `false` |
| `release-device` | `--release-device` | `true`/`false` — close the output device at each stream end and reopen it at the next stream start, for an exclusive device such as an ALSA `hw:` DAC shared with other players (Squeezelite, AirPlay, Spotify). The trade-off: another app can take the device between streams, and the next stream is then discarded; each stream also pays a fresh open. ALSA and PortAudio only; other backends ignore it | `false` |
| `pairing-code` | `--pairing-code` | exactly 8 digits — a static code a server can pair with, confirmed each time with `sendspin-cli pair confirm`; replaces the 6-digit code the player otherwise shows. See [Pairing a Player](Pairing-a-Player#2-static-code) | none |
| `pairing-token-endpoint` | `--pairing-token-endpoint` | `<address>:<port>` — serve the pairing token over HTTP on this one internal address, e.g. `172.30.32.1:8930` on Home Assistant OS, and advertise it for local pairing; a wildcard address is refused. See [Pairing with a server on the same host](Advanced-Usage#pairing-with-a-server-on-the-same-host) | none — nothing listens |
| `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-<port>.sock` |
Expand Down
23 changes: 23 additions & 0 deletions src/cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include "control.h"
#include "log.h"
#include "mdns.h"
#include "pairing_endpoint.h"
#include "supported_formats.h"

#ifdef _WIN32
Expand Down Expand Up @@ -80,6 +81,7 @@ enum LongOnly {
OPT_PAIRING_CODE,
OPT_FIXED_VOLUME,
OPT_RELEASE_DEVICE,
OPT_PAIRING_TOKEN_ENDPOINT,
};

/// An option a config file may set: its key (the long flag name) and how diagnostics name it.
Expand Down Expand Up @@ -121,6 +123,7 @@ const std::vector<SettableOption>& settable_options() {
{Opt::PairingCode, "pairing-code", "--pairing-code"},
{Opt::FixedVolume, "fixed-volume", "--fixed-volume"},
{Opt::ReleaseDevice, "release-device", "--release-device"},
{Opt::TokenEndpoint, "pairing-token-endpoint", "--pairing-token-endpoint"},
};
return table;
}
Expand Down Expand Up @@ -439,6 +442,15 @@ bool apply_option(const SettableOption& option, const std::string& value, Option
}
out.pairing_code = value;
break;
case Opt::TokenEndpoint: {
std::string reason;
if (!parse_pairing_endpoint(value, out.pairing_endpoint_host, out.pairing_endpoint_port,
reason)) {
error = "invalid --pairing-token-endpoint '" + value + "': " + reason;
return false;
}
break;
}
case Opt::NoControl:
if (!parse_bool(value, out.no_control)) {
error = "invalid --no-control '" + value + "' -- expected true or false";
Expand Down Expand Up @@ -609,6 +621,7 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) {
{"pairing-code", required_argument, nullptr, OPT_PAIRING_CODE},
{"fixed-volume", no_argument, nullptr, OPT_FIXED_VOLUME},
{"release-device", no_argument, nullptr, OPT_RELEASE_DEVICE},
{"pairing-token-endpoint", required_argument, nullptr, OPT_PAIRING_TOKEN_ENDPOINT},
{nullptr, 0, nullptr, 0},
};

Expand Down Expand Up @@ -757,6 +770,9 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) {
case OPT_RELEASE_DEVICE:
apply(Opt::ReleaseDevice, "true");
break;
case OPT_PAIRING_TOKEN_ENDPOINT:
apply(Opt::TokenEndpoint, optarg);
break;
case OPT_MANUFACTURER:
apply(Opt::Manufacturer, optarg);
break;
Expand Down Expand Up @@ -1117,6 +1133,13 @@ void print_usage(std::FILE* out, const char* prog) {
std::fprintf(out, " code the player otherwise logs when a server asks to\n");
std::fprintf(out, " pair. Prefer 'pairing-code =' in the config file: a\n");
std::fprintf(out, " flag is visible to every local user in 'ps'\n");
std::fprintf(out, " --pairing-token-endpoint <address>:<port>\n");
std::fprintf(out, " Serve this player's pairing token over HTTP on one\n");
std::fprintf(out, " internal address, e.g. 172.30.32.1:8930 on Home\n");
std::fprintf(out, " Assistant OS, and advertise the port so a server on\n");
std::fprintf(out, " the same host can pair without a copied token. The\n");
std::fprintf(out, " address is required and may not be a wildcard. Anyone\n");
std::fprintf(out, " who can reach it can pair with this player\n");
std::fprintf(out, " --no-mdns Do not advertise over mDNS (no effect with -s, which\n");
std::fprintf(out, " already suppresses it)\n");
std::fprintf(out, " --mdns-name <name>\n");
Expand Down
5 changes: 5 additions & 0 deletions src/cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ enum class Opt : unsigned {
PairingCode, ///< --pairing-code
FixedVolume, ///< --fixed-volume
ReleaseDevice, ///< --release-device
TokenEndpoint, ///< --pairing-token-endpoint
};

/// Everything the flag surface configures.
Expand Down Expand Up @@ -125,6 +126,10 @@ struct Options {
/// --release-device: close the output device at stream end instead of holding it.
bool release_device{false};

/// --pairing-token-endpoint <address>:<port>: serve the pairing token there; port 0 is off.
std::string pairing_endpoint_host;
uint16_t pairing_endpoint_port{0};

/// --no-mdns: do not advertise `_sendspin._tcp`.
bool no_mdns{false};

Expand Down
3 changes: 1 addition & 2 deletions src/control.h
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,7 @@ struct StatusSnapshot {
std::string server_name; ///< `ServerInformationObject::name`, empty when not known
std::string server_id; ///< `ServerInformationObject::server_id`

/// The connection's trust; absent before its handshake reports one.
std::optional<sendspin::ConnectionTrust> trust{};
bool paired{false}; ///< the connection is a paired session
size_t paired_servers{0}; ///< servers with a stored pairing, connected or not

std::string pairing_server_id; ///< the server a pairing is in progress with, else empty
Expand Down
5 changes: 1 addition & 4 deletions src/control_common.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -544,11 +544,8 @@ std::string format_status(const StatusSnapshot& snapshot) {

if (!snapshot.connected) {
append_line(out, "trust", "not connected");
} else if (!snapshot.trust.has_value()) {
append_line(out, "trust", "unknown");
} else {
append_line(out, "trust",
*snapshot.trust == sendspin::ConnectionTrust::USER ? "paired" : "unpaired");
append_line(out, "trust", snapshot.paired ? "paired" : "unpaired");
}
append_line(out, "paired servers", std::to_string(snapshot.paired_servers));

Expand Down
75 changes: 60 additions & 15 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include "log.h"
#include "mdns.h"
#include "outbound.h"
#include "pairing_endpoint.h"
#include "player_listener.h"
#include "state_store.h"
#include "supported_formats.h"
Expand Down Expand Up @@ -149,14 +150,21 @@ const char* pair_abort_reason(sendspin::SendspinPairAbortReason reason) {
return "the exchange broke down";
}

/// Logs trust and pairing progress, and caches both for `status`. Main loop only.
/// Logs session and pairing progress, and caches pairing progress for `status`. Main loop only.
struct PairingListener : sendspin::SendspinClientListener {
void on_trust_changed(sendspin::ConnectionTrust trust) override {
this->trust_ = trust;
// A new handshake: an attempt the last connection dropped never reported its end.
this->server_id_.clear();
log_line(LogLevel::INFO, LOG_TAG, "Connected server is %s",
trust == sendspin::ConnectionTrust::USER ? "paired" : "not paired");
/// One main-loop tick: logs a session that is new, or re-keyed onto a different PSK.
void tick(const sendspin::SendspinClient& client) {
// is_paired() first: a true is_connected() after it means that session is activated.
const bool paired = client.is_paired();
const bool connected = client.is_connected();
if (connected && (!this->connected_ || paired != this->paired_)) {
// A new handshake: an attempt the last connection dropped never reported its end.
this->server_id_.clear();
log_line(LogLevel::INFO, LOG_TAG, "Connected server is %s",
paired ? "paired" : "not paired");
}
this->connected_ = connected;
this->paired_ = paired;
}

void on_pairing_started(const std::string& server_id) override {
Expand Down Expand Up @@ -204,11 +212,6 @@ struct PairingListener : sendspin::SendspinClientListener {
log_line(LogLevel::INFO, LOG_TAG, "No longer waiting for 'pair confirm'");
}

/// The last trust reported; describes the connection only while one is up.
const std::optional<sendspin::ConnectionTrust>& trust() const {
return this->trust_;
}

/// The server a pairing is in progress with, or empty.
const std::string& server_id() const {
return this->server_id_;
Expand All @@ -224,7 +227,8 @@ struct PairingListener : sendspin::SendspinClientListener {
}

private:
std::optional<sendspin::ConnectionTrust> trust_;
bool connected_{false};
bool paired_{false};
std::string server_id_;
std::string code_;
bool window_open_{false};
Expand Down Expand Up @@ -495,8 +499,9 @@ class ControlDispatcher final : public ControlHandler {
StatusSnapshot snapshot;
snapshot.name = this->opts_.name;
snapshot.client_id = this->client_.client_id();
// Before is_connected(), so a connected snapshot's paired describes an activated session.
snapshot.paired = this->client_.is_paired();
snapshot.connected = this->client_.is_connected();
snapshot.trust = this->pairing_listener_.trust();
snapshot.paired_servers = this->state_store_.pairing_record_count();
// A dropped connection ends its attempt without a callback.
if (snapshot.connected) {
Expand Down Expand Up @@ -831,6 +836,10 @@ int main(int argc, char* argv[]) {
// The library offers one code method, and the dynamic one wins if both are set.
config.static_pairing_code = opts.pairing_code;
}
if (opts.pairing_endpoint_port != 0) {
config.application_roles.push_back(
{LOCAL_PAIRING_ROLE, local_pairing_support_json(opts.pairing_endpoint_port)});
}

sendspin::SendspinClient client(std::move(config));

Expand Down Expand Up @@ -956,19 +965,51 @@ int main(int argc, char* argv[]) {
player.update_volume(volume);
player.update_muted(muted);

// Bound before start(), so the port is open by the time client/hello advertises it.
std::unique_ptr<PairingEndpoint> pairing_endpoint;
std::string pairing_endpoint_url;
if (opts.pairing_endpoint_port != 0) {
const bool ipv6 = opts.pairing_endpoint_host.find(':') != std::string::npos;
pairing_endpoint_url = std::string("http://") + (ipv6 ? "[" : "") +
opts.pairing_endpoint_host + (ipv6 ? "]" : "") + ":" +
std::to_string(opts.pairing_endpoint_port) + PAIRING_TOKEN_PATH;
pairing_endpoint = std::make_unique<PairingEndpoint>(opts.pairing_endpoint_host,
opts.pairing_endpoint_port);
std::string error;
if (!pairing_endpoint->listen(error)) {
log_fatal(LOG_TAG, "--pairing-token-endpoint could not listen on %s: %s",
pairing_endpoint_url.c_str(), error.c_str());
return 1;
}
}

if (!client.start()) {
log_fatal(LOG_TAG, "could not start the Sendspin client on port %u", opts.port);
return 1;
}

if (pairing_endpoint) {
const std::optional<std::string> token = client.pairing_token();
if (!token.has_value()) {
log_fatal(LOG_TAG, "--pairing-token-endpoint: this player has no pairing token");
return 1;
}
pairing_endpoint->start(*token);
log_line(LogLevel::INFO, LOG_TAG,
"Serving the pairing token on %s -- anything that can reach it can pair with "
"this player",
pairing_endpoint_url.c_str());
}

cli_log(LogLevel::INFO, "sendspin-cli %s listening on port %u as \"%s\" (output: %s, mDNS: %s)",
SENDSPIN_CLI_VERSION, opts.port, opts.name.c_str(), sink->name().c_str(),
mdns_backend_name().c_str());

if (!state_store.has_pairing_record()) {
const std::optional<std::string> token = client.pairing_token();
// The logfile is world-readable, so the token only goes to the terminal or journal.
if (!opts.logfile.empty()) {
// Add-on logs are readable from the UI; the endpoint makes copying the token unnecessary.
if (!opts.logfile.empty() || pairing_endpoint) {
log_line(LogLevel::INFO, LOG_TAG,
"No server is paired yet -- run 'sendspin-cli pair-token' for the token to "
"paste into a server");
Expand Down Expand Up @@ -1036,6 +1077,7 @@ int main(int argc, char* argv[]) {
while (g_running.load()) {
const int64_t now_ms = monotonic_ms();
client.loop();
pairing_listener.tick(client);
// All three need their callbacks on the main loop.
mdns.poll(now_ms);
control_socket.poll(now_ms, control_dispatcher);
Expand All @@ -1054,6 +1096,9 @@ int main(int argc, char* argv[]) {
// Before the client stops, so a restart does not race a stale record or socket.
mdns.stop();
control_socket.close();
if (pairing_endpoint) {
pairing_endpoint->stop();
}
// Returns stopped, having delivered on_stream_end() -- so the stop hook is already queued.
client.stop();
// The stop hook may be pending behind a hung start hook; run it anyway.
Expand Down
Loading
Loading