#!/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 ' \ "$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'