340 lines
19 KiB
Bash
Executable File
340 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/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)"
|
|
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'
|
|
rg -Fq 'label: "Speech server"' "$dictation_page" \
|
|
|| fail 'DictationPage does not report whether the speech server is installed'
|
|
rg -Fq 'label: "Speech model"' "$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")"
|