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
+373
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Device profiles are the last thing the Sound page handed to GNOME Settings,
|
||||
# and the reason it had to was that reading them means shelling out to pactl.
|
||||
# SoundCards does that, so the page can show the profile dropdown itself.
|
||||
#
|
||||
# What is worth pinning is the parsing, because pactl's JSON is not the shape
|
||||
# the UI wants and every mistake in the conversion is invisible until someone
|
||||
# with a headset and a surround card opens the page:
|
||||
#
|
||||
# * profiles arrive keyed by name in an object, and come out as an ordered
|
||||
# list, because a dropdown has an order and an object does not;
|
||||
# * a profile pactl marked unavailable is kept and flagged, not dropped --
|
||||
# "Headset" missing entirely is a bug report, "Headset (unavailable)" is an
|
||||
# explanation;
|
||||
# * the port hint says what is physically plugged in, which is the one thing
|
||||
# the profile name never tells you;
|
||||
# * pactl failing produces an error string, not an empty list that reads as
|
||||
# "this machine has no sound card".
|
||||
#
|
||||
# Runs against canned pactl output. The real audio graph is never touched:
|
||||
# pactl is replaced on PATH and through the service's own helper seam.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/SoundCards.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
|
||||
fixture="$(mktemp -d /tmp/panama-sound-cards.XXXXXX)"
|
||||
state_home="$fixture/state"
|
||||
shell_log="$fixture/quickshell.log"
|
||||
harness_pid=""
|
||||
|
||||
fail() {
|
||||
printf 'sound cards contract: %s\n' "$1" >&2
|
||||
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$service" ]] || fail 'SoundCards.qml is missing'
|
||||
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
|
||||
|
||||
mkdir -p "$fixture/bin" "$state_home"
|
||||
|
||||
# Two cards, chosen for the two things that go wrong. The built-in card has a
|
||||
# connected line out and an available analog profile. The headset has no port
|
||||
# connected at all and a profile pactl reports as unavailable, which is what a
|
||||
# Bluetooth device looks like between "paired" and "actually here".
|
||||
cat >"$fixture/cards.good.json" <<'JSON'
|
||||
[
|
||||
{
|
||||
"index": 47,
|
||||
"name": "alsa_card.pci-0000_00_1f.3",
|
||||
"driver": "module-alsa-card.c",
|
||||
"properties": {
|
||||
"device.description": "Built-in Audio",
|
||||
"device.api": "alsa"
|
||||
},
|
||||
"profiles": {
|
||||
"off": {
|
||||
"description": "Off",
|
||||
"sinks": 0,
|
||||
"sources": 0,
|
||||
"priority": 0,
|
||||
"available": true
|
||||
},
|
||||
"output:hdmi-stereo": {
|
||||
"description": "Digital Stereo (HDMI) Output",
|
||||
"sinks": 1,
|
||||
"sources": 0,
|
||||
"priority": 5900,
|
||||
"available": false
|
||||
},
|
||||
"output:analog-stereo+input:analog-stereo": {
|
||||
"description": "Analog Stereo Duplex",
|
||||
"sinks": 1,
|
||||
"sources": 1,
|
||||
"priority": 6565,
|
||||
"available": true
|
||||
}
|
||||
},
|
||||
"active_profile": "output:analog-stereo+input:analog-stereo",
|
||||
"ports": {
|
||||
"analog-output-lineout": {
|
||||
"description": "Line Out",
|
||||
"type": "Line",
|
||||
"priority": 9900,
|
||||
"availability_group": "Legacy 1",
|
||||
"availability": "available"
|
||||
},
|
||||
"analog-output-headphones": {
|
||||
"description": "Headphones",
|
||||
"type": "Headphones",
|
||||
"priority": 9000,
|
||||
"availability_group": "Legacy 2",
|
||||
"availability": "not available"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 51,
|
||||
"name": "bluez_card.74_15_F5_13_A4_28",
|
||||
"driver": "module-bluez5-device.c",
|
||||
"properties": {
|
||||
"device.description": "WH-1000XM4",
|
||||
"device.api": "bluez5"
|
||||
},
|
||||
"profiles": {
|
||||
"a2dp-sink": {
|
||||
"description": "High Fidelity Playback (A2DP Sink)",
|
||||
"sinks": 1,
|
||||
"sources": 0,
|
||||
"priority": 40,
|
||||
"available": true
|
||||
},
|
||||
"headset-head-unit": {
|
||||
"description": "Headset Head Unit (HSP/HFP)",
|
||||
"sinks": 1,
|
||||
"sources": 1,
|
||||
"priority": 30,
|
||||
"available": false
|
||||
}
|
||||
},
|
||||
"active_profile": "a2dp-sink",
|
||||
"ports": {
|
||||
"bluez-output": {
|
||||
"description": "Headphone",
|
||||
"type": "Headphones",
|
||||
"priority": 0,
|
||||
"availability_group": "",
|
||||
"availability": "not available"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
# The read side never runs pactl: SoundCards' own seam,
|
||||
# PANAMA_SOUND_CARDS_FIXTURE, points it at a file it `cat`s instead. So this
|
||||
# stub exists for the *write* side -- `pactl set-card-profile` -- and to prove
|
||||
# the read side did not quietly fall back to the live daemon.
|
||||
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
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$fixture/bin/pactl"
|
||||
|
||||
# SoundDefaults shares this harness and reads on construction. Give it a
|
||||
# fixture of its own so it cannot reach the session's real metadata.
|
||||
: >"$fixture/defaults"
|
||||
|
||||
export PANAMA_SOUND_PACTL_LOG="$fixture/pactl.log"
|
||||
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
|
||||
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
|
||||
# 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 how a
|
||||
# read that fails looks from the service's side.
|
||||
cards_fixture() {
|
||||
case "$1" in
|
||||
good) cp "$fixture/cards.good.json" "$PANAMA_SOUND_CARDS_FIXTURE" ;;
|
||||
malformed) printf 'Failure: Module initialization failed\n' >"$PANAMA_SOUND_CARDS_FIXTURE" ;;
|
||||
absent) rm -f "$PANAMA_SOUND_CARDS_FIXTURE" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
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_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
|
||||
}
|
||||
|
||||
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'
|
||||
ipc show 2>/dev/null | rg -q '^target sound-services-test$' \
|
||||
|| fail 'sound-services-test IPC target did not register'
|
||||
|
||||
# ── The good parse ───────────────────────────────────────────────────────────
|
||||
cards_fixture good
|
||||
ipc call sound-services-test refreshCards >/dev/null
|
||||
state=""
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test cards)"
|
||||
[[ "$(jq -r '.cards | length' <<<"$state")" == "2" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.cards | length == 2' >/dev/null <<<"$state" \
|
||||
|| fail "canned pactl output did not become two cards: $state"
|
||||
jq -e '.lastError == ""' >/dev/null <<<"$state" \
|
||||
|| fail "a successful listing carried an error: $state"
|
||||
|
||||
# The fixture is the whole read. A service that fell back to the live daemon
|
||||
# when handed one would pass every assertion below on the developer's machine
|
||||
# and none of them on anyone else's.
|
||||
if grep -Fq 'list' "$PANAMA_SOUND_PACTL_LOG"; then
|
||||
fail "the fixture was ignored and the live daemon was read: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
fi
|
||||
|
||||
# Which leaves the live command itself unexercised, so pin it where it is
|
||||
# written. `-f json` is the load-bearing half: without it pactl prints a
|
||||
# human-readable block that JSON.parse rejects, and every card disappears.
|
||||
rg -Fq '"pactl", "-f", "json", "list", "cards"' "$service" \
|
||||
|| fail 'the live card listing is not the JSON one'
|
||||
|
||||
jq -e '.cards[0].name == "alsa_card.pci-0000_00_1f.3"
|
||||
and .cards[0].description == "Built-in Audio"
|
||||
and .cards[0].activeProfile == "output:analog-stereo+input:analog-stereo"' \
|
||||
>/dev/null <<<"$state" || fail "the built-in card lost its identity: $state"
|
||||
|
||||
# Profiles become an ordered list. An object has no order, and a dropdown does.
|
||||
jq -e '.cards[0].profiles | type == "array"' >/dev/null <<<"$state" \
|
||||
|| fail "profiles are still keyed by name, so the dropdown has no order: $state"
|
||||
jq -e '(.cards[0].profiles | map(.name)) | index("output:analog-stereo+input:analog-stereo") != null
|
||||
and (.cards[0].profiles | map(.name) | index("output:hdmi-stereo")) != null' \
|
||||
>/dev/null <<<"$state" || fail "a profile pactl reported went missing: $state"
|
||||
jq -e '(.cards[0].profiles[] | select(.name == "output:analog-stereo+input:analog-stereo") | .description)
|
||||
== "Analog Stereo Duplex"' >/dev/null <<<"$state" \
|
||||
|| fail "profiles are labelled by their internal name rather than their description: $state"
|
||||
|
||||
# The order is pactl's priority, descending -- the order PulseAudio and GNOME
|
||||
# both present, and the one that puts "Off" at the bottom where nobody clicks
|
||||
# it by accident. The fixture's priorities (6565, 5900, 0) are deliberately not
|
||||
# the order the JSON lists them in, so a service that kept insertion order
|
||||
# fails here.
|
||||
jq -e '(.cards[0].profiles | map(.name))
|
||||
== ["output:analog-stereo+input:analog-stereo", "output:hdmi-stereo", "off"]' \
|
||||
>/dev/null <<<"$state" || fail "profiles are not ordered by priority: $state"
|
||||
|
||||
# Unavailable profiles are kept and flagged. Dropping them is how a card ends
|
||||
# up silently missing the mode someone is looking for.
|
||||
jq -e '(.cards[0].profiles[] | select(.name == "output:hdmi-stereo") | .available) == false
|
||||
and (.cards[0].profiles[] | select(.name == "output:analog-stereo+input:analog-stereo") | .available) == true' \
|
||||
>/dev/null <<<"$state" || fail "profile availability was not carried through: $state"
|
||||
|
||||
# The port hint says what is plugged in. The spec's own example is "Line out
|
||||
# connected"; this pins the two halves rather than the exact casing, so a
|
||||
# sentence tweak does not read as a regression -- but a hint that names the
|
||||
# wrong port, or none, does.
|
||||
hint="$(jq -r '.cards[0].portHint' <<<"$state")"
|
||||
[[ "$(tr '[:upper:]' '[:lower:]' <<<"$hint")" == *"line out"* ]] \
|
||||
|| fail "the built-in card's port hint does not name its connected port: $hint"
|
||||
[[ "$(tr '[:upper:]' '[:lower:]' <<<"$hint")" == *"connected"* ]] \
|
||||
|| fail "the port hint does not say the port is connected: $hint"
|
||||
|
||||
# Nothing plugged in says so, in the spec's words.
|
||||
[[ "$(jq -r '.cards[1].portHint' <<<"$state")" == "No port connected" ]] \
|
||||
|| fail "a card with no available port did not say so: $(jq -r '.cards[1].portHint' <<<"$state")"
|
||||
|
||||
jq -e '.cards[1].name == "bluez_card.74_15_F5_13_A4_28"
|
||||
and .cards[1].description == "WH-1000XM4"
|
||||
and .cards[1].activeProfile == "a2dp-sink"
|
||||
and ((.cards[1].profiles[] | select(.name == "headset-head-unit") | .available) == false)' \
|
||||
>/dev/null <<<"$state" || fail "the Bluetooth card did not survive the parse: $state"
|
||||
|
||||
# ── Switching a profile ──────────────────────────────────────────────────────
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
ipc call sound-services-test setProfile \
|
||||
alsa_card.pci-0000_00_1f.3 output:hdmi-stereo >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
grep -Fq 'set-card-profile' "$PANAMA_SOUND_PACTL_LOG" && break
|
||||
sleep 0.1
|
||||
done
|
||||
grep -Fq 'pactl <set-card-profile> <alsa_card.pci-0000_00_1f.3> <output:hdmi-stereo>' \
|
||||
"$PANAMA_SOUND_PACTL_LOG" \
|
||||
|| fail "profile switch did not reach pactl: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
|
||||
# A card or profile nobody named is not a reason to run pactl with an empty
|
||||
# argument and let it decide.
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
ipc call sound-services-test setProfile "" output:hdmi-stereo >/dev/null || true
|
||||
ipc call sound-services-test setProfile alsa_card.pci-0000_00_1f.3 "" >/dev/null || true
|
||||
sleep 0.3
|
||||
if grep -Fq 'set-card-profile' "$PANAMA_SOUND_PACTL_LOG"; then
|
||||
fail "an empty card or profile name still started a pactl write: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
fi
|
||||
|
||||
# ── pactl answering with something that is not JSON ──────────────────────────
|
||||
cards_fixture malformed
|
||||
ipc call sound-services-test refreshCards >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test cards)"
|
||||
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastError != "" and (.cards | type == "array")' >/dev/null <<<"$state" \
|
||||
|| fail "unparseable pactl output did not degrade into an error: $state"
|
||||
|
||||
# ── pactl not answering at all ───────────────────────────────────────────────
|
||||
cards_fixture absent
|
||||
ipc call sound-services-test refreshCards >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test cards)"
|
||||
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastError != "" and .busy == false' >/dev/null <<<"$state" \
|
||||
|| fail "a failing pactl left the service busy or silent: $state"
|
||||
|
||||
# ── Recovery ─────────────────────────────────────────────────────────────────
|
||||
# An error is a state, not a terminal one. The card comes back on the next
|
||||
# refresh, and the error goes away with it.
|
||||
cards_fixture good
|
||||
ipc call sound-services-test refreshCards >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test cards)"
|
||||
jq -e '.lastError == "" and (.cards | length) == 2' >/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastError == "" and (.cards | length) == 2' >/dev/null <<<"$state" \
|
||||
|| fail "the service never recovered from a failed listing: $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
|
||||
|
||||
# The Sound page is a PipeWire surface. SoundCards is allowed to shell out --
|
||||
# it is the sibling singleton that exists so the page does not have to -- but
|
||||
# it must be the one place that speaks pactl about cards.
|
||||
rg -Fq 'pragma Singleton' "$service" || fail 'SoundCards is not a singleton'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'sound cards contract: PASS\n'
|
||||
Reference in New Issue
Block a user