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
+279
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The device list used to show only what is present, which meant that unplugging
|
||||
# a headset made the row that owned the sound vanish and left no trace of why
|
||||
# the laptop speakers had taken over. PipeWire remembers the choice; the page
|
||||
# did not show it.
|
||||
#
|
||||
# SoundDefaults is what makes the ghost row possible. Its whole job is the
|
||||
# difference between two keys that look the same in a log:
|
||||
#
|
||||
# default.audio.sink what is playing right now
|
||||
# default.configured.audio.sink what was chosen, present or not
|
||||
#
|
||||
# Reading the first one is the bug, and it is a quiet one: it always names a
|
||||
# device that exists, so the ghost row never appears and the feature does
|
||||
# nothing on every machine where the configured device happens to be plugged
|
||||
# in -- which is most machines, most of the time. This pins the second.
|
||||
#
|
||||
# It also pins the label, because an absent device has no description to borrow
|
||||
# and the stored name is all there is to work with.
|
||||
#
|
||||
# Runs against canned metadata through the service's own fixture seam. The
|
||||
# session's real defaults are never read and never written.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/SoundDefaults.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
|
||||
fixture="$(mktemp -d /tmp/panama-sound-defaults.XXXXXX)"
|
||||
state_home="$fixture/state"
|
||||
shell_log="$fixture/quickshell.log"
|
||||
harness_pid=""
|
||||
|
||||
# A Bluetooth address no adapter in this house has ever seen, so the "not
|
||||
# present" assertions cannot be flipped by what happens to be paired during the
|
||||
# sweep. The effective sink is a plausible built-in card, and the point of the
|
||||
# fixture is that the two disagree.
|
||||
configured_sink="bluez_output.AA_BB_CC_11_22_33.1"
|
||||
effective_sink="alsa_output.pci-0000_00_1f.3.analog-stereo"
|
||||
configured_source="alsa_input.usb-Blue_Microphones_Yeti-00.analog-stereo"
|
||||
|
||||
fail() {
|
||||
printf 'sound defaults contract: %s\n' "$1" >&2
|
||||
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$service" ]] || fail 'SoundDefaults.qml is missing'
|
||||
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
|
||||
|
||||
mkdir -p "$fixture/bin" "$state_home"
|
||||
|
||||
# The headset is the configured sink and is not here; the built-in card is what
|
||||
# is actually playing. A service that read the effective key would report the
|
||||
# built-in card and be wrong in the one case this exists for. The configured
|
||||
# line is written *first* so that "last key wins" is not what makes it pass.
|
||||
cat >"$fixture/metadata-absent" <<JSON
|
||||
Found "default" metadata 30
|
||||
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$configured_sink"}' type:'Spa:String:JSON'
|
||||
update: id:0 key:'default.audio.sink' value:'{"name":"$effective_sink"}' type:'Spa:String:JSON'
|
||||
update: id:0 key:'default.configured.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
|
||||
update: id:0 key:'default.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
|
||||
JSON
|
||||
|
||||
# Nothing was ever configured: PipeWire is picking by priority. There is no
|
||||
# ghost to draw, and an empty string is how that has to read -- not the name of
|
||||
# whatever happens to be playing, which would render a ghost row for a device
|
||||
# that is present, sitting underneath its own real row.
|
||||
cat >"$fixture/metadata-unconfigured" <<JSON
|
||||
Found "default" metadata 30
|
||||
update: id:0 key:'default.audio.sink' value:'{"name":"$effective_sink"}' type:'Spa:String:JSON'
|
||||
update: id:0 key:'default.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
|
||||
JSON
|
||||
|
||||
cat >"$fixture/metadata-empty" <<'JSON'
|
||||
Found "default" metadata 30
|
||||
JSON
|
||||
|
||||
# pw-metadata prints keys it does not own alongside the ones it does, and some
|
||||
# of them are bare strings rather than JSON. One of those must not take the
|
||||
# whole read down with it.
|
||||
cat >"$fixture/metadata-noise" <<JSON
|
||||
Found "default" metadata 30
|
||||
update: id:0 key:'log.level' value:'2' type:'Spa:String:JSON'
|
||||
update: id:0 key:'clock.force-quantum' value:'not json at all' type:'Spa:String:JSON'
|
||||
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$configured_sink"}' type:'Spa:String:JSON'
|
||||
JSON
|
||||
|
||||
# SoundCards shares this harness. An inert fixture keeps it off the live daemon.
|
||||
printf '[]\n' >"$fixture/cards.json"
|
||||
|
||||
# Nothing here should reach pipewire, but if the fixture seam ever regressed,
|
||||
# these stubs are what stands between the contract and the session's real
|
||||
# defaults. They answer nothing, which fails the assertions loudly rather than
|
||||
# passing them against real hardware.
|
||||
for tool in pw-metadata pw-dump pactl wpctl; do
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'exit 1' >"$fixture/bin/$tool"
|
||||
chmod +x "$fixture/bin/$tool"
|
||||
done
|
||||
|
||||
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
|
||||
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
|
||||
|
||||
# The seam is a path read once at construction, so a case changes what the file
|
||||
# says rather than where it points. `absent` deletes it, which is what a read
|
||||
# that fails looks like from the service's side.
|
||||
defaults_fixture() {
|
||||
if [[ "$1" == "failing" ]]; then
|
||||
rm -f "$PANAMA_SOUND_DEFAULTS_FIXTURE"
|
||||
else
|
||||
cp "$fixture/metadata-$1" "$PANAMA_SOUND_DEFAULTS_FIXTURE"
|
||||
fi
|
||||
}
|
||||
defaults_fixture absent
|
||||
|
||||
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_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \
|
||||
PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
ipc() {
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
|
||||
run ipc --pid "$harness_pid" "$@"
|
||||
else
|
||||
run ipc "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Refresh, then wait for the parsed value to settle into the shape the filter
|
||||
# describes. Everything passed here goes to jq, so a case can bring its own
|
||||
# `--arg`.
|
||||
await_defaults() {
|
||||
local state=""
|
||||
ipc call sound-services-test refreshDefaults >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test defaults)"
|
||||
jq -e "$@" >/dev/null <<<"$state" && { printf '%s' "$state"; return 0; }
|
||||
sleep 0.1
|
||||
done
|
||||
printf '%s' "$state"
|
||||
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'
|
||||
|
||||
# ── Configured, and not here ─────────────────────────────────────────────────
|
||||
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|
||||
|| fail "the configured sink was not read from the configured key: $state"
|
||||
[[ "$(jq -r .sink <<<"$state")" != "$effective_sink" ]] \
|
||||
|| fail 'the effective sink was reported as the configured one, so no ghost row can ever appear'
|
||||
[[ "$(jq -r .source <<<"$state")" == "$configured_source" ]] \
|
||||
|| fail "the configured source was not read: $state"
|
||||
|
||||
# ── The ghost row ────────────────────────────────────────────────────────────
|
||||
# Configured, absent from the graph, and labelled from the stored name -- which
|
||||
# is all there is, because the node that carried the description is gone.
|
||||
ghost="$(ipc call sound-services-test absent output)"
|
||||
jq -e --arg sink "$configured_sink" '.present == false and .name == $sink' \
|
||||
>/dev/null <<<"$ghost" || fail "a configured device that is not present did not become a ghost row: $ghost"
|
||||
[[ "$(jq -r .label <<<"$ghost")" == "Bluetooth device (AA:BB:CC:11:22:33)" ]] \
|
||||
|| fail "the ghost row's label is the raw node name: $(jq -r .label <<<"$ghost")"
|
||||
|
||||
# The label is the only part of a ghost row anyone reads, and the names it has
|
||||
# to work with are structured differently per transport.
|
||||
[[ "$(ipc call sound-services-test labelFor raop_sink.Living-Room.local.192.168.1.162.7000)" \
|
||||
== "Living Room" ]] \
|
||||
|| fail 'an AirPlay speaker is not named by its mDNS hostname'
|
||||
[[ "$(ipc call sound-services-test labelFor alsa_output.usb-Generic_USB_Audio-00.analog-stereo)" \
|
||||
== "Generic USB Audio" ]] \
|
||||
|| fail 'a USB device is not named by its product string'
|
||||
[[ "$(ipc call sound-services-test labelFor "")" == "" ]] \
|
||||
|| fail 'an empty name produced a label out of nothing'
|
||||
|
||||
# ── Keys that are not ours ───────────────────────────────────────────────────
|
||||
# pw-metadata prints the whole store, and some of it is not JSON. One bad line
|
||||
# must not take the read down with it.
|
||||
defaults_fixture noise
|
||||
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|
||||
|| fail "a non-JSON value on an unrelated key discarded the whole read: $state"
|
||||
|
||||
# ── Nothing configured ───────────────────────────────────────────────────────
|
||||
defaults_fixture unconfigured
|
||||
state="$(await_defaults '.sink == ""')" \
|
||||
|| fail "an unconfigured default did not read as empty: $state"
|
||||
[[ "$(jq -r .source <<<"$state")" == "" ]] \
|
||||
|| fail "an unconfigured source did not read as empty: $state"
|
||||
jq -e '.present == true' >/dev/null <<<"$(ipc call sound-services-test absent output)" \
|
||||
|| fail 'a session with no configured default still drew a ghost row'
|
||||
|
||||
# ── An empty metadata store ──────────────────────────────────────────────────
|
||||
defaults_fixture empty
|
||||
state="$(await_defaults '.sink == "" and .source == ""')" \
|
||||
|| fail "an empty metadata store did not read as empty: $state"
|
||||
|
||||
# ── The read failing ─────────────────────────────────────────────────────────
|
||||
# A session where this cannot be read is a session with no ghost rows, which is
|
||||
# the pre-redesign behaviour and perfectly usable. It is not a session where the
|
||||
# Sound page reports a device nobody configured.
|
||||
defaults_fixture failing
|
||||
state="$(await_defaults '.sink == "" and .source == ""')" \
|
||||
|| fail "a failed read left a stale or invented configured device: $state"
|
||||
|
||||
# ── Recovery ─────────────────────────────────────────────────────────────────
|
||||
defaults_fixture absent
|
||||
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|
||||
|| fail "the service never recovered after a failed read: $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 'SoundDefaults is not a singleton'
|
||||
|
||||
# The two greps that say which keys are being read. A regression here is
|
||||
# invisible at runtime on any machine whose configured device is plugged in.
|
||||
rg -Fq 'default.configured.audio.sink' "$service" \
|
||||
|| fail 'SoundDefaults does not name the configured sink key'
|
||||
rg -Fq 'default.configured.audio.source' "$service" \
|
||||
|| fail 'SoundDefaults does not name the configured source key'
|
||||
|
||||
# The live command, which the fixture seam means nothing above exercises.
|
||||
rg -Fq '"pw-metadata", "-n", "default", "0"' "$service" \
|
||||
|| fail 'the live read is not pw-metadata against the default metadata store'
|
||||
|
||||
# `Pipewire.preferredDefaultAudioSink` is the same configured value typed as a
|
||||
# node pointer, so it reads null in exactly the case this service exists for.
|
||||
# Binding the ghost row to it would make the ghost row impossible. The file's
|
||||
# own comment is allowed to say so; the code is not allowed to do it.
|
||||
python3 - "$service" <<'PY' || fail 'the configured device was read as a node pointer, which is null precisely when the device is absent'
|
||||
import sys
|
||||
|
||||
for line in open(sys.argv[1], encoding="utf-8"):
|
||||
if line.strip().startswith("//"):
|
||||
continue
|
||||
if "preferredDefaultAudio" in line:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'sound defaults contract: PASS\n'
|
||||
Reference in New Issue
Block a user