Give Sound the whole story, and keep the buttons inside the card
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Executable
+301
@@ -0,0 +1,301 @@
|
||||
#!/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 <move-sink-input> <412> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
|
||||
"$PANAMA_SOUND_PACTL_LOG" \
|
||||
|| fail "the first stream was not moved by its object.serial: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
grep -Fq 'pactl <move-sink-input> <418> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
|
||||
"$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 <move-sink-input> <77> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
|
||||
"$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
|
||||
# "" <sink>` -- 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> <default> <-d> <61> <target.object>' "$PANAMA_SOUND_PACTL_LOG" \
|
||||
|| fail "the first stream's pin was not released: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
grep -Fq 'pw-metadata <-n> <default> <-d> <62> <target.object>' "$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> <default> <-d> <61> <target.node>' "$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'
|
||||
Reference in New Issue
Block a user