diff --git a/bin/omarchy-powerprofiles-set b/bin/omarchy-powerprofiles-set index 1135e4d5..836ce827 100755 --- a/bin/omarchy-powerprofiles-set +++ b/bin/omarchy-powerprofiles-set @@ -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" diff --git a/shell/plugins/services/battery/BatteryModel.js b/shell/plugins/services/battery/BatteryModel.js index 2aca5dbd..f1b59589 100644 --- a/shell/plugins/services/battery/BatteryModel.js +++ b/shell/plugins/services/battery/BatteryModel.js @@ -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 } } diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml index eb7561f1..8538d421 100644 --- a/shell/plugins/services/battery/Service.qml +++ b/shell/plugins/services/battery/Service.qml @@ -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 { diff --git a/test/shell.d/battery-test.sh b/test/shell.d/battery-test.sh index 0447779a..f4392d00 100644 --- a/test/shell.d/battery-test.sh +++ b/test/shell.d/battery-test.sh @@ -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 diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index 9a5b3846..1c3fccad 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -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(