Files

344 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
# The Sound page is a first-class PipeWire control surface, not a launcher for
# another settings app. This contract keeps the real device plumbing shared
# with Quick Settings and verifies the controls that must remain available.
#
# 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)"
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/StatusBadge.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)"
shell_log="$state_home/quickshell.log"
fail() {
printf 'sound page contract: %s\n' "$1" >&2
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" \
PANAMA_SOUND_CARDS_FIXTURE="$cards_fixture" \
PANAMA_SOUND_DEFAULTS_FIXTURE="$defaults_fixture" \
qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home" "$state_home"
}
trap cleanup EXIT
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
# One shared source of truth owns device discovery and default selection.
rg -Fq 'pragma Singleton' "$audio_devices" || fail 'audio device service is not a singleton'
rg -Fq 'Singleton {' "$audio_devices" || fail 'audio device service has no singleton root'
rg -Fq 'PwNodeType.AudioSource' "$audio_devices" || fail 'audio sources are not filtered by PipeWire type'
rg -Fq 'Pipewire.preferredDefaultAudioSink = node;' "$audio_devices" || fail 'output selection does not reach PipeWire'
rg -Fq 'Pipewire.preferredDefaultAudioSource = node;' "$audio_devices" || fail 'input selection does not reach PipeWire'
rg -Fq 'AudioDevices.outputs' "$quick_devices" || fail 'Quick Settings does not share output discovery'
rg -Fq 'AudioDevices.inputs' "$quick_devices" || fail 'Quick Settings does not share input discovery'
rg -Fq 'AudioDevices.select(root.output, node)' "$quick_devices" || fail 'Quick Settings does not share device selection'
# Every hardware row binds its node before reading/writing audio state.
rg -Fq 'PwObjectTracker {' "$device_row" || fail 'device rows do not bind PipeWire objects'
rg -Fq 'root.node.audio.muted = !root.node.audio.muted;' "$device_row" || fail 'device mute is not writable'
rg -Fq 'root.node.audio.volume = value;' "$device_row" || fail 'per-device volume is not writable'
rg -Fq 'PwNodePeakMonitor {' "$device_row" || fail 'input rows have no level monitor'
rg -Fq 'enabled: !root.output && root.selected' "$device_row" || fail 'input monitoring is not scoped to the selected source'
# Stereo hardware gets a real channel balance control.
rg -Fq 'PwAudioChannel.FrontLeft' "$balance" || fail 'balance does not identify the left channel'
rg -Fq 'PwAudioChannel.FrontRight' "$balance" || fail 'balance does not identify the right channel'
rg -Fq 'root.node.audio.volumes = next;' "$balance" || fail 'balance does not write per-channel volume'
[[ "$(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 '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
# would report state it did not cause.
! rg -Fq 'Dictation.' "$sound_page" \
|| fail 'the Sound page reads Dictation state again -- dictation belongs to DictationPage, and two pages showing the same setup is how one of them goes stale'
rg -Fq 'label: "Set up dictation"' "$dictation_page" \
|| fail 'DictationPage has no setup action, so the one-time install cannot be started from Settings'
rg -Fq 'onTriggered: Dictation.setup()' "$dictation_page" \
|| fail 'the dictation setup action does not call the helper that installs both the speech server and the model'
# The Input-phase rebuild replaced the two literal TextRows with a status hero
# and setup steps, so the pin follows the state properties rather than the old
# row labels -- what matters is that the page still tells the truth about both
# halves of the install, not what furniture carries it.
rg -Fq 'Dictation.serverReady' "$dictation_page" \
|| fail 'DictationPage does not report whether the speech server is answering'
rg -Fq 'Dictation.modelInstalled' "$dictation_page" \
|| fail 'DictationPage does not report whether the speech model is installed'
rg -Fq 'Dictation.typingAvailable' "$dictation_page" \
|| fail 'DictationPage does not say when wtype is missing, so dictated text would silently go to the clipboard'
rg -Fq 'Dictation.lastError' "$dictation_page" \
|| fail 'DictationPage never surfaces a setup failure'
# The microphone is chosen on the Sound page, and dictation listens through it.
# Saying so, with a way to get there, is what replaces the two cards having sat
# side by side.
rg -Fq 'title: "Microphone"' "$dictation_page" \
|| fail 'DictationPage does not name the input device it listens through'
rg -Fq 'ShellState.openSettings("sound")' "$dictation_page" \
|| fail 'DictationPage does not hand off to Sound, so the device it listens through is unreachable from it'
! 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 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.
# Merely constructing these controls must never change a default or volume.
qs_for_harness --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 60); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' \
|| fail 'Sound page harness did not start'
for _ in $(seq 1 60); do
status="$(qs_for_harness ipc call sound-page-test status)"
[[ "$(jq -r .ready <<<"$status")" == "true" ]] && break
sleep 0.1
done
jq -e '.ready == true and .outputs > 0 and .inputs > 0
and .applications >= 0
and (.defaultOutput | length > 0) and (.defaultInput | length > 0)
and .populatedRows == 1 and .populatedStatus == ""
and .emptyRows == 0 and .emptyStatus == "Applications playing sound will appear here"
and .unavailableRows == 0 and .unavailableStatus == "PipeWire is unavailable"' \
<<<"$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
trap - EXIT
cleanup
printf 'sound page runtime: PASS (%s outputs, %s inputs)\n' \
"$(jq -r .outputs <<<"$status")" "$(jq -r .inputs <<<"$status")"