#!/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 also owns 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. 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" quick_devices="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioDeviceList.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 } qs_for_harness() { XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@" } cleanup() { qs_for_harness kill >/dev/null 2>&1 || true rm -rf "$config_home" "$state_home" } 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 [[ -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 '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' # ── 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 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 fail 'Sound controls bypass the Quickshell PipeWire service' fi 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" 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")"