#!/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. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.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" "$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' # 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")"