Files
omarchy/shell/plugins/services/battery/Service.qml
T
David Heinemeier HanssonandClaude Opus 5.5 1b1cc5ecb5 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>
2026-10-11 23:01:49 +09:00

147 lines
4.5 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import qs.Commons
import "BatteryModel.js" as BatteryModel
Item {
id: root
property var shell: null
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
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: ""
property bool powerProfileReadPending: false
property int powerProfileSignals: 0
property int powerProfileReadSignals: 0
readonly property bool powerSaverOnBattery: BatteryModel.isPowerSaverOnBattery(UPower.onBattery, activePowerProfile)
PersistentProperties {
id: persisted
reloadableId: "omarchy-battery"
property bool notifiedLowBattery: false
}
function batteryPercentage() {
return BatteryModel.batteryPercentage(UPower.displayDevice)
}
function isDischarging() {
return BatteryModel.isDischarging(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging)
}
function checkBattery() {
var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery)
persisted.notifiedLowBattery = state.notifiedLowBattery
if (state.notify) sendLowBatteryWarning(state.level)
}
function sendLowBatteryWarning(level) {
if (warningProcess.running) return
warningProcess.command = [
"omarchy-battery-low",
String(level)
]
warningProcess.running = true
}
function applyPowerProfile() {
pendingPowerSource = UPower.onBattery ? "battery" : "ac"
if (!powerProfileProcess.running) runPendingPowerProfile()
}
function runPendingPowerProfile() {
powerProfileProcess.command = ["omarchy-powerprofiles-set", pendingPowerSource]
pendingPowerSource = ""
powerProfileProcess.running = true
}
function refreshPowerProfile() {
// 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 profileName(profile) {
switch (profile) {
case PowerProfile.PowerSaver: return "power-saver"
case PowerProfile.Performance: return "performance"
default: return "balanced"
}
}
Process { id: warningProcess }
Process {
id: powerProfileProcess
onExited: {
if (root.pendingPowerSource !== "") root.runPendingPowerProfile()
root.refreshPowerProfile()
}
}
Process {
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 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.
command: ["busctl", "--json=short", "get-property", "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles", "net.hadess.PowerProfiles", "ActiveProfile"]
stdout: StdioCollector {
waitForEnd: true
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)
}
}
Connections {
target: PowerProfiles
function onProfileChanged() {
root.powerProfileSignals++
root.activePowerProfile = root.profileName(PowerProfiles.profile)
}
}
ShellIpc {
target: "battery"
function refreshPowerProfile(): void { root.refreshPowerProfile() }
}
Timer {
interval: 30000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.checkBattery()
}
Connections {
target: UPower
function onOnBatteryChanged() {
root.checkBattery()
root.applyPowerProfile()
root.refreshPowerProfile()
}
}
Component.onCompleted: root.refreshPowerProfile()
}