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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user