Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
473 changes: 473 additions & 0 deletions bin/omarchy-battery-guard

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions bin/omarchy-battery-low
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
#!/bin/bash

# omarchy:summary=Send the low battery warning notification and run battery-low hooks.
# omarchy:args=<percentage>
# omarchy:args=<percentage> [--quiet]
# omarchy:hidden=true

set -euo pipefail

if (($# != 1)); then
echo "Usage: omarchy-battery-low <percentage>" >&2
if (( $# < 1 || $# > 2 )) || [[ ${2:-} != "" && ${2:-} != "--quiet" ]]; then
echo "Usage: omarchy-battery-low <percentage> [--quiet]" >&2
exit 1
fi

level=$1
display_level=$(awk -v p="$level" 'BEGIN {
if (p <= 0) { print 0; exit }
if (p > 100) p = 100
usable = int((p - 4) * 100 / 96 + 0.5)
print usable < 1 ? 1 : usable
}')

omarchy-notification-send -g 󱐋 -u critical "Time to recharge!" "Battery is down to ${level}%" -i battery-caution -t 30000
if [[ ${2:-} != "--quiet" ]]; then
omarchy-notification-send -g 󱐋 -u critical "Time to recharge!" "Battery is down to ${display_level}%" -i battery-caution -t 30000
fi
omarchy-hook battery-low "$level"
13 changes: 8 additions & 5 deletions bin/omarchy-battery-status
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ battery=$(upower -e 2>/dev/null | grep -iE '/devices/battery' | head -n 1)

battery_info=$(upower -i "$battery")

# Keep UPower's unrounded percentage for the charge-hold comparisons below:
# rounding first would let 79.5% cross an 80% hold threshold while still
# charging. The bar widget shows Math.round of the same value, so round half-up
# only for display -- truncating made the panel differ from the bar by 1%.
# Keep raw telemetry for charge-hold comparisons. Display the same usable
# percentage as the shell: raw 5% is 1%, reserving charge for shutdown.
raw_percentage=$(awk '/percentage/ { print $2 + 0; exit }' <<<"$battery_info")
percentage=$(awk -v p="$raw_percentage" 'BEGIN { printf "%d", p + 0.5 }')
percentage=$(awk -v p="$raw_percentage" 'BEGIN {
if (p <= 0) { print 0; exit }
if (p > 100) p = 100
usable = int((p - 4) * 100 / 96 + 0.5)
print usable < 1 ? 1 : usable
}')
capacity=$(awk '/energy-full:/ { printf "%d", $2; exit }' <<<"$battery_info")
time_remaining=$(awk '/time to (empty|full)/ {
value = $4
Expand Down
58 changes: 58 additions & 0 deletions default/battery-guard/close-windows
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/bin/bash

# Called by the guard only after dropping to the graphical session's UID.
set -euo pipefail
(( EUID != 0 )) || exit 1
battery_path="${1:-/sys/class/power_supply/macsmc-battery}"
action="${2:-}"

power_recovered() {
local supply
for supply in "${battery_path%/*}"/*; do
[[ -r $supply/online && $(<"$supply/online") == "1" ]] || continue
[[ -r $battery_path/status ]] || continue
case $(<"$battery_path/status") in Charging|Full) return 0 ;; esac
done
return 1
}

valid_instance() {
[[ $signature =~ ^[a-zA-Z0-9_-]+$ && $pid =~ ^[0-9]+$ && $wayland =~ ^wayland-[0-9]+$ ]] || return 1
[[ $(stat -c %u "/proc/$pid" 2>/dev/null) == "$EUID" ]] || return 1
local socket="$XDG_RUNTIME_DIR/hypr/$signature/.socket.sock"
[[ -S $socket && $(stat -c %u "$socket") == "$EUID" ]]
}

case $action in
--snapshot)
(( $# == 2 )) || exit 2
# Read-only phase: no dispatch is possible until the root daemon persists
# the complete snapshot and subsequently calls --close-one.
instances=$(hyprctl -j instances | jq -r '.[] | [.instance, .pid, .wl_socket] | @tsv')
while IFS=$'\t' read -r signature pid wayland; do
valid_instance || continue
export HYPRLAND_INSTANCE_SIGNATURE="$signature" WAYLAND_DISPLAY="$wayland"
clients=$(hyprctl -j clients | jq -r '.[].address')
while read -r address; do
[[ $address =~ ^0x[0-9a-fA-F]+$ ]] || continue
printf '%s %s %s %s\n' "$signature" "$pid" "$wayland" "$address"
done <<<"$clients"
done <<<"$instances"
;;
--close-one)
(( $# == 6 )) || exit 2
signature=$3 pid=$4 wayland=$5 address=$6
valid_instance || exit 2
[[ $address =~ ^0x[0-9a-fA-F]+$ ]] || exit 2
power_recovered && exit 3
export HYPRLAND_INSTANCE_SIGNATURE="$signature" WAYLAND_DISPLAY="$wayland"
# The root daemon has already persisted this attempt. A transport failure
# is ambiguous (exit 1); only an explicit rejection allows retry (exit 2).
if reply=$(hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })"); then
[[ ${reply,,} != *"error:"* ]] || exit 2
else
exit 1
fi
;;
*) exit 2 ;;
esac
15 changes: 15 additions & 0 deletions default/systemd/system/omarchy-battery-guard.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[Unit]
Description=Graceful shutdown before hard battery cutoff
ConditionPathExists=/sys/class/power_supply
After=systemd-udevd.service

[Service]
Type=simple
Environment=OMARCHY_BATTERY_GUARD_STATE_DIR=/var/lib/omarchy-battery-guard
ExecStart=/usr/bin/omarchy-battery-guard
Restart=on-failure
RestartSec=2
StateDirectory=omarchy-battery-guard

[Install]
WantedBy=multi-user.target
3 changes: 3 additions & 0 deletions install/config/enable-services.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ systemctl enable sddm.service
# whole session down. [Install] pulls in systemd-oomd.socket via Also=, which
# is what the user manager reports app.slice candidacy over.
systemctl enable systemd-oomd.service

# Deploy the root-owned guard before enabling it; do not start it mid-install.
bash "$OMARCHY_INSTALL/helpers/battery-guard.sh"
57 changes: 57 additions & 0 deletions install/helpers/battery-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/bin/bash

# Package-backed deployment only. Never install a privileged service from a
# mutable user checkout or a /usr/bin symlink that resolves into one.
set -euo pipefail
export PATH=/usr/bin:/bin

trusted_path() {
local path owner mode
path=$1
while [[ $path != "/" ]]; do
[[ ! -L $path ]] || return 1
read -r owner mode < <(stat -c '%u %a' -- "$path") || return 1
[[ $owner == "0" ]] && (( (8#$mode & 0022) == 0 )) || return 1
path=${path%/*}
[[ -n $path ]] || path=/
done
}

guard=/usr/bin/omarchy-battery-guard
helper=/usr/share/omarchy/default/battery-guard/close-windows
unit=/usr/share/omarchy/default/systemd/system/omarchy-battery-guard.service
for source in "$guard" "$helper" "$unit"; do
if ! trusted_path "$source"; then
echo "Battery guard requires current root-owned Omarchy packages: $source" >&2
exit 1
fi
done

if [[ ${1:-} == "--restart" ]] &&
sha256sum --check --status /etc/systemd/system/omarchy-battery-guard.service.sha256 2>/dev/null &&
cmp -s "$unit" /etc/systemd/system/omarchy-battery-guard.service &&
systemctl is-enabled --quiet omarchy-battery-guard.service &&
systemctl is-active --quiet omarchy-battery-guard.service; then
exit 0
fi

# Executables remain package-owned. Only the enabled unit and a checksum record
# live under /etc; the record detects payload updates without copying binaries.
trusted_path /etc/systemd/system || exit 1
for target in /etc/systemd/system/omarchy-battery-guard.service.new /etc/systemd/system/omarchy-battery-guard.service.sha256.new; do
[[ ! -L $target ]] || exit 1
done
# Replace the original linked unit without writing through its symlink.
install -o root -g root -m 0644 "$unit" /etc/systemd/system/omarchy-battery-guard.service.new
mv -fT /etc/systemd/system/omarchy-battery-guard.service.new /etc/systemd/system/omarchy-battery-guard.service
if [[ ${1:-} == "--restart" ]]; then
systemctl daemon-reload
systemctl enable omarchy-battery-guard.service
systemctl restart omarchy-battery-guard.service
systemctl is-active --quiet omarchy-battery-guard.service
else
systemctl enable omarchy-battery-guard.service
fi
sha256sum "$guard" "$helper" "$unit" > /etc/systemd/system/omarchy-battery-guard.service.sha256.new
chmod 0644 /etc/systemd/system/omarchy-battery-guard.service.sha256.new
mv -fT /etc/systemd/system/omarchy-battery-guard.service.sha256.new /etc/systemd/system/omarchy-battery-guard.service.sha256
10 changes: 10 additions & 0 deletions manual/36-system-sleep.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ You toggle suspend by running `omarchy toggle suspend` from the terminal. That j
You set up hibernation by running `omarchy hibernation setup` from the terminal. Hibernation creates a /swap subvolume on your boot drive the size of your physical RAM allocation, so make sure you have plenty of room to spare. On a 32GB machine, you'll always need 32GB+ free for this volume. Hibernation also requires the default Limine bootloader.

When set up, you'll see the hibernate option under _System_ (or `Super + Esc`), and then you can see if it works consistently on your system. If not, you can remove it again by running `omarchy hibernation remove`.

### Emergency battery protection

Omarchy watches a discharging laptop while the system is awake, including before login and after logout. At 5% hardware charge, or when the battery reports no more than 90 seconds left, it starts a 60-second shutdown countdown. One notification updates at the start, 30 seconds, 10 seconds, and shutdown. Save your work and connect a charger with enough power to stop the battery discharging; that cancels the countdown. A weak charger that cannot stop discharge does not cancel protection.

In the last 15 seconds, Omarchy asks Hyprland windows to close normally so applications can save or show save dialogs. Answer those dialogs promptly. The save period is part of the countdown, not extra time. At the deadline, normal system shutdown stops services and unmounts filesystems. If the battery reports insufficient runtime, the countdown is shortened to reserve 30 seconds for shutdown; there may be no time for window-close requests. Estimates are imperfect and cannot guarantee protection from sudden battery failure.

This protection shuts down the computer; it does not restore your running session. Unsaved work can be lost if an application does not save before shutdown, and a save dialog cannot postpone emergency shutdown indefinitely. Connecting power after windows have closed will cancel shutdown but will not reopen those windows. The guard does not monitor discharge while the machine is suspended.

The bar, power panel, and battery status command show usable charge with a small shutdown reserve: 5% hardware charge appears as 1%, 100% as 100%, and zero as zero. Charge-limit settings and protection thresholds still use hardware percentages. Other battery tools may therefore show a different percentage.
30 changes: 30 additions & 0 deletions migrations/1789066533.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
echo "Deploy the shutdown-only battery guard from trusted packages"

# Per-user migration markers must not cause a second privilege prompt once
# another user has deployed the exact current payload and started the service.
if sha256sum --check --status /etc/systemd/system/omarchy-battery-guard.service.sha256 2>/dev/null &&
cmp -s /usr/share/omarchy/default/systemd/system/omarchy-battery-guard.service /etc/systemd/system/omarchy-battery-guard.service &&
systemctl is-enabled --quiet omarchy-battery-guard.service &&
systemctl is-active --quiet omarchy-battery-guard.service; then
exit 0
fi

# Validate the fixed packaged installer before executing any of its code as
# root. A user checkout (including a /usr/bin symlink into it) is not trusted.
sudo /bin/bash -c '
set -euo pipefail
export PATH=/usr/bin:/bin
source_path=/usr/share/omarchy/install/helpers/battery-guard.sh
path=$source_path
while [[ $path != "/" ]]; do
[[ ! -L $path ]] || { echo "Battery guard requires package files, not symlinks." >&2; exit 1; }
read -r owner mode < <(stat -c "%u %a" -- "$path")
if [[ $owner != "0" ]] || (( (8#$mode & 0022) != 0 )); then
echo "Update root-owned Omarchy packages before enabling battery protection." >&2
exit 1
fi
path=${path%/*}
[[ -n $path ]] || path=/
done
/bin/bash "$source_path" --restart
'
30 changes: 30 additions & 0 deletions migrations/1789068803.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
echo "Deploy the shutdown-only battery guard from trusted packages"

# Per-user migration markers must not cause a second privilege prompt once
# another user has deployed the exact current payload and started the service.
if sha256sum --check --status /etc/systemd/system/omarchy-battery-guard.service.sha256 2>/dev/null &&
cmp -s /usr/share/omarchy/default/systemd/system/omarchy-battery-guard.service /etc/systemd/system/omarchy-battery-guard.service &&
systemctl is-enabled --quiet omarchy-battery-guard.service &&
systemctl is-active --quiet omarchy-battery-guard.service; then
exit 0
fi

# Validate the fixed packaged installer before executing any of its code as
# root. A user checkout (including a /usr/bin symlink into it) is not trusted.
sudo /bin/bash -c '
set -euo pipefail
export PATH=/usr/bin:/bin
source_path=/usr/share/omarchy/install/helpers/battery-guard.sh
path=$source_path
while [[ $path != "/" ]]; do
[[ ! -L $path ]] || { echo "Battery guard requires package files, not symlinks." >&2; exit 1; }
read -r owner mode < <(stat -c "%u %a" -- "$path")
if [[ $owner != "0" ]] || (( (8#$mode & 0022) != 0 )); then
echo "Update root-owned Omarchy packages before enabling battery protection." >&2
exit 1
fi
path=${path%/*}
[[ -n $path ]] || path=/
done
/bin/bash "$source_path" --restart
'
9 changes: 6 additions & 3 deletions shell/plugins/panels/power/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,18 @@ function profileIcon(name) {
}

function batteryFraction(device) {
return device && device.isPresent ? Math.max(0, Math.min(1, device.percentage)) : 0
if (!device || !device.isPresent) return 0
var raw = Math.max(0, Math.min(100, Number(device.percentage || 0) * 100))
return (raw > 0 ? Math.max(1, Math.round((raw - 4) * 100 / 96)) : 0) / 100
}

function chargeThresholdActive(device, onBattery, states) {
var d = device || {}
var s = states || {}
if (!(d && d.isPresent && !onBattery)) return false

var fraction = batteryFraction(d)
// Charge-hold decisions always use raw telemetry, not the usable display.
var fraction = Number(d.percentage || 0)
if (d.state === s.Discharging) return false
if (d.state === s.PendingCharge) return true
if (d.state === s.FullyCharged && fraction < 0.99) return true
Expand All @@ -69,7 +72,7 @@ function batteryIcon(device, onBattery, states) {

var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
var index = Math.max(0, Math.min(9, Math.floor(d.percentage * 10)))
var index = Math.max(0, Math.min(9, Math.floor(batteryFraction(d) * 10)))
var threshold = chargeThresholdActive(d, onBattery, states)

if (threshold) return defaultIcons[index]
Expand Down
7 changes: 4 additions & 3 deletions shell/plugins/panels/power/Panel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ Panel {
var device = UPower.displayDevice
return Model.chargeThresholdActive(device, root.discharging, upowerStates())
}
readonly property bool batteryFull: fullyCharged || (!root.discharging && batteryFraction >= 1)
readonly property bool batteryFull: fullyCharged || (!root.discharging && UPower.displayDevice && UPower.displayDevice.percentage >= 1)
readonly property bool batteryFlowIdle: batteryFull || chargeThresholdActive

// 0..1 charge level, used by the visual progress bar.
// Usable charge, shared by the bar percentage and panel fill. Hardware
// charge-hold decisions above continue to use the raw UPower telemetry.
readonly property real batteryFraction: {
var d = UPower.displayDevice
return Model.batteryFraction(d)
Expand Down Expand Up @@ -372,7 +373,7 @@ Panel {
Text {
id: heroPercent
textFormat: Text.PlainText
text: root.batteryInfo.percentage || "—"
text: root.batteryPresent ? Math.round(root.batteryFraction * 100) + "%" : "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.displayLarge
Expand Down
10 changes: 7 additions & 3 deletions shell/plugins/services/battery/BatteryModel.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
function batteryPercentage(device) {
if (!device || !device.isPresent) return -1
return Math.round(Number(device.percentage || 0) * 100)
var raw = Math.max(0, Math.min(100, Number(device.percentage || 0) * 100))
return raw > 0 ? Math.max(1, Math.round((raw - 4) * 100 / 96)) : 0
}

function isDischarging(device, onBattery, dischargingState) {
return !!(device && device.isPresent && onBattery && device.state === dischargingState)
}

function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified) {
var level = batteryPercentage(device)
// Preserve the battery-low hook's raw percentage contract. The notification
// command maps its display text separately.
var level = device && device.isPresent ? Math.round(Number(device.percentage || 0) * 100) : -1
if (level < 0) return { level: level, notify: false, notifiedLowBattery: false }

var low = isDischarging(device, onBattery, dischargingState) && level <= threshold
var low = isDischarging(device, onBattery, dischargingState) && Number(device.percentage) * 100 <= threshold
return {
level: level,
// At the raw shutdown threshold the guard owns the countdown toast.
notify: low && !alreadyNotified,
notifiedLowBattery: low
}
Expand Down
2 changes: 2 additions & 0 deletions shell/plugins/services/battery/Service.qml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Item {
"omarchy-battery-low",
String(level)
]
// The guard owns the critical toast, but battery-low hooks still run once.
if (level <= 5) warningProcess.command = warningProcess.command.concat(["--quiet"])
warningProcess.running = true
}

Expand Down
Loading
Loading