375 lines
15 KiB
Bash
Executable File
375 lines
15 KiB
Bash
Executable File
#!/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)) as $names
|
|
| ($names | index("output:analog-stereo+input:analog-stereo")) != null
|
|
and ($names | 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'
|