#!/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" </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.*?)\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.*?)\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")"