diff --git a/config/dot/quickshell/audio-streams-harness.qml b/config/dot/quickshell/audio-streams-harness.qml new file mode 100644 index 0000000..21e0827 --- /dev/null +++ b/config/dot/quickshell/audio-streams-harness.qml @@ -0,0 +1,116 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import "services/AudioStreams.js" as AudioStreams + +ShellRoot { + readonly property int audioOutStreamFlag: 4 + + property var fixtureNodes: [ + { + id: 10, + ready: true, + type: audioOutStreamFlag, + properties: { + "application.id": "org.chromium.Chromium", + "application.name": "Chromium", + "application.icon_name": "chromium" + }, + description: "Chromium audio", + audio: { volume: 0.4, muted: false } + }, + { + id: 11, + ready: true, + type: audioOutStreamFlag, + properties: { + "application.id": "org.chromium.Chromium", + "application.name": "Chromium", + "application.icon_name": "chromium" + }, + description: "Chromium audio", + audio: { volume: 0.8, muted: true } + }, + { + id: 20, + ready: true, + type: audioOutStreamFlag, + properties: { + "application.process.binary": "spotify", + "application.name": "Spotify" + }, + description: "Spotify", + audio: { volume: 0.25, muted: false } + }, + { + id: 30, + ready: true, + type: 2, + properties: { "application.name": "Microphone capture" }, + description: "Input stream", + audio: { volume: 0.5, muted: false } + }, + { + id: 40, + ready: false, + type: audioOutStreamFlag, + properties: { "application.name": "Not ready" }, + description: "Unready stream", + audio: { volume: 0.5, muted: false } + }, + { + id: 99, + ready: true, + type: audioOutStreamFlag, + properties: {}, + description: "", + audio: { volume: 1, muted: false } + } + ] + + function groups(): var { + return AudioStreams.group(fixtureNodes, audioOutStreamFlag); + } + + IpcHandler { + target: "application-volume-test" + + function summary(): string { + const applications = groups(); + const chromium = applications.find(application => + application.key === "org.chromium.Chromium"); + return JSON.stringify({ + groups: applications.map(application => ({ + key: application.key, + label: application.label, + icon: application.icon, + count: application.nodes.length + })), + chromiumVolume: AudioStreams.volume(chromium), + chromiumMuted: AudioStreams.muted(chromium) + }); + } + + function mutateVolume(): string { + const chromium = groups().find(application => + application.key === "org.chromium.Chromium"); + const changed = AudioStreams.setVolume(chromium, 0.7); + return JSON.stringify({ + changed, + volumes: chromium.nodes.map(node => node.audio.volume), + muted: chromium.nodes.map(node => node.audio.muted) + }); + } + + function mutateMute(): string { + const chromium = groups().find(application => + application.key === "org.chromium.Chromium"); + const changed = AudioStreams.setMuted(chromium, true); + return JSON.stringify({ + changed, + muted: chromium.nodes.map(node => node.audio.muted) + }); + } + } +} diff --git a/config/dot/quickshell/services/AudioStreams.js b/config/dot/quickshell/services/AudioStreams.js new file mode 100644 index 0000000..9a33753 --- /dev/null +++ b/config/dot/quickshell/services/AudioStreams.js @@ -0,0 +1,72 @@ +function property(node, key) { + const value = node && node.properties ? node.properties[key] : ""; + return typeof value === "string" ? value.trim() : ""; +} + +function groupKey(node) { + return property(node, "application.id") + || property(node, "application.process.binary") + || property(node, "application.name") + || `node:${node.id}`; +} + +function label(node) { + return property(node, "application.name") + || String(node.description || "").trim() + || property(node, "media.name") + || "Unknown application"; +} + +function icon(node) { + return property(node, "application.icon_name") + || "audio-x-generic-symbolic"; +} + +function group(nodes, audioOutStreamFlag) { + const groups = []; + const byKey = {}; + for (const node of nodes || []) { + if (!node || node.ready !== true || !node.audio + || (node.type & audioOutStreamFlag) !== audioOutStreamFlag) + continue; + const key = groupKey(node); + if (!byKey[key]) { + byKey[key] = { key, label: label(node), icon: icon(node), nodes: [] }; + groups.push(byKey[key]); + } + byKey[key].nodes.push(node); + } + return groups; +} + +function audioNodes(application) { + return (application && application.nodes || []).filter(node => node && node.audio); +} + +function volume(application) { + const nodes = audioNodes(application); + return nodes.length === 0 ? 0 + : nodes.reduce((sum, node) => sum + node.audio.volume, 0) / nodes.length; +} + +function muted(application) { + const nodes = audioNodes(application); + return nodes.length > 0 && nodes.every(node => node.audio.muted === true); +} + +function setVolume(application, value) { + const next = Math.max(0, Math.min(1, Number(value))); + if (!Number.isFinite(next)) return false; + const nodes = audioNodes(application); + for (const node of nodes) { + node.audio.muted = false; + node.audio.volume = next; + } + return nodes.length > 0; +} + +function setMuted(application, mutedValue) { + const nodes = audioNodes(application); + for (const node of nodes) node.audio.muted = mutedValue === true; + return nodes.length > 0; +} diff --git a/tests/quickshell/application-volume-contract.sh b/tests/quickshell/application-volume-contract.sh new file mode 100755 index 0000000..0cd4eb6 --- /dev/null +++ b/tests/quickshell/application-volume-contract.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/audio-streams-harness.qml" +state_home="$(mktemp -d /tmp/panama-application-volume-state.XXXXXX)" +shell_log="$state_home/quickshell.log" +harness_pid="" + +fail() { + printf 'application volume contract: %s\n' "$1" >&2 + [[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2 + exit 1 +} + +instances_for_harness() { + qs list --all 2>/dev/null | awk -v expected="$harness" ' + /^Instance / { pid = "" } + /^[[:space:]]*Process ID:/ { pid = $3 } + /^[[:space:]]*Config path:/ { + path = $0 + sub(/^[[:space:]]*Config path: /, "", path) + if (path == expected && pid ~ /^[0-9]+$/) print pid + } + ' +} + +cleanup() { + if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then + kill "$harness_pid" 2>/dev/null || true + for _ in $(seq 1 40); do + kill -0 "$harness_pid" 2>/dev/null || break + sleep 0.05 + done + fi + rm -rf "$state_home" +} +trap cleanup EXIT + +qs_for_harness() { + if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then + XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" "${@:2}" + else + XDG_STATE_HOME="$state_home" qs -p "$harness" "$@" + fi +} + +qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated harness did not launch' +for _ in $(seq 1 50); do + harness_pid="$(instances_for_harness | head -1)" + if [[ "$harness_pid" =~ ^[0-9]+$ ]] \ + && qs_for_harness ipc show 2>/dev/null | rg -q '^target application-volume-test$'; then + break + fi + sleep 0.1 +done +[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated harness process did not start' +qs_for_harness ipc show 2>/dev/null | rg -q '^target application-volume-test$' \ + || fail 'application-volume-test IPC target did not register' + +summary="$(qs_for_harness ipc call application-volume-test summary)" +expected_groups='[ + {"key":"org.chromium.Chromium","label":"Chromium","icon":"chromium","count":2}, + {"key":"spotify","label":"Spotify","icon":"audio-x-generic-symbolic","count":1}, + {"key":"node:99","label":"Unknown application","icon":"audio-x-generic-symbolic","count":1} +]' +jq -e --argjson expected "$expected_groups" \ + '.groups == $expected and (.chromiumVolume - 0.6 | fabs) < 0.000001 and .chromiumMuted == false' \ + <<<"$summary" >/dev/null || fail "unexpected grouping summary: $summary" + +volume_result="$(qs_for_harness ipc call application-volume-test mutateVolume)" +jq -e '.changed == true and .volumes == [0.7, 0.7] and .muted == [false, false]' \ + <<<"$volume_result" >/dev/null || fail "volume mutation did not reach every stream: $volume_result" + +mute_result="$(qs_for_harness ipc call application-volume-test mutateMute)" +jq -e '.changed == true and .muted == [true, true]' <<<"$mute_result" >/dev/null \ + || fail "mute mutation did not reach every stream: $mute_result" + +if rg -n 'ReferenceError|TypeError|Binding loop|Cannot assign|PwObjectTracker' "$shell_log"; then + fail 'isolated harness emitted a QML runtime error' +fi + +printf 'application volume contract: PASS\n'