Track the power profile through Quickshell instead of polling busctl (#15027)

* Track the power profile through Quickshell instead of polling busctl

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Recover the power profile when the daemon was down at shell start

Addresses Codex review: Quickshell never attaches its PowerProfiles listener if the daemon failed at startup. One-off reads on startup, power source changes and an IPC refresh from omarchy-powerprofiles-set cover that, still with no timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Don't let a stale power profile read win over a change signal or drop a refresh

Addresses Codex re-review: a busctl read in flight could overwrite a newer PowerProfiles signal, and refreshes requested while a read ran were dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test power profile transitions and stale reads as behavior, not source text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 authored and GitHub committed 2026-10-11 23:01:49 +09:00
1 parent 3c1f7b780f
commit 1b1cc5ecb5
5 files changed
+101 -25

No files matched your search

+1
View File
@@ -56,6 +56,7 @@ if [[ -z ${profile:-} ]] || ! profile_available "$profile"; then
fi
powerprofilesctl set "$profile" || exit
omarchy-shell -q battery refreshPowerProfile >/dev/null 2>&1 || true
if [[ -n $requested_profile ]]; then
mkdir -p "$state_dir"
+23 -1
View File
@@ -19,10 +19,32 @@ function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, al
}
}
function parseActiveProfile(text) {
// busctl --json=short prints {"type":"s","data":"balanced"}; an empty or
// malformed reply (daemon not running) reads as no active profile.
try {
return String(JSON.parse(text).data || "").trim()
} catch (e) {
return ""
}
}
function profileAfterRead(current, replyText, signalsAtStart, signalsNow) {
// A change signal that arrived during the read is newer than its reply.
return signalsAtStart === signalsNow ? parseActiveProfile(replyText) : current
}
function isPowerSaverOnBattery(onBattery, profile) {
return !!onBattery && profile === "power-saver"
}
if (typeof module !== "undefined") {
module.exports = {
batteryPercentage: batteryPercentage,
isDischarging: isDischarging,
shouldWarnLowBattery: shouldWarnLowBattery
shouldWarnLowBattery: shouldWarnLowBattery,
parseActiveProfile: parseActiveProfile,
profileAfterRead: profileAfterRead,
isPowerSaverOnBattery: isPowerSaverOnBattery
}
}
+42 -22
View File
@@ -2,6 +2,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import qs.Commons
import "BatteryModel.js" as BatteryModel
Item {
@@ -12,8 +13,15 @@ Item {
readonly property int batteryThreshold: 10
property string pendingPowerSource: ""
// Follows power-profiles-daemon's change signals, so nothing polls it.
// Quickshell never attaches that listener if the daemon failed when the
// shell started, so a one-off read on startup, on a power source change and
// after omarchy-powerprofiles-set (which asks for it over IPC) recovers.
property string activePowerProfile: ""
readonly property bool powerSaverOnBattery: UPower.onBattery && activePowerProfile === "power-saver"
property bool powerProfileReadPending: false
property int powerProfileSignals: 0
property int powerProfileReadSignals: 0
readonly property bool powerSaverOnBattery: BatteryModel.isPowerSaverOnBattery(UPower.onBattery, activePowerProfile)
PersistentProperties {
id: persisted
@@ -56,16 +64,22 @@ Item {
}
function refreshPowerProfile() {
if (!powerProfileReadProcess.running) powerProfileReadProcess.running = true
// A read already running may have sampled the profile before the change
// this refresh is for, so run another once it finishes.
if (powerProfileReadProcess.running) {
powerProfileReadPending = true
return
}
powerProfileReadPending = false
powerProfileReadSignals = powerProfileSignals
powerProfileReadProcess.running = true
}
function parseActiveProfile(text) {
// busctl --json=short prints {"type":"s","data":"balanced"}; an empty or
// malformed reply (daemon not running) reads as no active profile.
try {
return String(JSON.parse(text).data || "").trim()
} catch (e) {
return ""
function profileName(profile) {
switch (profile) {
case PowerProfile.PowerSaver: return "power-saver"
case PowerProfile.Performance: return "performance"
default: return "balanced"
}
}
@@ -83,26 +97,32 @@ Item {
id: powerProfileReadProcess
// Read the property straight from the daemon rather than via
// `powerprofilesctl get`. That is a PyGObject script, and spawning a Python
// interpreter for it every two seconds trips a CPython 3.14 shutdown race
// interpreter for it trips a CPython 3.14 shutdown race
// (python/cpython#124619): the GLib D-Bus worker thread re-enters the
// interpreter after finalization and the process dies with SIGSEGV,
// leaving a core dump and a crash notification behind roughly daily.
// interpreter after finalization and the process dies with SIGSEGV.
command: ["busctl", "--json=short", "get-property", "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles", "net.hadess.PowerProfiles", "ActiveProfile"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.activePowerProfile = root.parseActiveProfile(text)
onStreamFinished: root.activePowerProfile = BatteryModel.profileAfterRead(root.activePowerProfile, text, root.powerProfileReadSignals, root.powerProfileSignals)
}
onExited: {
// Deferred: the process still reports running while it signals exit.
if (root.powerProfileReadPending) Qt.callLater(root.refreshPowerProfile)
}
}
Timer {
// There is no portable way to subscribe to profile changes from QML; keep
// them visible to consumers such as the wallpaper service without requiring
// the power panel to be open.
interval: 2000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refreshPowerProfile()
Connections {
target: PowerProfiles
function onProfileChanged() {
root.powerProfileSignals++
root.activePowerProfile = root.profileName(PowerProfiles.profile)
}
}
ShellIpc {
target: "battery"
function refreshPowerProfile(): void { root.refreshPowerProfile() }
}
Timer {
+26
View File
@@ -28,4 +28,30 @@ assertDeepEqual(
{ level: 40, notify: false, notifiedLowBattery: false },
'battery clears notified state after recovery'
)
const reply = profile => JSON.stringify({ type: 's', data: profile })
assertEqual(battery.parseActiveProfile(reply('power-saver')), 'power-saver', 'battery parses the busctl profile reply')
assertEqual(battery.parseActiveProfile(''), '', 'battery reads a missing daemon as no profile')
// Walk the profile through a session on battery: a startup read, then change
// signals, with the lock video gated on power-saver.
let profile = battery.profileAfterRead('', reply('balanced'), 0, 0)
assert(!battery.isPowerSaverOnBattery(true, profile), 'battery keeps the video on balanced')
profile = 'power-saver'
assert(battery.isPowerSaverOnBattery(true, profile), 'battery stops the video when power-saver arrives on battery')
assert(!battery.isPowerSaverOnBattery(false, profile), 'battery ignores power-saver on AC')
profile = 'balanced'
assert(!battery.isPowerSaverOnBattery(true, profile), 'battery resumes the video when power-saver ends')
// A read that sampled balanced before a power-saver signal must not undo it.
const signalsAtStart = 3
profile = 'power-saver'
profile = battery.profileAfterRead(profile, reply('balanced'), signalsAtStart, signalsAtStart + 1)
assertEqual(profile, 'power-saver', 'battery drops a read made stale by a change signal')
assert(battery.isPowerSaverOnBattery(true, profile), 'battery keeps the video stopped after a stale read')
assertEqual(
battery.profileAfterRead('balanced', reply('power-saver'), signalsAtStart, signalsAtStart),
'power-saver',
'battery takes a read no signal overtook'
)
JS
+9 -2
View File
@@ -147,10 +147,17 @@ assert(
)
assert(
batteryService.includes('property string activePowerProfile') &&
batteryService.includes('UPower.onBattery && activePowerProfile === "power-saver"') &&
batteryService.includes('powerSaverOnBattery: BatteryModel.isPowerSaverOnBattery(UPower.onBattery, activePowerProfile)') &&
/function onProfileChanged\(\) \{\s*root\.powerProfileSignals\+\+\s*root\.activePowerProfile = root\.profileName\(PowerProfiles\.profile\)/.test(batteryService) &&
batteryService.includes('root.activePowerProfile = BatteryModel.profileAfterRead(root.activePowerProfile, text, root.powerProfileReadSignals, root.powerProfileSignals)') &&
/if \(powerProfileReadProcess\.running\) \{\s*powerProfileReadPending = true/.test(batteryService) &&
batteryService.includes('if (root.powerProfileReadPending) Qt.callLater(root.refreshPowerProfile)') &&
batteryService.includes('case PowerProfile.PowerSaver: return "power-saver"') &&
batteryService.includes('"get-property", "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles", "net.hadess.PowerProfiles", "ActiveProfile"') &&
!batteryService.includes('["powerprofilesctl"') &&
batteryService.includes('interval: 2000'),
!/Timer \{[^}]*refreshPowerProfile/.test(batteryService) &&
batteryService.includes('function refreshPowerProfile(): void { root.refreshPowerProfile() }') &&
fs.readFileSync(path.join(root, 'bin/omarchy-powerprofiles-set'), 'utf8').includes('omarchy-shell -q battery refreshPowerProfile'),
'the battery service tracks the active power-saver profile'
)
assert(