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
77 changes: 69 additions & 8 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,42 @@ function applyTheme() {
applyThemeIcon()
}

// Write `text` to the clipboard; resolves true only once the write is
// confirmed. Browsers expose `navigator.clipboard` only on secure
// origins, and the device UI is plain HTTP, so fall back to
// `execCommand("copy")` on a temporary textarea. Call it synchronously
// from the click handler: the fallback needs the user gesture.
function copyText(text) {
if (window.isSecureContext && navigator.clipboard) {
return navigator.clipboard.writeText(text).then(() => true, err => {
console.warn("Clipboard copy failed:", err)
return false
})
}

const previousFocus = document.activeElement
const textarea = document.createElement("textarea")
textarea.value = text
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.top = "0"
textarea.style.left = "-9999px"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()

let ok = false
try {
ok = document.execCommand("copy")
} catch (err) {
console.warn("Clipboard copy failed:", err)
}

textarea.remove()
if (previousFocus && typeof previousFocus.focus === "function") previousFocus.focus()
return Promise.resolve(ok)
}

const Hooks = {
ThemeToggle: {
mounted() {
Expand Down Expand Up @@ -51,19 +87,44 @@ const Hooks = {

// Copy the element's `data-clipboard` attr to the system clipboard on
// click. Used by the Audio tab's per-card "more" disclosure for the
// client_id. Briefly mutates the button's text to give the user a
// visible "Copied" cue without round-tripping through the server.
// client_id and the revealed pairing token. Briefly mutates the
// button's text to "Copied" only once the write is confirmed; on
// failure it shows a failure cue and, when `data-select-target` names
// an element id, selects that element's text for a manual Ctrl/Cmd+C.
CopyToClipboard: {
mounted() {
this.el.addEventListener("click", () => {
const text = this.el.dataset.clipboard
if (!text || !navigator.clipboard) return
navigator.clipboard.writeText(text).then(() => {
const original = this.el.textContent
this.el.textContent = "Copied"
setTimeout(() => { this.el.textContent = original }, 1200)
}).catch(err => console.warn("Clipboard copy failed:", err))
if (!text) return
copyText(text).then(ok => {
if (ok) {
this.flash("Copied", 1200)
} else {
this.selectTarget()
this.flash("Copy failed — select manually", 2500)
}
})
})
},
destroyed() { if (this.timer) clearTimeout(this.timer) },
flash(label, ms) {
if (this.timer) clearTimeout(this.timer)
else this.original = this.el.textContent
this.el.textContent = label
this.timer = setTimeout(() => {
this.el.textContent = this.original
this.timer = null
}, ms)
},
selectTarget() {
const id = this.el.dataset.selectTarget
const target = id && document.getElementById(id)
if (!target) return
const range = document.createRange()
range.selectNodeContents(target)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
}
},

Expand Down
20 changes: 18 additions & 2 deletions c_src/sendspin_player/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ Differences:
keeps the library's identity keypair and pairing state under
`--state-dir`, and unpaired access is enabled (see *Identity and
admission* below)
- The Pairing PSK token is written to `<state-dir>/pairing_token` (never
to stdout/stderr) for the web UI to show on demand

## sendspin-cpp pin

Expand Down Expand Up @@ -157,7 +159,7 @@ The flags must match what `UniversalProxy.Audio.Player` passes — see the
| Flag | Required | Description |
| -------------------------- | -------- | -------------------------------------------- |
| `--name STR` | yes | Friendly display name |
| `--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` |
| `--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`; the pairing token is written to `pairing_token` in it |
| `--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 Down Expand Up @@ -186,6 +188,7 @@ changes, and on errors.
{"event":"volume","value":80}
{"event":"mute","value":false}
{"event":"error","kind":"alsa_configure","msg":"..."}
{"event":"error","kind":"pairing_token","msg":"..."} // non-fatal; token itself never on stdout
{"event":"shutdown"}
```

Expand Down Expand Up @@ -239,7 +242,20 @@ 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
PSK and any pairing records) as well as the identity key.

After a successful `start()` the binary writes `client.pairing_token()`
(the 107-character `SP:0…` string, RFC 4648 base32 with `2` mapped to
`9`) to `<state-dir>/pairing_token` through the same atomic `0600` write
path, rewriting it on every start so it follows the stored PSK. A server
that offers a "Pairing token" option (Music Assistant 2.10.7+) takes this
string to pair over the PSK instead of relying on unpaired access. The
token is a long-lived pairing secret, so it is never printed: the BEAM
side reads the file only when a user asks to see or copy it. If the token
is unavailable or the write fails, the binary emits a non-fatal
`pairing_token` error event (without the token) and keeps running.

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.
Expand Down
4 changes: 4 additions & 0 deletions c_src/sendspin_player/src/file_persistence.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ bool FilePersistence::save_blob(const std::string& key, const uint8_t* data, siz
return !keypair_save_failed_;
}

bool FilePersistence::write_file(const std::string& name, const std::string& data) {
return write_blob(name, reinterpret_cast<const uint8_t*>(data.data()), data.size());
}

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());
Expand Down
11 changes: 11 additions & 0 deletions c_src/sendspin_player/src/file_persistence.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
// of the wrong length as absent, so this stays a pure byte store and never
// interprets what it holds.
//
// Besides the library blobs, main.cpp stores the pairing token (the
// "SP:..." string a Sendspin server is given to pair via the Pairing PSK)
// as `pairing_token` through `write_file()`, so the BEAM side can show it
// to the user on demand without it ever crossing stdout.
//
// The output delay key is deliberately not stored. The BEAM side already
// owns that value (persisted from the `static_delay` event and passed back
// as `--initial-static-delay-ms`), and a stored blob would take precedence
Expand Down Expand Up @@ -55,6 +60,12 @@ class FilePersistence : public sendspin::SendspinPersistenceProvider {
/// reported here rather than silently replaced.
bool keypair_save_failed() const { return keypair_save_failed_; }

/// Write a file of our own (not a library blob) into the state dir with
/// the same atomic, 0600, fsynced path. `name` must not be one of the
/// library's `persistence_keys` (all at most 12 characters), or the
/// library would read it back as its own state.
bool write_file(const std::string& name, const std::string& data);

private:
/// Library keys are short lowercase identifiers (`keypair`, `rec_0`,
/// …). Anything else is refused rather than turned into a path.
Expand Down
23 changes: 21 additions & 2 deletions c_src/sendspin_player/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
// non-default WebSocket port so multiple players can coexist
// • Persists the library's identity and pairing state under --state-dir
// (file_persistence.h) and reports the derived client_id on stdout
// • Writes the Pairing PSK token to <state-dir>/pairing_token (0600) for
// the BEAM side to show on demand; it never goes to stdout or stderr
//
// Licensed under the Apache License, Version 2.0.

Expand All @@ -39,6 +41,7 @@
#include <getopt.h>
#include <iostream>
#include <mutex>
#include <optional>
#include <poll.h>
#include <sstream>
#include <string>
Expand Down Expand Up @@ -798,8 +801,9 @@ int main(int argc, char* argv[]) {
// Admit servers that have not paired with us. Music Assistant approves
// a player-only client that offers this without any setup step, which
// keeps outputs zero-config. The library always offers `pairing_psk`
// (its generated PSK is persisted like the keypair), but we have no UI
// for the pairing-code methods or for handing out the PSK token. The
// (its generated PSK is persisted like the keypair); its token is
// written to the state dir below for the web UI to hand out. There is
// no UI for the pairing-code methods. The
// library never persists this flag, so it is set on every launch,
// before start(), so the first client/hello carries it.
client.set_unpaired_access_enabled(true);
Expand Down Expand Up @@ -842,6 +846,21 @@ int main(int argc, char* argv[]) {
return 1;
}

// The token is a long-lived pairing secret: it goes only to a 0600 file
// in the 0700 state dir, never to stdout/stderr. Rewritten on every
// start so it follows the stored PSK. Failure is not fatal: unpaired
// access still works, the UI just has no token to show.
{
std::optional<std::string> token = client.pairing_token();
if (!token) {
emit_json("{\"event\":\"error\",\"kind\":\"pairing_token\","
"\"msg\":\"pairing token unavailable\"}");
} else if (!persistence.write_file("pairing_token", *token)) {
emit_json("{\"event\":\"error\",\"kind\":\"pairing_token\","
"\"msg\":\"failed to write the pairing token\"}");
}
}

{
auto v = static_cast<uint8_t>(opts.initial_volume);
audio_sink.set_volume(v);
Expand Down
22 changes: 21 additions & 1 deletion lib/universal_proxy/audio.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ defmodule UniversalProxy.Audio do
`{card_name, nil, nil}` (e.g. `{"bcm2835 Headphones", nil, nil}`).
"""

alias UniversalProxy.Audio.{Server, Store}
alias UniversalProxy.Audio.{PairingToken, Server, Store}

@typedoc """
Composite identifier for an audio output:
Expand Down Expand Up @@ -55,4 +55,24 @@ defmodule UniversalProxy.Audio do
def set_enabled({_, _, _} = key, enabled?) when is_boolean(enabled?) do
Server.set_enabled(key, enabled?)
end

@doc """
Read an output's Sendspin pairing token (the `"SP:..."` string a
Sendspin server such as Music Assistant takes to pair with the output).

Read from the player's state dir on every call and never cached or
broadcast: it is a long-lived pairing secret. `{:error, :not_ready}`
until the output's player has started once; `{:error, :unavailable}`
when the output isn't present, the audio subsystem is down, or the
file is unreadable or malformed.
"""
@spec pairing_token(key()) :: {:ok, String.t()} | {:error, :not_ready | :unavailable}
def pairing_token({_, _, _} = key) do
case Server.state_dir(key) do
{:ok, dir} -> PairingToken.read(dir)
:error -> {:error, :unavailable}
end
catch
:exit, _ -> {:error, :unavailable}
end
end
70 changes: 70 additions & 0 deletions lib/universal_proxy/audio/pairing_token.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule UniversalProxy.Audio.PairingToken do
@moduledoc """
Reads a player's Sendspin pairing token from its state dir.

`sendspin_player` writes the token (`"SP:"` followed by RFC 4648 base32,
107 characters for the current version) to `pairing_token` in its
`--state-dir` on every start. It is a long-lived pairing secret: a
server holding it can pair with the output over the Pairing PSK. It is
read only on demand, never cached here, and never included in a return
value other than `{:ok, token}`, so it cannot leak through a logged
error tuple.
"""

@filename "pairing_token"

# The spec caps a token at 127 bytes. Reads stop just past that (room for
# a trailing newline plus one byte), so a huge file is never loaded; a
# read that fills the whole buffer means the file is oversized and is
# rejected before trimming, so trailing content past the limit cannot
# hide behind whitespace.
@max_bytes 127
@read_bytes @max_bytes + 2

@type error :: :not_ready | :unavailable

@doc "The token file's name inside a player's state dir."
@spec filename() :: String.t()
def filename, do: @filename

@doc """
Read and validate the token in `state_dir`.

`{:error, :not_ready}` when the file does not exist yet (the player has
never started); `{:error, :unavailable}` when it cannot be read or does
not hold a well-formed token.
"""
@spec read(Path.t()) :: {:ok, String.t()} | {:error, error()}
def read(state_dir) when is_binary(state_dir) do
path = Path.join(state_dir, @filename)

case File.open(path, [:read, :binary], &IO.binread(&1, @read_bytes)) do
{:ok, data} when byte_size(data) == @read_bytes -> {:error, :unavailable}
{:ok, data} when is_binary(data) -> validate(data)
{:ok, _eof_or_error} -> {:error, :unavailable}
{:error, :enoent} -> {:error, :not_ready}
{:error, _reason} -> {:error, :unavailable}
end
end

@doc """
Validate raw file contents as a token: surrounding whitespace is
trimmed, and the result must start with `"SP:"`, be at most 127 bytes,
and hold only printable ASCII.
"""
@spec validate(binary()) :: {:ok, String.t()} | {:error, :unavailable}
def validate(data) when is_binary(data) do
token = String.trim(data)

if byte_size(token) in 4..@max_bytes//1 and match?("SP:" <> _, token) and
printable_ascii?(token) do
{:ok, token}
else
{:error, :unavailable}
end
end

defp printable_ascii?(<<>>), do: true
defp printable_ascii?(<<c, rest::binary>>) when c in 0x21..0x7E, do: printable_ascii?(rest)
defp printable_ascii?(_), do: false
end
30 changes: 21 additions & 9 deletions lib/universal_proxy/audio/player.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ defmodule UniversalProxy.Audio.Player do
{"event":"mute","value":false}
{"event":"static_delay","value":200}
{"event":"error","kind":"alsa_configure","msg":"..."}
{"event":"error","kind":"pairing_token","msg":"..."}
{"event":"shutdown"}

Commands (BEAM → binary, written to stdin):
Expand All @@ -56,6 +57,11 @@ defmodule UniversalProxy.Audio.Player do
{"cmd":"set_muted","value":true}
{"cmd":"shutdown"}

The binary also writes its Sendspin pairing token to `pairing_token`
in the state dir (see `state_dir/1`); that secret never appears on
stdout, so it never reaches PubSub or the logs.
`UniversalProxy.Audio.pairing_token/1` reads it on demand.

Each event is broadcast on PubSub topic `"sendspin:state"` as
`{:sendspin_state, key, event_map}` so `LiveView` (Phase 4) and the
`Audio.Server` cache can stay in sync without polling.
Expand Down Expand Up @@ -219,7 +225,7 @@ defmodule UniversalProxy.Audio.Player do
mdns_module = Keyword.get(opts, :mdns_module, MdnsLite)
device_name_fun = Keyword.get(opts, :device_name_fun, &default_device_name/0)
mac_address_fun = Keyword.get(opts, :mac_address_fun, &default_mac_address/0)
state_dir = Keyword.get(opts, :state_dir) || default_state_dir(config)
state_dir = Keyword.get(opts, :state_dir) || state_dir(config)

state = %__MODULE__{
key: key,
Expand Down Expand Up @@ -1023,14 +1029,20 @@ defmodule UniversalProxy.Audio.Player do
defp binary_or_nil(v) when is_binary(v) and v != "", do: v
defp binary_or_nil(_), do: nil

# One directory per output for the binary's keypair and pairing state,
# on the writable data partition on Nerves and under `_build/` on the
# host, like `Audio.Store`'s DETS file. Named by the Store's per-output
# `client_id`, which is generated once and never changes, so the
# Sendspin identity derived from the keypair is stable across respawns
# and reboots. That value is always generated hex today; anything else
# (a corrupted DETS row) is hashed so it can never escape the base dir.
defp default_state_dir(cfg) do
@doc """
The `--state-dir` a player for this output config uses (unless started
with an explicit `:state_dir`).

One directory per output for the binary's keypair and pairing state,
on the writable data partition on Nerves and under `_build/` on the
host, like `Audio.Store`'s DETS file. Named by the Store's per-output
`client_id`, which is generated once and never changes, so the
Sendspin identity derived from the keypair is stable across respawns
and reboots. That value is always generated hex today; anything else
(a corrupted DETS row) is hashed so it can never escape the base dir.
"""
@spec state_dir(%{required(:client_id) => String.t(), optional(any()) => any()}) :: String.t()
def state_dir(cfg) do
base =
if File.dir?("/data") do
"/data/sendspin_players"
Expand Down
Loading
Loading