Skip to content
Merged
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
10 changes: 9 additions & 1 deletion c_src/sendspin_player/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG" CACHE STRING "" FORCE)
# build dirs. This also means `-DSENDSPIN_CPP_REF=...` cannot override;
# for scratch experiments against another ref use
# `-DFETCHCONTENT_SOURCE_DIR_SENDSPIN-CPP=<local-checkout>` instead.
set(SENDSPIN_CPP_REF "9331ace6428979982c934384702d289958ca125e" # v0.8.0
set(SENDSPIN_CPP_REF "b95cf854a784ed308b1f8fe9697f414b02768ddd" # v0.9.5
CACHE STRING "sendspin-cpp commit SHA to pin" FORCE)

# Trim sendspin-cpp to just the player role. Other roles ship dead code on a
Expand All @@ -32,6 +32,13 @@ set(SENDSPIN_ENABLE_METADATA OFF CACHE BOOL "" FORCE)
set(SENDSPIN_ENABLE_COLOR OFF CACHE BOOL "" FORCE)
set(SENDSPIN_ENABLE_ARTWORK OFF CACHE BOOL "" FORCE)
set(SENDSPIN_ENABLE_VISUALIZER OFF CACHE BOOL "" FORCE)
# Added upstream in v0.9.x and defaulted ON: the source (audio capture) role
# and its Opus encoder. We only play audio, so the role is off; the codec
# option stays ON because our advertised formats include Opus (see
# codec_name()/supported formats in src/main.cpp) and upstream made the
# decoder optional in v0.9.0.
set(SENDSPIN_ENABLE_SOURCE OFF CACHE BOOL "" FORCE)
set(SENDSPIN_ENABLE_OPUS ON CACHE BOOL "" FORCE)

include(FetchContent)
FetchContent_Declare(
Expand Down Expand Up @@ -93,6 +100,7 @@ add_executable(sendspin_player
src/main.cpp
src/alsa_pipe_sink.cpp
src/alsa_caps.cpp
src/file_persistence.cpp
)

target_link_libraries(sendspin_player
Expand Down
54 changes: 48 additions & 6 deletions c_src/sendspin_player/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,16 @@ Differences:
- Sets `SendspinClientConfig::server_port` from `--mdns-port` so multiple
binary instances can bind distinct ports (first-class upstream since
sendspin-cpp #61)
- A file-backed `SendspinPersistenceProvider` (`src/file_persistence.*`)
keeps the library's identity keypair and pairing state under
`--state-dir`, and unpaired access is enabled (see *Identity and
admission* below)

## sendspin-cpp pin

Pinned via `FetchContent` to a **commit SHA** in `CMakeLists.txt`
(`SENDSPIN_CPP_REF` cache var). Currently `9331ace6...`, upstream
`v0.8.0`. We pin to a SHA rather
(`SENDSPIN_CPP_REF` cache var). Currently `b95cf854...`, upstream
`v0.9.5`. We pin to a SHA rather
than a tag because git tags are mutable server-side; a retagged upstream
would otherwise silently flow into firmware. The friendly tag name (or
nearest-release note) lives in a comment beside the SHA for traceability.
Expand Down Expand Up @@ -142,7 +146,7 @@ Outside Mix, configure and build by hand:
cd c_src/sendspin_player
cmake -B _build -S . -DCMAKE_BUILD_TYPE=Release
cmake --build _build -j
./_build/sendspin_player --name "Test" --client-id "test-uuid"
./_build/sendspin_player --name "Test" --state-dir /tmp/sendspin-test
```

## CLI surface
Expand All @@ -153,7 +157,7 @@ The flags must match what `UniversalProxy.Audio.Player` passes — see the
| Flag | Required | Description |
| -------------------------- | -------- | -------------------------------------------- |
| `--name STR` | yes | Friendly display name |
| `--client-id STR` | yes | Stable unique client identifier (UUID) |
| `--state-dir PATH` | yes | Per-output directory for the persisted identity keypair and pairing state, created `0700`. The Sendspin `client_id` is derived from the keypair and reported on `listening` |
| `--server URL` | no | Outbound Sendspin server WebSocket URL |
| `--product STR` | no | Product id in `device_info` (node name, e.g. `universal-proxy-07507f`); vendor is fixed to `Universal Proxy`. Default `sendspin_player` |
| `--mac STR` | no | MAC for `device_info`, lowercase colon-separated. Pre-normalized by the Elixir side; empty/absent = library auto-detects from the active interface |
Expand All @@ -173,7 +177,7 @@ changes, and on errors.

```jsonc
{"event":"started","version":"0.1.0","port":8928,"name":"Out 1","product":"universal-proxy-07507f","alsa_device":"plughw:0,0","formats":[{"codec":"flac","channels":2,"rate":48000,"bit_depth":16}]}
{"event":"listening","port":8928} // WebSocket listener confirmed bound
{"event":"listening","port":8928,"client_id":"E8oqWSUo4DZKok0mgH32tIvrxxyc_Fzn-YfXfYm_mBM"} // listener bound
{"event":"connected","server":"ws://music.local:8927/sendspin"}
{"event":"disconnected"}
{"event":"stream_start","sample_rate":48000,"channels":2,"bit_depth":16,"codec":"opus"}
Expand All @@ -200,7 +204,45 @@ imply the port accepts connections yet). `Audio.Player` gates its mDNS
advertisement on this event: Music Assistant's discovery connect is one-shot
(aiosendspin `retry_initial_connection=False`), so advertising before the
listener is up hands MA a connection-refused that orphans the player until
the mDNS record next flaps.
the mDNS record next flaps. It also carries the Sendspin `client_id` (see
below), which `Audio.Player` advertises as the TXT `client_id` entry.

## Identity and admission

Since sendspin-cpp v0.9.0 (Sendspin 1.0.0-rc1) the `client_id` is not
configured. The library derives it as base64url (no padding) of a static
X25519 public key, generates that keypair on first start, and stores it
through the persistence provider. `FilePersistence` writes each library
blob as one file in `--state-dir` (dir `0700`, files `0600`, written via
temp file + fsync + rename), so the identity survives respawns and
reboots. A state dir that cannot be created fails the process before the
client starts, rather than letting it mint an identity it cannot keep. The
library only logs a failed save of a freshly generated keypair (a full or
read-only disk) and starts anyway, so the binary checks for that after
`start()` and exits with a `state_dir` error event before `listening`. A
`keypair` file that exists but cannot be read (permissions, I/O error) is
treated the same way: the library would mint a new identity, so
`FilePersistence` refuses to save over the unreadable file, leaving it
untouched, and the process exits with that error instead of changing the
`client_id`.

The library's output-delay blob is deliberately not stored: the BEAM side
already owns that value (persisted from the `static_delay` event and
passed back via `--initial-static-delay-ms`), and a stored blob would
outrank the CLI value.

The connection is Noise-encrypted. The player calls
`set_unpaired_access_enabled(true)` before `start()`, so a server that has
not paired with it can still admit it: Music Assistant approves a
player-only client that offers unpaired access without any setup step.

The library always advertises the `pairing_psk` method in `client/hello`.
It backs it with a Pairing PSK it generates on first start and stores
under the `pairing_psk` key, so `--state-dir` holds pairing secrets (the
PSK and any pairing records) as well as the identity key. We do not offer
the pairing-code methods (dynamic or static code); those would need UI to
show the code (`on_display_pairing_code`) and to confirm the pairing
window.

## Stdin commands

Expand Down
14 changes: 8 additions & 6 deletions c_src/sendspin_player/patches/0003-listener-bound-hook.patch
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
diff --git a/src/host/ws_server.cpp b/src/host/ws_server.cpp
index 0586f63..1ab37db 100644
index 7acef5f..f43e675 100644
--- a/src/host/ws_server.cpp
+++ b/src/host/ws_server.cpp
@@ -20,6 +20,16 @@
#include "server_connection.h"
@@ -26,8 +26,18 @@

#include <atomic>
#include <cstddef>
+#include <cstdint>
+
#include <utility>

+// universal_proxy patch: listener-bound hook. The embedding binary may
+// define this symbol to learn exactly when the WebSocket listener has
+// successfully bound and started accepting connections. Declared weak so
Expand All @@ -16,10 +17,11 @@ index 0586f63..1ab37db 100644
+// it fires only for THIS process's listener, never for a foreign or
+// dying-predecessor socket holding the port.
+extern "C" void sendspin_host_listener_bound(uint16_t port) __attribute__((weak));
+
namespace sendspin {

@@ -156,6 +166,13 @@ bool SendspinWsServer::start(SendspinClient* client, bool /*task_stack_in_psram*
static const char* const TAG = "sendspin.ws_server";
@@ -164,6 +174,13 @@ bool SendspinWsServer::start(SendspinClient* client, bool /*task_stack_in_psram*
}

this->server_->start();
Expand Down
197 changes: 197 additions & 0 deletions c_src/sendspin_player/src/file_persistence.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#include "file_persistence.h"

#include "sendspin/persistence_keys.h"

#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <utility>

namespace {

// Larger than any library blob (the biggest, `rec_order`, is at most 255
// bytes). A file past this is not something the library wrote.
constexpr size_t MAX_BLOB_BYTES = 4096;

constexpr size_t MAX_KEY_LEN = 32;

std::string errno_str(const char* what, const std::string& path) {
return std::string(what) + " " + path + ": " + std::strerror(errno);
}

// mkdir -p, creating each missing component 0700. An existing component
// is left as it is, except the leaf, which is forced to 0700 because it
// holds the private key.
bool make_dirs(const std::string& dir, std::string& err) {
if (dir.empty()) {
err = "state dir is empty";
return false;
}
for (size_t pos = 1; pos <= dir.size(); ++pos) {
if (pos != dir.size() && dir[pos] != '/') continue;
std::string part = dir.substr(0, pos);
if (::mkdir(part.c_str(), 0700) != 0 && errno != EEXIST) {
err = errno_str("mkdir", part);
return false;
}
}
struct stat st{};
if (::stat(dir.c_str(), &st) != 0) {
err = errno_str("stat", dir);
return false;
}
if (!S_ISDIR(st.st_mode)) {
err = "state dir " + dir + " is not a directory";
return false;
}
if (::chmod(dir.c_str(), 0700) != 0) {
err = errno_str("chmod", dir);
return false;
}
return true;
}

bool write_all(int fd, const uint8_t* data, size_t len) {
while (len > 0) {
ssize_t n = ::write(fd, data, len);
if (n < 0) {
if (errno == EINTR) continue;
return false;
}
data += n;
len -= static_cast<size_t>(n);
}
return true;
}

} // namespace

FilePersistence::FilePersistence(std::string dir) : dir_(std::move(dir)) {
while (dir_.size() > 1 && dir_.back() == '/') dir_.pop_back();
}

FilePersistence::~FilePersistence() {
if (dir_fd_ >= 0) ::close(dir_fd_);
}

bool FilePersistence::init(std::string& err) {
if (!make_dirs(dir_, err)) return false;
dir_fd_ = ::open(dir_.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dir_fd_ < 0) {
err = errno_str("open", dir_);
return false;
}
return true;
}

bool FilePersistence::valid_key(const std::string& key) {
if (key.empty() || key.size() > MAX_KEY_LEN) return false;
for (char c : key) {
bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_';
if (!ok) return false;
}
return true;
}

std::string FilePersistence::path_for(const std::string& key) const {
return dir_ + "/" + key;
}

std::optional<std::vector<uint8_t>> FilePersistence::load_blob(const std::string& key) {
if (key == sendspin::persistence_keys::OUTPUT_DELAY) return std::nullopt;
if (!valid_key(key)) {
std::fprintf(stderr, "sendspin_player: refusing persistence key '%s'\n", key.c_str());
return std::nullopt;
}
const std::string path = path_for(key);
const bool is_keypair = key == sendspin::persistence_keys::KEYPAIR;
int fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC);
if (fd < 0) {
if (errno != ENOENT) {
std::fprintf(stderr, "sendspin_player: %s\n", errno_str("open", path).c_str());
if (is_keypair) keypair_load_failed_ = true;
}
return std::nullopt;
}
std::vector<uint8_t> out;
uint8_t buf[512];
bool ok = true;
for (;;) {
ssize_t n = ::read(fd, buf, sizeof(buf));
if (n < 0) {
if (errno == EINTR) continue;
std::fprintf(stderr, "sendspin_player: %s\n", errno_str("read", path).c_str());
if (is_keypair) keypair_load_failed_ = true;
ok = false;
break;
}
if (n == 0) break;
out.insert(out.end(), buf, buf + n);
if (out.size() > MAX_BLOB_BYTES) {
std::fprintf(stderr, "sendspin_player: %s exceeds %zu bytes, ignoring\n",
path.c_str(), MAX_BLOB_BYTES);
ok = false;
break;
}
}
::close(fd);
if (!ok) return std::nullopt;
return out;
}

bool FilePersistence::save_blob(const std::string& key, const uint8_t* data, size_t len) {
// Accepted and dropped: the BEAM side owns the output delay (see the
// header comment).
if (key == sendspin::persistence_keys::OUTPUT_DELAY) return true;
if (key != sendspin::persistence_keys::KEYPAIR) return write_blob(key, data, len);
// A keypair file that exists but could not be read still holds the
// identity; the library would replace it with the one it just minted.
if (keypair_load_failed_) {
std::fprintf(stderr, "sendspin_player: not overwriting unreadable %s\n",
path_for(key).c_str());
keypair_save_failed_ = true;
return false;
}
keypair_save_failed_ = !write_blob(key, data, len);
return !keypair_save_failed_;
}

bool FilePersistence::write_blob(const std::string& key, const uint8_t* data, size_t len) {
if (!valid_key(key) || dir_fd_ < 0) {
std::fprintf(stderr, "sendspin_player: refusing persistence key '%s'\n", key.c_str());
return false;
}
const std::string path = path_for(key);
const std::string tmp = dir_ + "/." + key + ".tmp";

int fd = ::open(tmp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (fd < 0) {
std::fprintf(stderr, "sendspin_player: %s\n", errno_str("open", tmp).c_str());
return false;
}
bool ok = write_all(fd, data, len) && ::fsync(fd) == 0;
if (!ok) std::fprintf(stderr, "sendspin_player: %s\n", errno_str("write", tmp).c_str());
if (::close(fd) != 0 && ok) {
std::fprintf(stderr, "sendspin_player: %s\n", errno_str("close", tmp).c_str());
ok = false;
}
if (ok && ::rename(tmp.c_str(), path.c_str()) != 0) {
std::fprintf(stderr, "sendspin_player: %s\n", errno_str("rename", tmp).c_str());
ok = false;
}
if (!ok) {
::unlink(tmp.c_str());
return false;
}
// Make the rename itself durable before reporting the write accepted;
// the library only calls commit() after pairing material, and writing
// through here keeps every key equally safe.
if (::fsync(dir_fd_) != 0) {
std::fprintf(stderr, "sendspin_player: %s\n", errno_str("fsync", dir_).c_str());
return false;
}
return true;
}
Loading
Loading