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:
@@ -77,6 +77,22 @@ mute_result="$(qs_for_harness ipc call application-volume-test mutateMute)"
|
||||
jq -e '.changed == true and .muted == [true, true]' <<<"$mute_result" >/dev/null \
|
||||
|| fail "mute mutation did not reach every stream: $mute_result"
|
||||
|
||||
# Over-amplification lives in Settings, and AudioStreams.js is pure JavaScript
|
||||
# with no Settings import -- so the ceiling arrives as an argument. What must
|
||||
# not happen is a default that quietly follows the preference: a caller that
|
||||
# forgets to pass the max gets 100%, not 150%.
|
||||
clamp_result="$(qs_for_harness ipc call application-volume-test clampVolume)"
|
||||
jq -e '.overAmpChanged == true
|
||||
and ((.overAmp[0] - 1.4) | fabs) < 0.000001
|
||||
and (.overAmp | length) == 2 and (.overAmp[0] == .overAmp[1])
|
||||
and .overAmpMuted == [false, false]
|
||||
and .ceiling == [1.5, 1.5]
|
||||
and .defaultMax == [1, 1]
|
||||
and .floor == [0, 0]
|
||||
and .nonNumericChanged == false
|
||||
and .nonNumeric == [0.1, 0.1]' \
|
||||
<<<"$clamp_result" >/dev/null || fail "volume clamp did not honour its max parameter: $clamp_result"
|
||||
|
||||
service_summary="$(qs_for_harness ipc call application-volume-test serviceSummary)"
|
||||
jq -e '.count >= 0 and .validTypes == true' <<<"$service_summary" >/dev/null \
|
||||
|| fail "live service exposed invalid playback groups: $service_summary"
|
||||
|
||||
@@ -94,6 +94,13 @@ elif [[ $1 == "status" ]]; then
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/pw-play" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'pw-play' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/qs" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'qs' >>"$OSD_TEST_LOG"
|
||||
@@ -104,10 +111,31 @@ SH
|
||||
|
||||
chmod +x "$scratch/bin/"*
|
||||
|
||||
# The blip and the over-amplification limit are both read out of Panama's own
|
||||
# settings.json, so every run below gets its own config root. Without this the
|
||||
# helper would read Gabriel's real preferences and the assertions would pass or
|
||||
# fail depending on which switches he happens to have on.
|
||||
blip_sound="$scratch/blip.oga"
|
||||
: >"$blip_sound"
|
||||
mkdir -p "$scratch/config-default"
|
||||
|
||||
# Write a settings.json for one run. No arguments means no file at all, which
|
||||
# is what a fresh install looks like: the helper must fall back to the schema
|
||||
# defaults rather than treating an absent file as an error.
|
||||
settings_root() {
|
||||
local name="$1" body="${2:-}" root="$scratch/config-$name"
|
||||
rm -rf "$root"
|
||||
mkdir -p "$root/panama"
|
||||
[[ -n "$body" ]] && printf '%s\n' "$body" >"$root/panama/settings.json"
|
||||
printf '%s' "$root"
|
||||
}
|
||||
|
||||
run_helper() {
|
||||
local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}"
|
||||
mkdir -p "$runtime"
|
||||
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
|
||||
XDG_CONFIG_HOME="${OSD_CONFIG_HOME:-$scratch/config-default}" \
|
||||
PANAMA_OSD_BLIP_SOUND="${OSD_BLIP_SOUND:-$blip_sound}" \
|
||||
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
|
||||
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
|
||||
PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \
|
||||
@@ -132,6 +160,31 @@ assert_line() {
|
||||
}
|
||||
}
|
||||
|
||||
# The blip is backgrounded and never waited on -- deliberately, so it cannot sit
|
||||
# between the key press and the OSD. Which means the line may land after the
|
||||
# helper has already exited, and asserting it needs a moment rather than an
|
||||
# instant. Waiting here also keeps a slow blip from bleeding into the next
|
||||
# case's freshly truncated log.
|
||||
await_line() {
|
||||
local expected="$1"
|
||||
for _ in $(seq 1 60); do
|
||||
grep -Fqx -- "$expected" "$log" && return 0
|
||||
sleep 0.05
|
||||
done
|
||||
printf 'osd helper contract: missing call\n%s\nactual:\n' "$expected" >&2
|
||||
cat "$log" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
refute_line() {
|
||||
local unexpected="$1" why="$2"
|
||||
if grep -Fq -- "$unexpected" "$log"; then
|
||||
printf 'osd helper contract: %s\nunexpected: %s\nactual:\n' "$why" "$unexpected" >&2
|
||||
cat "$log" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
: >"$log"
|
||||
run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
@@ -148,6 +201,86 @@ WPCTL_OUTPUT='Volume: 0.72 [MUTED]' run_helper microphone toggle
|
||||
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SOURCE@> <toggle>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Muted>'
|
||||
|
||||
# ── Over-amplification: the ceiling is a setting, not a constant ─────────────
|
||||
# `wpctl set-volume` clamps to 1.0 unless told otherwise, and it clamps the
|
||||
# *result* -- so the limit has to be on the down step too. Without it, coming
|
||||
# down from 130% would snap to 100% instead of stepping to 124%, which reads as
|
||||
# the slider jumping on its own.
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
|
||||
run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1.5> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
|
||||
run_helper volume down 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1.5> <@DEFAULT_AUDIO_SINK@> <6%->'
|
||||
|
||||
# The microphone is not part of the bargain: no amount of gain past 100% makes
|
||||
# a capture device better, and the Sound page's input slider stays 0-100 to
|
||||
# match.
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
|
||||
run_helper microphone up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SOURCE@> <6%+>'
|
||||
refute_line '<-l> <1.5> <@DEFAULT_AUDIO_SOURCE@>' \
|
||||
'over-amplification leaked onto the microphone'
|
||||
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root plain '{"overAmplification": false}')" \
|
||||
run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
|
||||
# A settings file that predates the key, and a settings file that is not JSON
|
||||
# at all, both mean "off" rather than "no volume keys work today".
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root nokey '{"barBackdrop": true}')" run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root broken '{not json')" run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
|
||||
|
||||
# ── The volume blip ─────────────────────────────────────────────────────────
|
||||
# Default on, per the schema, so a settings.json that has never been written
|
||||
# still clicks. Fire-and-forget: the blip must never gate the OSD.
|
||||
: >"$log"
|
||||
run_helper volume up 6
|
||||
await_line "pw-play <$blip_sound>"
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
|
||||
|
||||
: >"$log"
|
||||
run_helper volume down 6
|
||||
await_line "pw-play <$blip_sound>"
|
||||
|
||||
: >"$log"
|
||||
WPCTL_OUTPUT='Volume: 0.58' run_helper volume toggle
|
||||
await_line "pw-play <$blip_sound>"
|
||||
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root silent '{"volumeChangeBlip": false}')" \
|
||||
run_helper volume up 6
|
||||
refute_line 'pw-play' 'the blip played with volumeChangeBlip off'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
|
||||
|
||||
# Brightness and the microphone are not volume changes.
|
||||
: >"$log"
|
||||
run_helper brightness up 5
|
||||
refute_line 'pw-play' 'a brightness step played the volume blip'
|
||||
|
||||
: >"$log"
|
||||
WPCTL_OUTPUT='Volume: 0.72' run_helper microphone up 6
|
||||
refute_line 'pw-play' 'a microphone step played the volume blip'
|
||||
|
||||
# A distribution without the freedesktop sound theme has no file to play. That
|
||||
# is a silent desktop, not a broken volume key.
|
||||
: >"$log"
|
||||
OSD_BLIP_SOUND="$scratch/nothing-here.oga" run_helper volume up 6
|
||||
refute_line 'pw-play' 'the blip ran against a file that does not exist'
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
|
||||
|
||||
: >"$log"
|
||||
run_helper brightness up 5
|
||||
assert_line 'brightnessctl <-m> <-c> <backlight>'
|
||||
|
||||
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'
|
||||
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'
|
||||
@@ -4,26 +4,46 @@
|
||||
# another settings app. This contract keeps the real device plumbing shared
|
||||
# with Quick Settings and verifies the controls that must remain available.
|
||||
#
|
||||
# It also owns the line between Sound and Dictation. Dictation used to be a
|
||||
# It owns two lines that the page keeps wanting to cross.
|
||||
#
|
||||
# The first is the line between Sound and Dictation. Dictation used to be a
|
||||
# card on this page, because it listens through the input device chosen here.
|
||||
# It is an input method, so it now sits under Input with the keyboard -- and
|
||||
# the thing that made the old arrangement legible, that the microphone and the
|
||||
# dictation setup were visibly the same subject, has to survive the move as an
|
||||
# explicit handoff rather than as a second device picker.
|
||||
#
|
||||
# The second is the line between the page and the shell. Anything that speaks
|
||||
# pactl, wpctl or pw-metadata races the PipeWire objects AudioDevices already
|
||||
# holds, so it lives in a sibling singleton -- SoundTest, SoundFeedback, and
|
||||
# now SoundCards, SoundRouting and SoundDefaults. The device list, the rows,
|
||||
# the balance control and the service that owns discovery must stay native, and
|
||||
# no component on the page may grow a Process of its own.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml"
|
||||
dictation_page="$repo_dir/config/dot/quickshell/modules/settings/DictationPage.qml"
|
||||
device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml"
|
||||
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml"
|
||||
application_mixer="$repo_dir/config/dot/quickshell/modules/settings/ApplicationMixer.qml"
|
||||
application_row="$repo_dir/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml"
|
||||
balance="$repo_dir/config/dot/quickshell/modules/settings/AudioBalance.qml"
|
||||
audio_devices="$repo_dir/config/dot/quickshell/services/AudioDevices.qml"
|
||||
sound_feedback="$repo_dir/config/dot/quickshell/services/SoundFeedback.qml"
|
||||
settings="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
services="$repo_dir/config/dot/quickshell/services"
|
||||
sound_page="$settings/SoundPage.qml"
|
||||
dictation_page="$settings/DictationPage.qml"
|
||||
device_list="$settings/SoundDeviceList.qml"
|
||||
device_row="$settings/SoundDeviceRow.qml"
|
||||
application_mixer="$settings/ApplicationMixer.qml"
|
||||
application_row="$settings/ApplicationVolumeRow.qml"
|
||||
balance="$settings/AudioBalance.qml"
|
||||
badge="$settings/SoundBadge.qml"
|
||||
capture_row="$settings/SoundCaptureRow.qml"
|
||||
channel_strip="$settings/SoundChannelStrip.qml"
|
||||
theme_row="$settings/SoundThemeRow.qml"
|
||||
volume_row="$settings/SoundVolumeRow.qml"
|
||||
audio_devices="$services/AudioDevices.qml"
|
||||
sound_feedback="$services/SoundFeedback.qml"
|
||||
sound_cards="$services/SoundCards.qml"
|
||||
sound_routing="$services/SoundRouting.qml"
|
||||
sound_defaults="$services/SoundDefaults.qml"
|
||||
quick_devices="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml"
|
||||
quick_slider="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioSlider.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/sound-page-harness.qml"
|
||||
config_home="$(mktemp -d /tmp/panama-sound-config.XXXXXX)"
|
||||
state_home="$(mktemp -d /tmp/panama-sound-state.XXXXXX)"
|
||||
@@ -34,8 +54,25 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The harness reads the two shelling services through their fixture seams, so
|
||||
# the page's structure is measured against canned state rather than against
|
||||
# whatever hardware happens to be plugged in. The configured sink below is a
|
||||
# Bluetooth address nothing in this session can be, which is what makes the
|
||||
# ghost-row assertions deterministic.
|
||||
ghost_sink="bluez_output.AA_BB_CC_11_22_33.1"
|
||||
cards_fixture="$state_home/cards.json"
|
||||
defaults_fixture="$state_home/defaults"
|
||||
printf '[]\n' >"$cards_fixture"
|
||||
cat >"$defaults_fixture" <<JSON
|
||||
Found "default" metadata 30
|
||||
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$ghost_sink"}' type:'Spa:String:JSON'
|
||||
JSON
|
||||
|
||||
qs_for_harness() {
|
||||
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
|
||||
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
||||
PANAMA_SOUND_CARDS_FIXTURE="$cards_fixture" \
|
||||
PANAMA_SOUND_DEFAULTS_FIXTURE="$defaults_fixture" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
@@ -44,7 +81,14 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
for file in "$sound_page" "$dictation_page" "$device_list" "$device_row" "$application_mixer" "$application_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do
|
||||
core_files=("$audio_devices" "$device_list" "$device_row" "$balance")
|
||||
page_components=("$sound_page" "$device_list" "$device_row" "$application_mixer"
|
||||
"$application_row" "$balance" "$badge" "$capture_row" "$channel_strip"
|
||||
"$theme_row" "$volume_row")
|
||||
|
||||
for file in "${page_components[@]}" "$dictation_page" "$audio_devices" \
|
||||
"$sound_feedback" "$sound_cards" "$sound_routing" "$sound_defaults" \
|
||||
"$quick_devices" "$quick_slider"; do
|
||||
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
||||
done
|
||||
|
||||
@@ -73,12 +117,121 @@ rg -Fq 'root.node.audio.volumes = next;' "$balance" || fail 'balance does not wr
|
||||
[[ "$(rg -c 'SoundDeviceList \{' "$sound_page")" -eq 2 ]] || fail 'Sound page does not expose output and input device lists'
|
||||
rg -Fq 'AudioBalance {' "$sound_page" || fail 'Sound page has no output balance control'
|
||||
rg -Fq 'ApplicationMixer {' "$sound_page" || fail 'Sound page has no Applications mixer'
|
||||
rg -Fq 'label: "Device profiles"' "$sound_page" || fail 'advanced handoff is not limited to device profiles'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("sound")' "$sound_page" || fail 'advanced GNOME Sound handoff was removed'
|
||||
rg -Fq 'SoundFeedback.setEventSounds(checked)' "$sound_page" || fail 'event sounds are not controllable'
|
||||
rg -Fq 'SoundFeedback.setInputFeedback(checked)' "$sound_page" || fail 'input feedback sounds are not controllable'
|
||||
rg -Fq 'org.gnome.desktop.sound' "$sound_feedback" || fail 'sound feedback does not use the desktop sound schema'
|
||||
|
||||
# ── Device profiles are native now ───────────────────────────────────────────
|
||||
# This page used to end in a button that opened GNOME's Sound panel, because
|
||||
# reading a card's profiles means shelling out and the page may not. SoundCards
|
||||
# does the shelling, so the handoff is gone -- and it has to stay gone, because
|
||||
# an "Open panel" button next to a working dropdown is a second answer to the
|
||||
# same question.
|
||||
! rg -Fq 'openGnomePanel("sound")' "$sound_page" \
|
||||
|| fail 'the GNOME Sound handoff came back, next to the profile dropdown that replaced it'
|
||||
! rg -Fq 'label: "Device profiles"' "$sound_page" \
|
||||
|| fail 'device profiles are an ActionRow again rather than the native card'
|
||||
rg -Fq 'title: "Device profiles"' "$sound_page" || fail 'the Device profiles card is gone'
|
||||
rg -Fq 'SoundCards.cards' "$sound_page" || fail 'the profile card reads no cards'
|
||||
rg -Fq 'SoundCards.setProfile(' "$sound_page" || fail 'the profile dropdown writes nothing'
|
||||
rg -Fq 'SoundCards.refresh();' "$sound_page" || fail 'device profiles are never re-read on page open'
|
||||
rg -Fq 'SoundDefaults.refresh();' "$sound_page" || fail 'the configured defaults are never re-read on page open'
|
||||
rg -Fq 'SoundCards.lastError' "$sound_page" \
|
||||
|| fail 'a failed profile read degrades nothing, so the card would show an empty list instead of a reason'
|
||||
|
||||
# ── The microphone, and who is holding it ────────────────────────────────────
|
||||
rg -Fq 'PwNodeType.AudioInStream' "$audio_devices" \
|
||||
|| fail 'capture streams are not filtered by PipeWire type'
|
||||
rg -Fq 'captureApplications' "$audio_devices" || fail 'AudioDevices exposes no capture applications'
|
||||
rg -Fq 'AudioDevices.captureApplications' "$capture_row" \
|
||||
|| fail 'the microphone row does not read the grouped capture applications'
|
||||
rg -Fq 'AudioDevices.setApplicationMuted(' "$capture_row" \
|
||||
|| fail 'an application holding the microphone cannot be muted from the row'
|
||||
rg -Fq 'PwObjectTracker {' "$capture_row" \
|
||||
|| fail 'capture nodes are untracked, so their mute state reads false and the write is swallowed'
|
||||
|
||||
# Nothing listening means no row, not an empty one. "No applications are using
|
||||
# the microphone" is a sentence nobody needs on a page they opened to change an
|
||||
# output device.
|
||||
rg -Fq 'visible: root.users.length > 0' "$capture_row" \
|
||||
|| fail 'the microphone row renders itself when nothing is listening'
|
||||
|
||||
rg -Fq 'SoundTest.startMicTest(' "$sound_page" || fail 'there is no microphone test'
|
||||
rg -Fq 'SoundTest.micTestState' "$sound_page" \
|
||||
|| fail 'the microphone test button never says what it is doing'
|
||||
|
||||
# ── The ghost row ────────────────────────────────────────────────────────────
|
||||
# A configured device that is not present renders last, dimmed, and inert. The
|
||||
# two halves that make it honest: it is keyed off the *configured* name, and it
|
||||
# only appears when no present node carries that name.
|
||||
rg -Fq 'SoundDefaults.absent(root.output)' "$device_list" \
|
||||
|| fail 'the device list does not ask which configured device is missing, so it can draw no ghost row'
|
||||
rg -Fq 'ghost: true' "$device_list" || fail 'the list has no ghost row at all'
|
||||
rg -Fq 'Returns when connected' "$device_row" || fail 'the ghost row does not say why it is there'
|
||||
|
||||
# Non-interactive: selecting a device that is not here would ask PipeWire to
|
||||
# make a node that does not exist the default.
|
||||
python3 - "$device_row" <<'PY' || fail 'the ghost row is selectable, so a device that is not here can be chosen'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
for handler in ("TapHandler", "HoverHandler"):
|
||||
for match in re.finditer(handler + r" \{(?P<body>.*?)\n \}", source, re.S):
|
||||
if "!root.ghost" not in match.group("body"):
|
||||
raise SystemExit(1)
|
||||
if "AudioDevices.select(" not in source:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# The ghost row renders after the present devices, not among them.
|
||||
python3 - "$device_list" <<'PY' || fail 'the ghost row is not last in the list'
|
||||
import sys
|
||||
|
||||
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
|
||||
repeater = next(i for i, line in enumerate(lines) if line.strip().startswith("Repeater"))
|
||||
ghost = next(i for i, line in enumerate(lines) if "ghost: true" in line)
|
||||
if ghost < repeater:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# ── Badges ───────────────────────────────────────────────────────────────────
|
||||
# Native, off the node's own properties. A transport badge derived from the
|
||||
# device name would be a guess.
|
||||
rg -Fq '"device.api"' "$device_row" || fail 'device badges are not read from the node properties'
|
||||
rg -Fq '"raop"' "$device_row" || fail 'AirPlay devices carry no badge'
|
||||
rg -Fq '"bluez5"' "$device_row" || fail 'Bluetooth devices carry no badge'
|
||||
|
||||
# ── Over-amplification ───────────────────────────────────────────────────────
|
||||
# One ceiling, one preference, and the input is never part of it: a microphone
|
||||
# above 100% is gain on noise, not loudness.
|
||||
rg -Fq 'overAmplification' "$sound_page" || fail 'the Sound page has no over-amplification control'
|
||||
rg -Fq '1.5 : 1' "$sound_page" || fail 'the output slider maximum is not gated on the preference'
|
||||
rg -Fq 'root.output' "$quick_slider" \
|
||||
|| fail "Quick Settings' slider does not distinguish the sink from the microphone"
|
||||
rg -Fq '1.5 : 1' "$quick_slider" || fail "Quick Settings' output slider cannot over-amplify"
|
||||
python3 - "$quick_slider" <<'PY' || fail 'over-amplification is not restricted to the output in Quick Settings'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
match = re.search(r"property real maximum:(?P<body>.*?)\n\n", source, re.S)
|
||||
if not match or "root.output" not in match.group("body"):
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
rg -Fq 'root.maximum > 1' "$volume_row" \
|
||||
|| fail 'the region past 100% is not marked, so 150% looks like a full slider'
|
||||
|
||||
# ── Per-application routing ──────────────────────────────────────────────────
|
||||
rg -Fq 'SoundRouting.moveApplication(' "$application_row" \
|
||||
|| fail 'an application cannot be sent to another output'
|
||||
rg -Fq 'SoundRouting.routeToDefault(' "$application_row" \
|
||||
|| fail 'an application cannot be handed back to the system default'
|
||||
rg -Fq 'SoundRouting.currentSinkFor(' "$application_row" \
|
||||
|| fail 'the output picker does not say where the application is playing'
|
||||
rg -Fq 'label: "System default"' "$application_row" \
|
||||
|| fail 'following the system default is not an option anyone can pick'
|
||||
|
||||
# ── Dictation lives on its own page under Input ──────────────────────────────
|
||||
# One page owns the dictation controls. Two would mean two setup buttons
|
||||
# driving the same one-time install, and whichever one someone found second
|
||||
@@ -108,12 +261,28 @@ rg -Fq 'ShellState.openSettings("sound")' "$dictation_page" \
|
||||
! rg -Fq 'SoundDeviceList {' "$dictation_page" \
|
||||
|| fail 'DictationPage grew its own device picker -- there is one input device, and two places to change it disagree'
|
||||
|
||||
# Native bindings are the supported path. Shelling out would race the service
|
||||
# that owns these same objects and regress Quick Settings coherence.
|
||||
if rg -q '\b(Process|pactl|wpctl)\b' "$audio_devices" "$device_list" "$device_row" "$balance"; then
|
||||
# ── Native bindings are the supported path ───────────────────────────────────
|
||||
# Shelling out here would race the service that owns these same objects and
|
||||
# regress Quick Settings coherence.
|
||||
if rg -q '\b(Process|pactl|wpctl|pw-metadata)\b' "${core_files[@]}"; then
|
||||
fail 'Sound controls bypass the Quickshell PipeWire service'
|
||||
fi
|
||||
|
||||
# The ban extends to every component the rebuilt page is made of. Five new ones
|
||||
# landed with it, and the cheapest way for any of them to get something the
|
||||
# PipeWire bindings do not expose is a Process nobody noticed.
|
||||
for component in "${page_components[@]}"; do
|
||||
if rg -q '\bProcess\b' "$component"; then
|
||||
fail "${component#"$settings/"} shells out -- anything that does belongs in a sibling singleton beside SoundTest"
|
||||
fi
|
||||
done
|
||||
|
||||
# And the singletons that are allowed to shell out are the ones the spec names.
|
||||
for service in "$sound_cards" "$sound_routing" "$sound_defaults"; do
|
||||
rg -Fq 'pragma Singleton' "$service" \
|
||||
|| fail "${service#"$services/"} is not a singleton, so the page would hold its own copy of it"
|
||||
done
|
||||
|
||||
printf 'sound page static contract: PASS\n'
|
||||
|
||||
# Instantiate the complete page against the real, read-only PipeWire graph.
|
||||
@@ -140,6 +309,26 @@ jq -e '.ready == true and .outputs > 0 and .inputs > 0
|
||||
<<<"$status" >/dev/null \
|
||||
|| fail "real PipeWire graph was not represented: $status"
|
||||
|
||||
# Capture grouping is the playback grouping applied to the other direction, so
|
||||
# the same invariant holds: every node in a group is an input stream, and an
|
||||
# application recording on three streams is one entry.
|
||||
jq -e '.captureApplications >= 0 and .captureTypesValid == true' <<<"$status" >/dev/null \
|
||||
|| fail "capture applications were grouped from the wrong stream type: $status"
|
||||
jq -e '.captureRowVisible == (.captureApplications > 0)' <<<"$status" >/dev/null \
|
||||
|| fail "the microphone row does not follow whether anything is listening: $status"
|
||||
|
||||
# The ghost row, against a configured sink this session cannot possibly have.
|
||||
# The read is a subprocess, so it lands after the first status call.
|
||||
for _ in $(seq 1 60); do
|
||||
status="$(qs_for_harness ipc call sound-page-test status)"
|
||||
[[ "$(jq -r .ghostVisible <<<"$status")" == "true" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.ghostVisible == true' <<<"$status" >/dev/null \
|
||||
|| fail "a configured output that is not present did not produce a ghost row: $status"
|
||||
jq -e '.ghostLabel == "Bluetooth device (AA:BB:CC:11:22:33)"' <<<"$status" >/dev/null \
|
||||
|| fail "the ghost row is labelled with the raw node name: $status"
|
||||
|
||||
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign|PwObjectTracker' "$shell_log"; then
|
||||
fail 'Sound page emitted a QML runtime warning'
|
||||
fi
|
||||
|
||||
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