From fede97e045b726e11b8c47cee90aa099ca682f82 Mon Sep 17 00:00:00 2001 From: Ben Bangert <100193+bbangert@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:04:43 +0000 Subject: [PATCH 1/2] Show each output's Sendspin pairing token on the Audio tab The player writes its Pairing PSK token to a 0600 file in its state dir, and the output card's details can reveal or copy it on demand for pasting into Music Assistant's "Pairing token" option. The token never goes to stdout, PubSub, logs or the page until the user asks for it. Co-Authored-By: Claude Opus 5.5 --- c_src/sendspin_player/README.md | 20 ++- .../sendspin_player/src/file_persistence.cpp | 4 + c_src/sendspin_player/src/file_persistence.h | 11 ++ c_src/sendspin_player/src/main.cpp | 23 ++- lib/universal_proxy/audio.ex | 22 ++- lib/universal_proxy/audio/pairing_token.ex | 66 ++++++++ lib/universal_proxy/audio/player.ex | 30 ++-- lib/universal_proxy/audio/server.ex | 21 +++ lib/universal_proxy_web/live/audio_live.ex | 142 +++++++++++++++++- test/sendspin_player_contract_test.exs | 117 +++++++++++++-- test/support/fake_sendspin_player.py | 29 +++- .../audio/pairing_token_test.exs | 60 ++++++++ test/universal_proxy/audio/player_test.exs | 11 ++ test/universal_proxy/audio/server_test.exs | 23 +++ .../live/audio_live_test.exs | 103 +++++++++++++ 15 files changed, 650 insertions(+), 32 deletions(-) create mode 100644 lib/universal_proxy/audio/pairing_token.ex create mode 100644 test/universal_proxy/audio/pairing_token_test.exs diff --git a/c_src/sendspin_player/README.md b/c_src/sendspin_player/README.md index eded48d..92ba95c 100644 --- a/c_src/sendspin_player/README.md +++ b/c_src/sendspin_player/README.md @@ -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 `/pairing_token` (never + to stdout/stderr) for the web UI to show on demand ## sendspin-cpp pin @@ -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 | @@ -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"} ``` @@ -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 `/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. diff --git a/c_src/sendspin_player/src/file_persistence.cpp b/c_src/sendspin_player/src/file_persistence.cpp index b511bd5..0ae4c48 100644 --- a/c_src/sendspin_player/src/file_persistence.cpp +++ b/c_src/sendspin_player/src/file_persistence.cpp @@ -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(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()); diff --git a/c_src/sendspin_player/src/file_persistence.h b/c_src/sendspin_player/src/file_persistence.h index 095b5f2..fe13e86 100644 --- a/c_src/sendspin_player/src/file_persistence.h +++ b/c_src/sendspin_player/src/file_persistence.h @@ -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 @@ -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. diff --git a/c_src/sendspin_player/src/main.cpp b/c_src/sendspin_player/src/main.cpp index 246edf7..1898c3a 100644 --- a/c_src/sendspin_player/src/main.cpp +++ b/c_src/sendspin_player/src/main.cpp @@ -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 /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. @@ -39,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -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); @@ -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 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(opts.initial_volume); audio_sink.set_volume(v); diff --git a/lib/universal_proxy/audio.ex b/lib/universal_proxy/audio.ex index 5ecfbe1..5b12784 100644 --- a/lib/universal_proxy/audio.ex +++ b/lib/universal_proxy/audio.ex @@ -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: @@ -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 diff --git a/lib/universal_proxy/audio/pairing_token.ex b/lib/universal_proxy/audio/pairing_token.ex new file mode 100644 index 0000000..9871020 --- /dev/null +++ b/lib/universal_proxy/audio/pairing_token.ex @@ -0,0 +1,66 @@ +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 an oversized file is rejected + # rather than silently truncated, and a huge one is never loaded. + @max_bytes 127 + + @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, @max_bytes + 2)) do + {: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?(<>) when c in 0x21..0x7E, do: printable_ascii?(rest) + defp printable_ascii?(_), do: false +end diff --git a/lib/universal_proxy/audio/player.ex b/lib/universal_proxy/audio/player.ex index 6c1e666..38d4956 100644 --- a/lib/universal_proxy/audio/player.ex +++ b/lib/universal_proxy/audio/player.ex @@ -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): @@ -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. @@ -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, @@ -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" diff --git a/lib/universal_proxy/audio/server.ex b/lib/universal_proxy/audio/server.ex index fae2f38..49f2989 100644 --- a/lib/universal_proxy/audio/server.ex +++ b/lib/universal_proxy/audio/server.ex @@ -137,6 +137,17 @@ defmodule UniversalProxy.Audio.Server do GenServer.call(server, {:set_enabled, key, enabled?}) end + @doc """ + The `--state-dir` of a currently present output's player (see + `UniversalProxy.Audio.Player.state_dir/1`). Returns `:error` if the + output isn't tracked. The dir is returned whether or not the player is + running: what it holds persists across respawns. + """ + @spec state_dir(GenServer.server(), key()) :: {:ok, String.t()} | :error + def state_dir(server \\ __MODULE__, {slot_sub, _vid, _pid} = key) when is_binary(slot_sub) do + GenServer.call(server, {:state_dir, key}) + end + @doc """ Force a hotplug poll synchronously. Tests use this to avoid waiting on the periodic timer. @@ -283,6 +294,16 @@ defmodule UniversalProxy.Audio.Server do end end + def handle_call({:state_dir, key}, _from, state) do + case Map.fetch(state.outputs, key) do + {:ok, %{client_id: id} = output} when is_binary(id) -> + {:reply, {:ok, Player.state_dir(output)}, state} + + _ -> + {:reply, :error, state} + end + end + def handle_call({:update_config, key, params}, _from, state) do with {:ok, existing} <- Map.fetch(state.outputs, key), update = sanitize_update(params), diff --git a/lib/universal_proxy_web/live/audio_live.ex b/lib/universal_proxy_web/live/audio_live.ex index 0ac4cdc..b6ad074 100644 --- a/lib/universal_proxy_web/live/audio_live.ex +++ b/lib/universal_proxy_web/live/audio_live.ex @@ -46,6 +46,17 @@ defmodule UniversalProxyWeb.AudioLive do use UniversalProxyWeb, :live_view + defmodule RevealedToken do + @moduledoc false + # A pairing token the user chose to reveal. Wrapped so a LiveView + # crash report (which inspects the socket assigns) cannot print it. + @derive {Inspect, except: [:token]} + @enforce_keys [:token] + defstruct [:token] + + @type t :: %__MODULE__{token: String.t()} + end + require Logger import UniversalProxyWeb.Components.UI @@ -117,6 +128,12 @@ defmodule UniversalProxyWeb.AudioLive do # index + client_id). Tracking as a MapSet keyed on the same # encoded ids the cards use. |> assign(:more_open, MapSet.new()) + # Per-card Sendspin pairing token disclosure, keyed by the encoded + # card id: a `RevealedToken` while shown, or the reason it could not + # be read. Absent means hidden. The token is read on demand only and + # dropped again on Hide / disclosure close / output removal. + |> assign(:pairing_tokens, %{}) + |> assign(:pairing_copied, nil) # Audio inputs (capture cards), keyed by the same base64-encoded # `{slot_sub, vid, pid}` id the outputs map above uses. |> assign(:inputs, build_inputs_map(inputs))} @@ -220,13 +237,52 @@ defmodule UniversalProxyWeb.AudioLive do def handle_event("toggle_more", %{"id" => id}, socket) do {:noreply, - update(socket, :more_open, fn open -> + socket + |> update(:more_open, fn open -> if MapSet.member?(open, id) do MapSet.delete(open, id) else MapSet.put(open, id) end - end)} + end) + |> update(:pairing_tokens, &Map.delete(&1, id))} + end + + def handle_event("show_pairing_token", %{"id" => id}, socket) do + case read_pairing_token(socket, id) do + {:ok, token} -> + {:noreply, put_pairing_token(socket, id, %RevealedToken{token: token})} + + {:error, reason} when reason in [:not_ready, :unavailable] -> + {:noreply, put_pairing_token(socket, id, reason)} + + {:error, :not_found} -> + {:noreply, socket} + end + end + + def handle_event("hide_pairing_token", %{"id" => id}, socket) do + {:noreply, update(socket, :pairing_tokens, &Map.delete(&1, id))} + end + + # Re-reads the token instead of using a revealed one, so Copy works + # while it is hidden and the token never has to be in the DOM. + def handle_event("copy_pairing_token", %{"id" => id}, socket) do + case read_pairing_token(socket, id) do + {:ok, token} -> + Process.send_after(self(), {:reset_pairing_copied, id}, 2_000) + + {:noreply, + socket + |> assign(:pairing_copied, id) + |> push_event("copy", %{text: token})} + + {:error, reason} when reason in [:not_ready, :unavailable] -> + {:noreply, put_pairing_token(socket, id, reason)} + + {:error, :not_found} -> + {:noreply, socket} + end end # Operator consent gesture for pairing a capture card. Opens the source's @@ -268,6 +324,7 @@ defmodule UniversalProxyWeb.AudioLive do socket |> update(:outputs, &Map.delete(&1, id)) |> update(:more_open, &MapSet.delete(&1, id)) + |> update(:pairing_tokens, &Map.delete(&1, id)) |> maybe_start_reconnecting(key, removed) |> assign(:bt_meta, build_bt_meta()) @@ -338,6 +395,14 @@ defmodule UniversalProxyWeb.AudioLive do end end + def handle_info({:reset_pairing_copied, id}, socket) do + if socket.assigns.pairing_copied == id do + {:noreply, assign(socket, :pairing_copied, nil)} + else + {:noreply, socket} + end + end + def handle_info(_msg, socket), do: {:noreply, socket} @impl true @@ -359,6 +424,8 @@ defmodule UniversalProxyWeb.AudioLive do hci={get_in(@bt_meta, [output.bt_mac, :hci])} battery={get_in(@bt_meta, [output.bt_mac, :battery])} more_open?={MapSet.member?(@more_open, id)} + pairing={Map.get(@pairing_tokens, id)} + pairing_copied?={@pairing_copied == id} /> <.reconnecting_card :for={{mac, info} <- active_reconnecting(@reconnecting, @outputs)} name={info.name} mac={mac} /> @@ -581,6 +648,8 @@ defmodule UniversalProxyWeb.AudioLive do attr(:hci, :string, default: nil) attr(:battery, :integer, default: nil) attr(:more_open?, :boolean, required: true) + attr(:pairing, :any, default: nil) + attr(:pairing_copied?, :boolean, default: false) # Single player card. Layout is a vertical flex with three logical # blocks: @@ -734,7 +803,8 @@ defmodule UniversalProxyWeb.AudioLive do - <%!-- Disclosure: card index + client id (mono) + copy --%> + <%!-- Disclosure: card index, client id and pairing token, each + with a copy action --%>
Pairing token + + {@pairing.token} + + + — {pairing_token_hint(@pairing)} + + •••••••• +
+ + + +
+ + Paste into Music Assistant's Sendspin "Pairing token" option to pair this output. +
<%!-- Last-error footnote (only when present) --%> @@ -1179,6 +1293,28 @@ defmodule UniversalProxyWeb.AudioLive do |> Enum.sort_by(fn {_mac, %{name: name}} -> name end) end + defp pairing_token_hint(:not_ready), do: "Available once the output has started" + defp pairing_token_hint(:unavailable), do: "Unavailable" + + # Only for a card this view still shows: a stale event for a removed + # output reads nothing. + defp read_pairing_token(socket, id) do + with {:ok, _output} <- fetch_output(socket, id), + {:ok, key} <- decode_key(id) do + audio_facade().pairing_token(key) + else + _ -> {:error, :not_found} + end + end + + defp put_pairing_token(socket, id, value) do + update(socket, :pairing_tokens, &Map.put(&1, id, value)) + end + + # Test seam, like `OverviewLive`'s `:storage_facade`: the app-tree + # `Audio.Server` runs with no outputs in test. + defp audio_facade, do: Application.get_env(:universal_proxy, :audio_facade, Audio) + defp fetch_output(socket, id) do case Map.fetch(socket.assigns.outputs, id) do {:ok, _} = ok -> ok diff --git a/test/sendspin_player_contract_test.exs b/test/sendspin_player_contract_test.exs index 4c94478..20b16f1 100644 --- a/test/sendspin_player_contract_test.exs +++ b/test/sendspin_player_contract_test.exs @@ -27,6 +27,11 @@ defmodule SendspinPlayerContractTest do # the upstream default (8928). @ws_port 18_928 + # pairing.md "Pairing Token", version 0: "SP:" + version + RFC 4648 + # base32 of key || PSK (64 bytes → 103 chars unpadded), with `2` mapped + # to `9`. + @pairing_token_re ~r/\ASP:0[A-Z3-79]{103}\z/ + # chmod 000 does not stop root from reading a file. @running_as_root (case System.cmd("id", ["-u"]) do {"0\n", 0} -> true @@ -168,6 +173,65 @@ defmodule SendspinPlayerContractTest do assert assert_listening_event(again)["client_id"] == first["client_id"] end + test "writes the pairing token privately under --state-dir", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "pairing_token") + assert %File.Stat{mode: mode} = File.stat!(path) + assert Bitwise.band(mode, 0o777) == 0o600 + + token = File.read!(path) + assert byte_size(token) == 107 + assert token =~ @pairing_token_re + assert UniversalProxy.Audio.PairingToken.read(tmp_dir) == {:ok, token} + + # Our own file, so the temp file of its atomic write must be gone. + refute File.exists?(Path.join(tmp_dir, ".pairing_token.tmp")) + end + + test "keeps the same pairing token across a restart", %{port: port, tmp_dir: tmp_dir} do + token = File.read!(Path.join(tmp_dir, "pairing_token")) + send_command(port, ~s({"cmd":"shutdown"})) + assert wait_for_exit(port) == 0 + + again = spawn_player(state_dir: tmp_dir, ws_port: @ws_port + 7) + + os_pid = + case Port.info(again, :os_pid) do + {:os_pid, pid} -> pid + _ -> nil + end + + on_exit(fn -> shutdown_os_process(os_pid) end) + + assert_started_event(again) + assert_volume_event(again) + assert File.read!(Path.join(tmp_dir, "pairing_token")) == token + end + + test "never prints the pairing token on stdout or stderr", %{tmp_dir: tmp_dir} do + # Verbose library logging on, stderr merged into the captured stream, + # across a full start → listening → shutdown cycle. + dir = Path.join(tmp_dir, "quiet") + + port = + spawn_player( + state_dir: dir, + ws_port: @ws_port + 8, + log_level: "verbose", + port_opts: [:stderr_to_stdout] + ) + + output = collect_until(port, ~s("event":"listening"), "") + send_command(port, ~s({"cmd":"shutdown"})) + {output, 0} = collect_until_exit(port, output) + + assert output =~ ~s("event":"listening") + token = File.read!(Path.join(dir, "pairing_token")) + assert token =~ @pairing_token_re + refute String.contains?(output, token) + # Nor the bare body, in case something logs it without the prefix. + refute String.contains?(output, binary_part(token, 4, 103)) + end + test "exits before `listening` when the keypair cannot be persisted", %{tmp_dir: tmp_dir} do # The state dir itself initialises fine and holds no keypair; a # directory at the provider's temp path then makes the library's first @@ -301,6 +365,8 @@ defmodule SendspinPlayerContractTest do defp spawn_player(opts) do ws_port = Keyword.get(opts, :ws_port, @ws_port) extra = Keyword.get(opts, :extra, []) + log_level = Keyword.get(opts, :log_level, "error") + port_opts = Keyword.get(opts, :port_opts, []) state_dir_args = case Keyword.fetch!(opts, :state_dir) do @@ -314,19 +380,20 @@ defmodule SendspinPlayerContractTest do :binary, :exit_status, {:line, 1024}, - args: - [ - "--name", - "contract-test", - "--mdns-port", - Integer.to_string(ws_port), - "--alsa-device", - "default", - "--initial-volume", - "42", - "--log-level", - "error" - ] ++ state_dir_args ++ extra + {:args, + [ + "--name", + "contract-test", + "--mdns-port", + Integer.to_string(ws_port), + "--alsa-device", + "default", + "--initial-volume", + "42", + "--log-level", + log_level + ] ++ state_dir_args ++ extra} + | port_opts ] ) end @@ -360,6 +427,30 @@ defmodule SendspinPlayerContractTest do end end + # Raw captured output (lines and partial lines alike) until `marker` + # shows up. + defp collect_until(port, marker, acc) do + if String.contains?(acc, marker) do + acc + else + receive do + {^port, {:data, {_eol_or_noeol, chunk}}} -> + collect_until(port, marker, acc <> chunk <> "\n") + after + 5_000 -> flunk("#{inspect(marker)} not seen within 5s") + end + end + end + + defp collect_until_exit(port, acc) do + receive do + {^port, {:data, {_eol_or_noeol, chunk}}} -> collect_until_exit(port, acc <> chunk <> "\n") + {^port, {:exit_status, status}} -> {acc, status} + after + 5_000 -> flunk("binary did not exit within 5s of shutdown command") + end + end + defp send_command(port, json) when is_binary(json) do Port.command(port, json <> "\n") end diff --git a/test/support/fake_sendspin_player.py b/test/support/fake_sendspin_player.py index 37dc43e..2399594 100755 --- a/test/support/fake_sendspin_player.py +++ b/test/support/fake_sendspin_player.py @@ -9,6 +9,7 @@ startup → {"event":"started","version":...,"port":...,"name":...,"alsa_device":...} {"event":"volume","value":} {"event":"static_delay","value":} + (writes /pairing_token, 0600; never printed) {"event":"listening","port":...,"client_id":...} stdin commands → stdout events {"cmd":"set_volume","value":N} → {"event":"volume","value":N} @@ -36,6 +37,26 @@ # shape. Tests assert on this constant. FAKE_CLIENT_ID = "ZmFrZS1zZW5kc3Bpbi1wbGF5ZXItaWQtMzJieXRlcyE" +# Same shape as the real binary's token ("SP:" + version "0" + 103 base32 +# chars, 2 mapped to 9). Like the real one it is only ever written to +# /pairing_token, never to stdout/stderr. +FAKE_PAIRING_TOKEN = "SP:0" + ("FAKETOKEN" * 12)[:103] + + +def write_pairing_token(state_dir): + """Mirror main.cpp: atomic 0600 write into a 0700 dir; a failure is a + non-fatal `pairing_token` error event that never carries the token.""" + try: + os.makedirs(state_dir, mode=0o700, exist_ok=True) + tmp = os.path.join(state_dir, ".pairing_token.tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(FAKE_PAIRING_TOKEN) + os.replace(tmp, os.path.join(state_dir, "pairing_token")) + except OSError: + emit({"event": "error", "kind": "pairing_token", + "msg": "failed to write the pairing token"}) + def emit(event): sys.stdout.write(json.dumps(event) + "\n") @@ -51,8 +72,8 @@ def main(): # is a fatal error. Marking them required here means a regression # that drops either arg from `Audio.Player.build_cli_args/1` # surfaces as a non-zero exit from the fake, which Player.init - # reports as `{:binary_exited, _}` and tests fail. The fake never - # writes to the state dir. + # reports as `{:binary_exited, _}` and tests fail. The only thing the + # fake writes to the state dir is the pairing token file. p.add_argument("--name", required=True) p.add_argument("--state-dir", required=True) p.add_argument("--product", default="sendspin_player") @@ -84,6 +105,10 @@ def main(): "alsa_device": args.alsa_device, }) + # Like the real binary, write the pairing token after start() and + # before the initial `volume` event. + write_pairing_token(args.state_dir) + # Real binary emits a separate `volume` event after `started` to # publish the initial volume (main.cpp:642-649). emit({"event": "volume", "value": args.initial_volume}) diff --git a/test/universal_proxy/audio/pairing_token_test.exs b/test/universal_proxy/audio/pairing_token_test.exs new file mode 100644 index 0000000..3d92244 --- /dev/null +++ b/test/universal_proxy/audio/pairing_token_test.exs @@ -0,0 +1,60 @@ +defmodule UniversalProxy.Audio.PairingTokenTest do + use ExUnit.Case, async: true + + alias UniversalProxy.Audio + alias UniversalProxy.Audio.PairingToken + + @moduletag :tmp_dir + + # Shape of a real token: "SP:" + version "0" + 103 base32 chars. + @token "SP:0" <> String.duplicate("ABCDEFGH34567", 7) <> "ABCDEFGHIJKL" + + defp write!(dir, data), do: File.write!(Path.join(dir, PairingToken.filename()), data) + + test "a missing file is not ready", %{tmp_dir: dir} do + assert PairingToken.read(dir) == {:error, :not_ready} + assert PairingToken.read(Path.join(dir, "absent")) == {:error, :not_ready} + end + + test "returns a valid token, trimmed", %{tmp_dir: dir} do + assert byte_size(@token) == 107 + write!(dir, @token) + assert PairingToken.read(dir) == {:ok, @token} + + write!(dir, @token <> "\n") + assert PairingToken.read(dir) == {:ok, @token} + end + + test "rejects malformed contents", %{tmp_dir: dir} do + for bad <- [ + "", + "\n", + "SP:", + "XX:0ABC", + "sp:0ABC", + "SP:0AB C", + "SP:0ABé", + "SP:0AB\0", + <<"SP:0", 0xFF>>, + "SP:" <> String.duplicate("A", 125) + ] do + write!(dir, bad) + assert PairingToken.read(dir) == {:error, :unavailable}, "accepted #{inspect(bad)}" + end + end + + test "accepts the 127-byte maximum", %{tmp_dir: dir} do + token = "SP:" <> String.duplicate("A", 124) + write!(dir, token) + assert PairingToken.read(dir) == {:ok, token} + end + + test "an unreadable token path is unavailable", %{tmp_dir: dir} do + File.mkdir_p!(Path.join(dir, PairingToken.filename())) + assert PairingToken.read(dir) == {:error, :unavailable} + end + + test "Audio.pairing_token/1 is unavailable for an unknown output" do + assert Audio.pairing_token({"no such output", nil, nil}) == {:error, :unavailable} + end +end diff --git a/test/universal_proxy/audio/player_test.exs b/test/universal_proxy/audio/player_test.exs index 2fbda9c..c11b143 100644 --- a/test/universal_proxy/audio/player_test.exs +++ b/test/universal_proxy/audio/player_test.exs @@ -226,6 +226,17 @@ defmodule UniversalProxy.Audio.PlayerTest do refute "--client-id" in Player.__cli_args__(state) end + @tag :tmp_dir + test "the pairing token lands in the state dir, never on PubSub", %{tmp_dir: tmp_dir} do + _pid = start_player!(state_dir: tmp_dir) + assert_receive {:sendspin_state, @key, %{event: "listening"}}, 2_000 + + assert {:ok, "SP:0" <> _ = token} = UniversalProxy.Audio.PairingToken.read(tmp_dir) + + {:messages, messages} = Process.info(self(), :messages) + refute inspect(messages, limit: :infinity, printable_limit: :infinity) =~ token + end + test "hashes a Store client_id that is not a safe directory name" do # Only `generate_client_id/0` writes the field today; this guards a # corrupted DETS row from turning into a path outside the base dir. diff --git a/test/universal_proxy/audio/server_test.exs b/test/universal_proxy/audio/server_test.exs index 3df1ca0..f473e54 100644 --- a/test/universal_proxy/audio/server_test.exs +++ b/test/universal_proxy/audio/server_test.exs @@ -625,6 +625,29 @@ defmodule UniversalProxy.Audio.ServerTest do end end + describe "state_dir/2" do + test "returns the player's state dir for a present output only", %{server: server} do + EnumerateStub.set(%{ + @hp_key => %{ + card_index: 0, + alsa_device: "plughw:0,0", + card_name: "bcm2835 Headphones", + usb_port: nil + } + }) + + :ok = Server.check_now(server) + assert_receive {:sendspin_output_added, _} + + {:ok, output} = Server.get_output(server, @hp_key) + assert {:ok, dir} = Server.state_dir(server, @hp_key) + assert dir == UniversalProxy.Audio.Player.state_dir(output) + assert Path.basename(dir) == output.client_id + + assert :error = Server.state_dir(server, {"nope", nil, nil}) + end + end + describe "update_config/3" do setup %{server: server} do EnumerateStub.set(%{ diff --git a/test/universal_proxy_web/live/audio_live_test.exs b/test/universal_proxy_web/live/audio_live_test.exs index a853078..3d92f2a 100644 --- a/test/universal_proxy_web/live/audio_live_test.exs +++ b/test/universal_proxy_web/live/audio_live_test.exs @@ -815,4 +815,107 @@ defmodule UniversalProxyWeb.AudioLiveTest do refute html =~ "Reconnecting" assert html =~ "No audio outputs detected" end + + describe "pairing token disclosure" do + @token "SP:0" <> String.duplicate("PAIRTOKEN", 12) + + defmodule PairingTokenFacade do + @moduledoc false + # Stands in for `UniversalProxy.Audio` (see AudioLive's + # `:audio_facade` seam); the result is set per test. + def pairing_token(key) do + send(Application.get_env(:universal_proxy, :pairing_token_test_pid), {:read, key}) + Application.get_env(:universal_proxy, :pairing_token_result) + end + end + + setup do + Application.put_env(:universal_proxy, :audio_facade, PairingTokenFacade) + Application.put_env(:universal_proxy, :pairing_token_test_pid, self()) + Application.put_env(:universal_proxy, :pairing_token_result, {:ok, @token}) + + on_exit(fn -> + Application.delete_env(:universal_proxy, :audio_facade) + Application.delete_env(:universal_proxy, :pairing_token_test_pid) + Application.delete_env(:universal_proxy, :pairing_token_result) + end) + end + + defp open_disclosure(conn) do + {:ok, view, _html} = live(conn, "/audio") + + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:output_added", + {:sendspin_output_added, sample_output()} + ) + + id = encode(@hp_key) + html = render_hook(view, "toggle_more", %{"id" => id}) + {view, id, html} + end + + test "is hidden until Show, and hidden again after Hide", %{conn: conn} do + {view, id, html} = open_disclosure(conn) + + assert html =~ "Pairing token" + assert html =~ "Pairing token" option" + refute html =~ @token + # Nothing is read until the user asks. + refute_received {:read, _} + + html = view |> element("button[phx-click=show_pairing_token]") |> render_click() + assert_received {:read, @hp_key} + assert html =~ @token + + html = view |> element("button[phx-click=hide_pairing_token]") |> render_click() + refute html =~ @token + + # Closing the disclosure drops a revealed token too. + render_hook(view, "show_pairing_token", %{"id" => id}) + assert render(view) =~ @token + render_hook(view, "toggle_more", %{"id" => id}) + html = render_hook(view, "toggle_more", %{"id" => id}) + refute html =~ @token + end + + test "Copy pushes the token without revealing it", %{conn: conn} do + {view, _id, _html} = open_disclosure(conn) + + html = view |> element("button[phx-click=copy_pairing_token]") |> render_click() + assert_push_event(view, "copy", %{text: @token}) + refute html =~ @token + assert html =~ "Copied" + end + + test "shows a hint instead when the token is not ready", %{conn: conn} do + Application.put_env(:universal_proxy, :pairing_token_result, {:error, :not_ready}) + {view, _id, _html} = open_disclosure(conn) + + html = view |> element("button[phx-click=show_pairing_token]") |> render_click() + assert html =~ "Available once the output has started" + refute has_element?(view, "button[phx-click=copy_pairing_token]") + end + + test "ignores events for an output that is gone", %{conn: conn} do + {view, id, _html} = open_disclosure(conn) + render_hook(view, "show_pairing_token", %{"id" => id}) + assert render(view) =~ @token + + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:output_removed", + {:sendspin_output_removed, %{key: @hp_key}} + ) + + refute render(view) =~ @token + assert_received {:read, @hp_key} + + render_hook(view, "show_pairing_token", %{"id" => id}) + render_hook(view, "copy_pairing_token", %{"id" => id}) + render_hook(view, "show_pairing_token", %{"id" => "garbage"}) + refute_received {:read, _} + refute render(view) =~ @token + end + end end From 31d72a0c3a2f8a1a53009de3b4f5b0a4bdcda56c Mon Sep 17 00:00:00 2001 From: Ben Bangert <100193+bbangert@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:14:24 +0000 Subject: [PATCH 2/2] Confirm clipboard copies and reject oversized token files The device UI is plain HTTP, where navigator.clipboard is unavailable, so copy falls back to execCommand and reports "Copied" only on success; the pairing token is copied client-side once revealed. A token file that fills the read buffer is now rejected before trimming. Co-Authored-By: Claude Opus 5.5 --- assets/js/app.js | 77 +++++++++++++++++-- lib/universal_proxy/audio/pairing_token.ex | 10 ++- lib/universal_proxy_web/live/audio_live.ex | 45 +++-------- .../audio/pairing_token_test.exs | 12 +++ .../live/audio_live_test.exs | 29 +++++-- 5 files changed, 119 insertions(+), 54 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 4178a8d..ea4fb0e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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() { @@ -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) } }, diff --git a/lib/universal_proxy/audio/pairing_token.ex b/lib/universal_proxy/audio/pairing_token.ex index 9871020..1aa0733 100644 --- a/lib/universal_proxy/audio/pairing_token.ex +++ b/lib/universal_proxy/audio/pairing_token.ex @@ -14,9 +14,12 @@ defmodule UniversalProxy.Audio.PairingToken do @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 an oversized file is rejected - # rather than silently truncated, and a huge one is never loaded. + # 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 @@ -35,7 +38,8 @@ defmodule UniversalProxy.Audio.PairingToken do 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, @max_bytes + 2)) do + 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} diff --git a/lib/universal_proxy_web/live/audio_live.ex b/lib/universal_proxy_web/live/audio_live.ex index b6ad074..5b57d3b 100644 --- a/lib/universal_proxy_web/live/audio_live.ex +++ b/lib/universal_proxy_web/live/audio_live.ex @@ -133,7 +133,6 @@ defmodule UniversalProxyWeb.AudioLive do # be read. Absent means hidden. The token is read on demand only and # dropped again on Hide / disclosure close / output removal. |> assign(:pairing_tokens, %{}) - |> assign(:pairing_copied, nil) # Audio inputs (capture cards), keyed by the same base64-encoded # `{slot_sub, vid, pid}` id the outputs map above uses. |> assign(:inputs, build_inputs_map(inputs))} @@ -265,26 +264,6 @@ defmodule UniversalProxyWeb.AudioLive do {:noreply, update(socket, :pairing_tokens, &Map.delete(&1, id))} end - # Re-reads the token instead of using a revealed one, so Copy works - # while it is hidden and the token never has to be in the DOM. - def handle_event("copy_pairing_token", %{"id" => id}, socket) do - case read_pairing_token(socket, id) do - {:ok, token} -> - Process.send_after(self(), {:reset_pairing_copied, id}, 2_000) - - {:noreply, - socket - |> assign(:pairing_copied, id) - |> push_event("copy", %{text: token})} - - {:error, reason} when reason in [:not_ready, :unavailable] -> - {:noreply, put_pairing_token(socket, id, reason)} - - {:error, :not_found} -> - {:noreply, socket} - end - end - # Operator consent gesture for pairing a capture card. Opens the source's # time-boxed "allow pairing" window; the source stays authoritative over what # happens next (it still requires MA to type the derived PIN). @@ -395,14 +374,6 @@ defmodule UniversalProxyWeb.AudioLive do end end - def handle_info({:reset_pairing_copied, id}, socket) do - if socket.assigns.pairing_copied == id do - {:noreply, assign(socket, :pairing_copied, nil)} - else - {:noreply, socket} - end - end - def handle_info(_msg, socket), do: {:noreply, socket} @impl true @@ -425,7 +396,6 @@ defmodule UniversalProxyWeb.AudioLive do battery={get_in(@bt_meta, [output.bt_mac, :battery])} more_open?={MapSet.member?(@more_open, id)} pairing={Map.get(@pairing_tokens, id)} - pairing_copied?={@pairing_copied == id} /> <.reconnecting_card :for={{mac, info} <- active_reconnecting(@reconnecting, @outputs)} name={info.name} mac={mac} /> @@ -649,7 +619,6 @@ defmodule UniversalProxyWeb.AudioLive do attr(:battery, :integer, default: nil) attr(:more_open?, :boolean, required: true) attr(:pairing, :any, default: nil) - attr(:pairing_copied?, :boolean, default: false) # Single player card. Layout is a vertical flex with three logical # blocks: @@ -856,14 +825,20 @@ defmodule UniversalProxyWeb.AudioLive do > Show + <%!-- Copy is offered only while revealed, so the token is in the + DOM only after Show; the hook copies client-side and falls + back to selecting the token where the clipboard is blocked + (non-secure origins). --%> diff --git a/test/universal_proxy/audio/pairing_token_test.exs b/test/universal_proxy/audio/pairing_token_test.exs index 3d92244..566d2d6 100644 --- a/test/universal_proxy/audio/pairing_token_test.exs +++ b/test/universal_proxy/audio/pairing_token_test.exs @@ -47,6 +47,18 @@ defmodule UniversalProxy.Audio.PairingTokenTest do token = "SP:" <> String.duplicate("A", 124) write!(dir, token) assert PairingToken.read(dir) == {:ok, token} + + write!(dir, token <> "\n") + assert PairingToken.read(dir) == {:ok, token} + end + + test "rejects a file whose content runs past the read limit", %{tmp_dir: dir} do + token = "SP:" <> String.duplicate("A", 124) + write!(dir, token <> "\n\n" <> "garbage") + assert PairingToken.read(dir) == {:error, :unavailable} + + write!(dir, @token <> String.duplicate(" ", 22) <> "garbage") + assert PairingToken.read(dir) == {:error, :unavailable} end test "an unreadable token path is unavailable", %{tmp_dir: dir} do diff --git a/test/universal_proxy_web/live/audio_live_test.exs b/test/universal_proxy_web/live/audio_live_test.exs index 3d92f2a..2a507a1 100644 --- a/test/universal_proxy_web/live/audio_live_test.exs +++ b/test/universal_proxy_web/live/audio_live_test.exs @@ -879,22 +879,36 @@ defmodule UniversalProxyWeb.AudioLiveTest do refute html =~ @token end - test "Copy pushes the token without revealing it", %{conn: conn} do - {view, _id, _html} = open_disclosure(conn) + test "Copy is offered only while the token is revealed", %{conn: conn} do + {view, id, html} = open_disclosure(conn) + copy = "#copy-pairing-token-#{id}" + + refute html =~ @token + refute has_element?(view, copy) + + view |> element("button[phx-click=show_pairing_token]") |> render_click() - html = view |> element("button[phx-click=copy_pairing_token]") |> render_click() - assert_push_event(view, "copy", %{text: @token}) + assert has_element?(view, "#pairing-token-#{id}", @token) + + assert has_element?( + view, + ~s(#{copy}[phx-hook="CopyToClipboard"][data-clipboard="#{@token}"]) <> + ~s([data-select-target="pairing-token-#{id}"]) + ) + + html = view |> element("button[phx-click=hide_pairing_token]") |> render_click() refute html =~ @token - assert html =~ "Copied" + refute has_element?(view, copy) + refute has_element?(view, "#pairing-token-#{id}") end test "shows a hint instead when the token is not ready", %{conn: conn} do Application.put_env(:universal_proxy, :pairing_token_result, {:error, :not_ready}) - {view, _id, _html} = open_disclosure(conn) + {view, id, _html} = open_disclosure(conn) html = view |> element("button[phx-click=show_pairing_token]") |> render_click() assert html =~ "Available once the output has started" - refute has_element?(view, "button[phx-click=copy_pairing_token]") + refute has_element?(view, "#copy-pairing-token-#{id}") end test "ignores events for an output that is gone", %{conn: conn} do @@ -912,7 +926,6 @@ defmodule UniversalProxyWeb.AudioLiveTest do assert_received {:read, @hp_key} render_hook(view, "show_pairing_token", %{"id" => id}) - render_hook(view, "copy_pairing_token", %{"id" => id}) render_hook(view, "show_pairing_token", %{"id" => "garbage"}) refute_received {:read, _} refute render(view) =~ @token