From 2e8292599a3b602f0f977318ae88de0df3c8e10b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:46:03 -0400 Subject: [PATCH] Add power profiles to Power & Lock The same Power Saver / Balanced / Performance choice GNOME's Power panel offers, and the daemon behind it was already running here -- it simply had no control anywhere in Panama. This machine has been sitting on "performance" with nothing to say so. Talks to the net.hadess.PowerProfiles interface rather than to a binary. Fedora 44 implements it with tuned-ppd instead of power-profiles-daemon, and powerprofilesctl is not installed at all, so anything shelling out to that command would have found nothing while the service was right there. Setting a profile needs no privileges: the daemon accepts a property write from the active session user. Not a stored preference. The daemon owns the profile, it survives Panama restarts, and anything else on the system can change it, so a copy in settings.json would drift -- the same reasoning as monitor brightness. PerformanceDegraded is surfaced because it is what makes the setting a lie: a thermally throttled machine reports "performance" while behaving otherwise, and that is worth saying out loud. The contract stubs busctl, because the real daemon is a system service shared with everything else on the machine and a test that flipped the daily driver to power-saver and then died would leave it there. It pins the parsing in particular: busctl renders the Profiles property flat, so profile names and driver names arrive in one stream, and a pattern loose enough to match both reports the driver as an extra profile. On this machine the driver is called "tuned", which reads exactly like a plausible fourth profile -- verified the contract catches that, and catches an unvalidated name reaching the system service. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../quickshell/modules/settings/PowerPage.qml | 34 ++++++ .../quickshell/scripts/panama-power-profile | 85 ++++++++++++++ .../dot/quickshell/services/PowerProfiles.qml | 107 ++++++++++++++++++ tests/quickshell/power-profile-contract.sh | 103 +++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100755 config/dot/quickshell/scripts/panama-power-profile create mode 100644 config/dot/quickshell/services/PowerProfiles.qml create mode 100755 tests/quickshell/power-profile-contract.sh diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml index 78a358d..2591709 100644 --- a/config/dot/quickshell/modules/settings/PowerPage.qml +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -14,9 +14,43 @@ import qs.services SettingsPage { id: root + // Probing the power daemon is a D-Bus round trip, so it happens when the + // page opens rather than at shell startup. + Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh() + title: "Power & Lock" lede: "When the screen turns off, when the session locks, and whether it ever sleeps." + // The same profiles GNOME's Power panel offers. Not a stored preference -- + // the daemon owns it, it survives Panama restarts, and anything else on the + // system can change it, so a copy here would drift. + SettingsCard { + visible: PowerProfiles.available || PowerProfiles.lastError !== "" + title: "Power profile" + subtitle: PowerProfiles.degraded !== "" + ? "Performance is limited right now: " + PowerProfiles.degraded + : (PowerProfiles.available + ? "Applies to the whole system and persists across sessions." + : PowerProfiles.lastError) + + Repeater { + model: PowerProfiles.profiles + + SettingRow { + id: profileRow + required property var modelData + required property int index + + label: PowerProfiles.label(profileRow.modelData) + detail: PowerProfiles.detail(profileRow.modelData) + value: profileRow.modelData === PowerProfiles.active ? "Active" : "" + divider: profileRow.index < PowerProfiles.profiles.length - 1 + activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy + onActivated: PowerProfiles.set(profileRow.modelData) + } + } + } + SettingsCard { title: "Idle behaviour" subtitle: IdleLock.managed diff --git a/config/dot/quickshell/scripts/panama-power-profile b/config/dot/quickshell/scripts/panama-power-profile new file mode 100755 index 0000000..529ee23 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-power-profile @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +# System power profile, via the PowerProfiles D-Bus API. +# +# GNOME's Power panel offers Balanced / Performance / Power Saver; this is the +# same daemon behind it. On Fedora 44 the implementation is tuned-ppd rather +# than power-profiles-daemon, but it serves the same net.hadess.PowerProfiles +# interface, which is why this talks to the interface rather than to either +# binary -- powerprofilesctl is not even installed here. +# +# Setting a profile needs no privileges: the daemon accepts a property write +# from the active session user. +# +# Usage: +# panama-power-profile list -> {"profiles":[...],"active":"...","degraded":"..."} +# panama-power-profile set +# +# PerformanceDegraded is reported because it is the one thing that makes the +# choice a lie: a thermally throttled laptop reports "performance" while +# behaving otherwise, and GNOME surfaces exactly this. It is an empty string +# when nothing is wrong. + +set -uo pipefail + +readonly BUS_NAME=net.hadess.PowerProfiles +readonly OBJECT=/net/hadess/PowerProfiles + +emit_error() { + printf '{"profiles":[],"active":"","degraded":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')" + exit 0 +} + +command -v busctl >/dev/null 2>&1 || emit_error 'busctl is not available' + +property() { + busctl get-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" "$1" 2>/dev/null +} + +cmd_list() { + # A machine with no power-profiles daemon is a normal state -- plenty of + # desktops have none -- so it is reported rather than treated as a failure. + busctl status "$BUS_NAME" >/dev/null 2>&1 \ + || emit_error 'No power profile service is running. GNOME uses power-profiles-daemon; Fedora ships tuned-ppd.' + + local active degraded profiles + active="$(property ActiveProfile | sed 's/^s //; s/"//g')" + degraded="$(property PerformanceDegraded | sed 's/^s //; s/"//g')" + + # Profiles is an array of dicts, which busctl renders flat: + # v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ... + # so each profile is the string following its own "Profile" marker. Matching + # the marker matters: "Driver" values sit in the same stream, and on this + # machine the driver is called "tuned", which a looser pattern happily + # reports as a fourth profile that does not exist. + profiles="$(property Profiles \ + | grep -oE '"Profile" s "[a-z-]+"' \ + | sed 's/.*s "//; s/"$//' \ + | awk '!seen[$0]++')" + + [[ -n "$profiles" ]] || emit_error 'The power profile service reported no profiles.' + + jq -cn \ + --arg active "$active" \ + --arg degraded "$degraded" \ + --argjson profiles "$(printf '%s\n' "$profiles" | jq -Rn '[inputs | select(length > 0)]')" \ + '{profiles: $profiles, active: $active, degraded: $degraded, error: ""}' +} + +cmd_set() { + local profile="${1:-}" + # Constrained rather than passed through: this reaches a system service. + [[ "$profile" =~ ^[a-z-]+$ ]] || { + printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2 + return 2 + } + busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \ + | head -2 >&2 + return 0 +} + +case "${1:-list}" in + list) cmd_list ;; + set) shift; cmd_set "${1:-}" ;; + *) printf 'usage: panama-power-profile [list|set ]\n' >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/services/PowerProfiles.qml b/config/dot/quickshell/services/PowerProfiles.qml new file mode 100644 index 0000000..670a06b --- /dev/null +++ b/config/dot/quickshell/services/PowerProfiles.qml @@ -0,0 +1,107 @@ +pragma Singleton + +// The system power profile: power-saver, balanced, or performance. +// +// The same daemon GNOME's Power panel drives. On Fedora 44 the implementation +// is tuned-ppd rather than power-profiles-daemon, but it serves the same +// net.hadess.PowerProfiles interface -- so this talks to the interface, not to +// either binary. powerprofilesctl is not installed here at all. +// +// Not a stored preference. The profile lives in the daemon, survives Panama +// restarts, and can be changed by anything else on the system; keeping a copy +// in settings.json would mean restoring a value the daemon had moved past. +// Same reasoning as monitor brightness. +// +// Read on demand and after each change. The daemon does emit PropertiesChanged, +// but subscribing to it would mean holding a bus connection open for a value +// that changes only when someone chooses it. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-power-profile" + + property var profiles: [] + property string active: "" + property bool scanned: false + property bool busy: false + property string lastError: "" + + // Non-empty when the machine cannot actually deliver the profile it is set + // to -- thermal throttling, or a laptop running on battery. Worth showing, + // because otherwise "performance" is a claim the hardware is not honouring. + property string degraded: "" + + readonly property bool available: root.profiles.length > 0 + + // Presentation lives here rather than in the page so the Control Center and + // Settings cannot disagree about what a profile is called. + function label(profile: string): string { + switch (profile) { + case "power-saver": return "Power Saver"; + case "balanced": return "Balanced"; + case "performance": return "Performance"; + default: return profile; + } + } + + function detail(profile: string): string { + switch (profile) { + case "power-saver": return "Reduces performance to save energy and run quieter"; + case "balanced": return "Standard behaviour, scaling up only when needed"; + case "performance": return "Holds higher clocks, using more power and making more noise"; + default: return ""; + } + } + + function refresh(): void { + if (!query.running) + query.running = true; + } + + function set(profile: string): void { + if (root.busy || profile === root.active) + return; + root.busy = true; + apply.command = [root.helperPath, "set", profile]; + apply.running = true; + } + + Process { + id: query + command: [root.helperPath, "list"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.profiles = Array.isArray(parsed.profiles) ? parsed.profiles : []; + root.active = String(parsed.active ?? ""); + root.degraded = String(parsed.degraded ?? ""); + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.profiles = []; + root.lastError = "Could not read the power profile helper's output."; + console.warn("PowerProfiles: could not parse helper output:", error); + } + root.scanned = true; + } + } + } + + Process { + id: apply + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + // Re-read rather than assuming: the daemon may refuse, or may land on a + // different profile than the one asked for. + onExited: { + root.busy = false; + root.refresh(); + } + } +} diff --git a/tests/quickshell/power-profile-contract.sh b/tests/quickshell/power-profile-contract.sh new file mode 100755 index 0000000..abfd77b --- /dev/null +++ b/tests/quickshell/power-profile-contract.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +# panama-power-profile reads and sets the system power profile. +# +# Runs against a stubbed busctl. The real daemon is a SYSTEM service shared with +# everything else on the machine, and a test that flipped the daily driver into +# power-saver and crashed before restoring would leave it there. +# +# The parsing is the fragile part. busctl renders the Profiles property flat: +# +# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ... +# +# so profile names and driver names sit in the same stream. A pattern loose +# enough to match both reports the driver as an extra profile -- and on this +# machine the driver is literally called "tuned", which reads exactly like a +# plausible fourth profile. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-power-profile" + +fail() { + printf 'power profile contract: %s\n' "$1" >&2 + exit 1 +} + +stub_dir="$(mktemp -d /tmp/panama-power.XXXXXX)" +trap 'rm -rf "$stub_dir"' EXIT + +cat >"$stub_dir/busctl" <<'STUB' +#!/usr/bin/env bash +# Records set-property calls so the test can assert what was written. +case "${1:-}" in + status) + [[ "${PANAMA_POWER_FAKE:-up}" == "down" ]] && exit 1 + exit 0 ;; + get-property) + case "${5:-}" in + ActiveProfile) printf 's "performance"\n' ;; + PerformanceDegraded) printf 's "%s"\n' "${PANAMA_POWER_DEGRADED:-}" ;; + Profiles) + if [[ "${PANAMA_POWER_FAKE:-up}" == "empty" ]]; then + printf 'v aa{sv} 0\n' + else + printf 'v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" s "balanced" "Driver" s "tuned" 2 "Profile" s "performance" "Driver" s "tuned"\n' + fi ;; + esac + exit 0 ;; + set-property) + printf '%s\n' "${!#}" >>"$PANAMA_POWER_SET_LOG" + exit 0 ;; +esac +exit 0 +STUB +chmod +x "$stub_dir/busctl" + +export PANAMA_POWER_SET_LOG="$stub_dir/sets.log" +: >"$PANAMA_POWER_SET_LOG" + +run() { PATH="$stub_dir:$PATH" "$helper" "$@"; } + +# ── Parsing ────────────────────────────────────────────────────────────────── +out="$(run list)" +jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out" + +[[ "$(jq -r '.profiles | length' <<<"$out")" == "3" ]] \ + || fail "expected exactly three profiles; a fourth usually means the Driver value was parsed as one: $out" + +jq -e '.profiles == ["power-saver", "balanced", "performance"]' >/dev/null <<<"$out" \ + || fail "profiles were parsed wrongly or reordered: $out" + +jq -e '.profiles | index("tuned") == null' >/dev/null <<<"$out" \ + || fail 'the driver name "tuned" was reported as a profile' + +[[ "$(jq -r '.active' <<<"$out")" == "performance" ]] \ + || fail "the active profile was not read: $out" + +# ── Degradation is surfaced, since it makes the active profile a lie ───────── +out="$(PANAMA_POWER_DEGRADED="lap-detected" run list)" +[[ "$(jq -r '.degraded' <<<"$out")" == "lap-detected" ]] \ + || fail "a degraded performance state was not reported: $out" + +# ── Setting ────────────────────────────────────────────────────────────────── +run set balanced +[[ "$(tail -1 "$PANAMA_POWER_SET_LOG")" == "balanced" ]] \ + || fail "set did not write the requested profile: $(cat "$PANAMA_POWER_SET_LOG")" + +before="$(wc -l <"$PANAMA_POWER_SET_LOG")" +run set 'evil; rm -rf /' 2>/dev/null +[[ "$(wc -l <"$PANAMA_POWER_SET_LOG")" == "$before" ]] \ + || fail 'a profile name with shell metacharacters reached the system service' + +# ── No daemon, and a daemon with nothing to offer, are both states ─────────── +out="$(PANAMA_POWER_FAKE=down run list)" +jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \ + || fail "a missing power daemon must be reported with a reason: $out" + +out="$(PANAMA_POWER_FAKE=empty run list)" +jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \ + || fail "a daemon offering no profiles must be reported, not shown as an empty card: $out" + +printf 'power profile contract: PASS\n'