#!/usr/bin/env bash # Sending one application to a different output is the thing every mixer on # every other desktop has and Panama's did not. SoundRouting is the singleton # that does it, because moving a stream means `pactl move-sink-input` and the # Sound page is banned from shelling out. # # The parts that are easy to get wrong and impossible to notice: # # * an application is a *group* of streams -- a browser playing two tabs has # two sink inputs, and moving one of them is worse than moving none; # * pactl wants the stream's `object.serial`, not its node id, and the two # are different numbers that both look plausible in a log; # * a stream with no serial still has to move, on its node id; # * an application that stopped playing between the click and the call must # not turn into a pactl invocation with an empty argument; # * putting an application *back* on the system default is not the same call # in reverse -- moving it to the default sink pins it there, so it stops # following the moment the default moves again. # # Runs against recording pactl and pw-metadata stubs. No real stream is ever # moved and no real metadata key is ever deleted. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" service="$repo_dir/config/dot/quickshell/services/SoundRouting.qml" harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml" fixture="$(mktemp -d /tmp/panama-sound-routing.XXXXXX)" state_home="$fixture/state" shell_log="$fixture/quickshell.log" harness_pid="" fail() { printf 'sound routing contract: %s\n' "$1" >&2 [[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2 exit 1 } [[ -f "$service" ]] || fail 'SoundRouting.qml is missing' [[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing' mkdir -p "$fixture/bin" "$state_home" cat >"$fixture/bin/pactl" <<'STUB' #!/usr/bin/env bash printf 'pactl' >>"$PANAMA_SOUND_PACTL_LOG" printf ' <%s>' "$@" >>"$PANAMA_SOUND_PACTL_LOG" printf '\n' >>"$PANAMA_SOUND_PACTL_LOG" for arg in "$@"; do [[ "$arg" == "cards" ]] && { printf '[]\n'; exit 0; } done if [[ "$(cat "$PANAMA_SOUND_MOVE_MODE" 2>/dev/null || printf 'good')" == "failing" ]]; then printf 'Failure: No such entity\n' >&2 exit 1 fi exit 0 STUB chmod +x "$fixture/bin/pactl" cat >"$fixture/bin/pw-metadata" <<'STUB' #!/usr/bin/env bash printf 'pw-metadata' >>"$PANAMA_SOUND_PACTL_LOG" printf ' <%s>' "$@" >>"$PANAMA_SOUND_PACTL_LOG" printf '\n' >>"$PANAMA_SOUND_PACTL_LOG" exit 0 STUB chmod +x "$fixture/bin/pw-metadata" # SoundCards and SoundDefaults share this harness and would otherwise read the # live daemon. Both take a fixture path; give them inert ones so every line in # the log below came from SoundRouting. printf '[]\n' >"$fixture/cards.json" : >"$fixture/defaults" export PANAMA_SOUND_PACTL_LOG="$fixture/pactl.log" export PANAMA_SOUND_MOVE_MODE="$fixture/mode" export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json" export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults" : >"$PANAMA_SOUND_PACTL_LOG" printf 'good\n' >"$PANAMA_SOUND_MOVE_MODE" instances_for_harness() { qs list --all 2>/dev/null | awk -v expected="$harness" ' /^Instance / { pid = "" } /^[[:space:]]*Process ID:/ { pid = $3 } /^[[:space:]]*Config path:/ { path = $0 sub(/^[[:space:]]*Config path: /, "", path) if (path == expected && pid ~ /^[0-9]+$/) print pid } ' } cleanup() { if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then kill "$harness_pid" 2>/dev/null || true for _ in $(seq 1 40); do kill -0 "$harness_pid" 2>/dev/null || break sleep 0.05 done fi rm -rf "$fixture" } trap cleanup EXIT run() { PATH="$fixture/bin:$PATH" \ XDG_STATE_HOME="$state_home" \ PANAMA_SOUND_PACTL_LOG="$PANAMA_SOUND_PACTL_LOG" \ PANAMA_SOUND_MOVE_MODE="$PANAMA_SOUND_MOVE_MODE" \ PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \ PANAMA_SOUND_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \ qs -p "$harness" "$@" } ipc() { if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then run ipc --pid "$harness_pid" "$@" else run ipc "$@" fi } await_log() { local needle="$1" for _ in $(seq 1 60); do grep -Fq "$needle" "$PANAMA_SOUND_PACTL_LOG" && return 0 sleep 0.1 done return 1 } run --daemonize >"$shell_log" 2>&1 || fail 'sound services harness did not launch' for _ in $(seq 1 60); do harness_pid="$(instances_for_harness | head -1)" if [[ "$harness_pid" =~ ^[0-9]+$ ]] \ && ipc show 2>/dev/null | rg -q '^target sound-services-test$'; then break fi sleep 0.1 done [[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'sound services harness process did not start' # ── Every stream in the group moves ────────────────────────────────────────── : >"$PANAMA_SOUND_PACTL_LOG" ipc call sound-services-test moveApplication twoStream \ alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null await_log 'move-sink-input' \ || fail 'moving an application never reached pactl' for _ in $(seq 1 40); do [[ "$(grep -Fc 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG")" == "2" ]] && break sleep 0.05 done grep -Fq 'pactl <412> ' \ "$PANAMA_SOUND_PACTL_LOG" \ || fail "the first stream was not moved by its object.serial: $(cat "$PANAMA_SOUND_PACTL_LOG")" grep -Fq 'pactl <418> ' \ "$PANAMA_SOUND_PACTL_LOG" \ || fail "the second stream of the same application stayed behind: $(cat "$PANAMA_SOUND_PACTL_LOG")" # The node id is a different number that looks just as plausible in a log, and # pactl will happily move whatever stream happens to carry it. if grep -Eq 'move-sink-input> <(61|62)>' "$PANAMA_SOUND_PACTL_LOG"; then fail "streams were moved by node id instead of object.serial: $(cat "$PANAMA_SOUND_PACTL_LOG")" fi state="$(ipc call sound-services-test routing)" for _ in $(seq 1 40); do state="$(ipc call sound-services-test routing)" jq -e '.busy == false' >/dev/null <<<"$state" && break sleep 0.1 done jq -e '.busy == false and .lastError == ""' >/dev/null <<<"$state" \ || fail "a successful move left the service busy or carrying an error: $state" # ── A stream PipeWire never gave a serial still moves ──────────────────────── : >"$PANAMA_SOUND_PACTL_LOG" ipc call sound-services-test moveApplication serialless \ alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null await_log 'move-sink-input' || fail 'a serial-less stream was skipped entirely' grep -Fq 'pactl <77> ' \ "$PANAMA_SOUND_PACTL_LOG" \ || fail "a stream with no object.serial did not fall back to its node id: $(cat "$PANAMA_SOUND_PACTL_LOG")" # ── Nothing to move, nothing to run ────────────────────────────────────────── # An application that stopped playing between the click and the call is the # ordinary case, not an error, and it must not become `pactl move-sink-input # "" ` -- which pactl answers by moving something else. : >"$PANAMA_SOUND_PACTL_LOG" ipc call sound-services-test moveApplication empty \ alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null || true ipc call sound-services-test moveApplication twoStream "" >/dev/null || true sleep 0.3 if grep -Fq 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG"; then fail "an empty group or an unnamed sink still started a move: $(cat "$PANAMA_SOUND_PACTL_LOG")" fi # ── Following the system default again ─────────────────────────────────────── # Not the same call in reverse. `move-sink-input` to the current default *pins* # the stream there, so it would stop following and quietly stay behind the next # time the default moved -- the exact bug the button exists to undo. The pin # lives as a `target.object` key on the stream's node id in PipeWire's default # metadata, and releasing it means deleting that key. # # Note the id asymmetry, which is the other way to get this wrong: pactl speaks # object serials (412, 418) and pw-metadata speaks node ids (61, 62). They are # different numbers for the same stream and both look right in a log. : >"$PANAMA_SOUND_PACTL_LOG" ipc call sound-services-test routeToDefault twoStream >/dev/null await_log 'target.object' || fail 'returning an application to the default ran nothing' for _ in $(seq 1 60); do [[ "$(grep -Fc 'pw-metadata' "$PANAMA_SOUND_PACTL_LOG")" == "4" ]] && break sleep 0.05 done grep -Fq 'pw-metadata <-n> <-d> <61> ' "$PANAMA_SOUND_PACTL_LOG" \ || fail "the first stream's pin was not released: $(cat "$PANAMA_SOUND_PACTL_LOG")" grep -Fq 'pw-metadata <-n> <-d> <62> ' "$PANAMA_SOUND_PACTL_LOG" \ || fail "the second stream of the same application stayed pinned: $(cat "$PANAMA_SOUND_PACTL_LOG")" # Streams pinned by older tooling carry `target.node` instead, and a release # that only deletes one of the two keys leaves half the population stuck. grep -Fq 'pw-metadata <-n> <-d> <61> ' "$PANAMA_SOUND_PACTL_LOG" \ || fail "the legacy target.node pin was left in place: $(cat "$PANAMA_SOUND_PACTL_LOG")" if grep -Fq 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG"; then fail "returning to the default moved the stream to a sink, which pins it there instead of releasing it: $(cat "$PANAMA_SOUND_PACTL_LOG")" fi if grep -Eq 'pw-metadata <.*> <(412|418)>' "$PANAMA_SOUND_PACTL_LOG"; then fail "pw-metadata was given an object serial where it wants a node id: $(cat "$PANAMA_SOUND_PACTL_LOG")" fi : >"$PANAMA_SOUND_PACTL_LOG" ipc call sound-services-test routeToDefault empty >/dev/null || true sleep 0.3 [[ ! -s "$PANAMA_SOUND_PACTL_LOG" ]] \ || fail "an empty group still ran a command: $(cat "$PANAMA_SOUND_PACTL_LOG")" # ── Where an application is playing ────────────────────────────────────────── # Fixture streams are not in the real graph, so nothing links them to a sink. # That is exactly the "follows the system default" case, and it reads as an # empty string rather than "unknown", a null, or a thrown error. [[ "$(ipc call sound-services-test currentSinkFor twoStream)" == "" ]] \ || fail 'an unlinked stream did not read as following the system default' [[ "$(ipc call sound-services-test currentSinkFor empty)" == "" ]] \ || fail 'an application with no streams did not read as following the system default' # ── pactl refusing ─────────────────────────────────────────────────────────── printf 'failing\n' >"$PANAMA_SOUND_MOVE_MODE" : >"$PANAMA_SOUND_PACTL_LOG" ipc call sound-services-test moveApplication twoStream \ alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null for _ in $(seq 1 60); do state="$(ipc call sound-services-test routing)" [[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break sleep 0.1 done jq -e '.lastError != "" and .busy == false' >/dev/null <<<"$state" \ || fail "a refused move was neither reported nor finished: $state" # An error is a state, not a terminal one. printf 'good\n' >"$PANAMA_SOUND_MOVE_MODE" ipc call sound-services-test moveApplication twoStream \ alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null for _ in $(seq 1 60); do state="$(ipc call sound-services-test routing)" jq -e '.lastError == "" and .busy == false' >/dev/null <<<"$state" && break sleep 0.1 done jq -e '.lastError == "" and .busy == false' >/dev/null <<<"$state" \ || fail "the service never cleared the error from a move that then succeeded: $state" if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then fail 'sound services harness emitted a QML runtime warning' fi # ── Static ─────────────────────────────────────────────────────────────────── rg -Fq 'pragma Singleton' "$service" || fail 'SoundRouting is not a singleton' # There is more than one way to put an application back on the system default # and they do not behave the same. Whichever one is here, the next person has # to be able to find out why, without running it. python3 - "$service" <<'PY' || fail 'routeToDefault does not say which mechanism it uses' import re import sys lines = open(sys.argv[1], encoding="utf-8").read().splitlines() index = next((i for i, line in enumerate(lines) if re.search(r'\bfunction\s+routeToDefault\b', line)), None) if index is None: raise SystemExit("routeToDefault is missing") above = [line.strip() for line in lines[max(0, index - 12):index]] if not any(line.startswith("//") for line in above): raise SystemExit("routeToDefault has no comment explaining its mechanism") PY trap - EXIT cleanup printf 'sound routing contract: PASS\n'