diff --git a/bin/omarchy-hibernation-remove b/bin/omarchy-hibernation-remove index f06396b55fe..18f7647099d 100755 --- a/bin/omarchy-hibernation-remove +++ b/bin/omarchy-hibernation-remove @@ -44,6 +44,14 @@ if grep -Fq "$SWAP_FILE" /etc/fstab; then sudo sed -i '/^# Btrfs swapfile for system hibernation$/d' /etc/fstab fi +# Without hibernation, UPower's critical action goes back to its default +UPOWER_DROP_IN="/etc/UPower/UPower.conf.d/70-omarchy-critical-hibernate.conf" +if [[ -f $UPOWER_DROP_IN ]]; then + echo "Removing critical battery hibernation" + sudo rm -f "$UPOWER_DROP_IN" + sudo systemctl try-restart upower.service +fi + # Remove mkinitcpio resume hook echo "Removing resume hook" sudo rm "$MKINITCPIO_CONF" diff --git a/bin/omarchy-hibernation-setup b/bin/omarchy-hibernation-setup index 23636939fab..6185d311ad6 100755 --- a/bin/omarchy-hibernation-setup +++ b/bin/omarchy-hibernation-setup @@ -41,6 +41,7 @@ MKINITCPIO_CONF="/etc/mkinitcpio.conf.d/omarchy_resume.conf" SWAP_FILE="/swap/swapfile" RESUME_DROP_IN="/etc/limine-entry-tool.d/resume.conf" RESUME_REBUILD_PENDING="/var/lib/omarchy/hibernation-resume-rebuild" +UPOWER_DROP_IN="/etc/UPower/UPower.conf.d/70-omarchy-critical-hibernate.conf" install_root_file() { local source="$1" @@ -137,6 +138,34 @@ rebuild_renamed_resume_device() { return 1 } +# The kernel can only restore a hibernated session once it knows where the +# image is: the resume parameters carry the swapfile's offset. +resume_ready() { + [[ -f $RESUME_DROP_IN ]] && grep -q 'resume_offset=' "$RESUME_DROP_IN" && ! grep -q 'resume_offset="$' "$RESUME_DROP_IN" +} + +# On a laptop, hibernate when the battery runs critically low instead of +# suspending into it. Rerunning setup installs it on machines set up before. +# Only once resume works, so a dying battery never powers off a session that +# cannot be restored. +setup_critical_battery_hibernate() { + omarchy-battery-present || return 0 + if ! resume_ready; then + echo "Not hibernating on critical battery until the resume offset is set" >&2 + return 0 + fi + cmp -s "$OMARCHY_PATH/default/upower/omarchy-critical-hibernate.conf" "$UPOWER_DROP_IN" && return 0 + + echo "Hibernating on critical battery" + sudo mkdir -p "${UPOWER_DROP_IN%/*}" + if install_root_file "$OMARCHY_PATH/default/upower/omarchy-critical-hibernate.conf" "$UPOWER_DROP_IN" 0644; then + sudo systemctl try-restart upower.service + else + echo "Could not install the critical battery action" >&2 + return 1 + fi +} + # Check if hibernation is already configured if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; then # Fix empty resume_offset if btrfs map-swapfile failed during initial setup @@ -154,6 +183,7 @@ if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; # After the offset repair, which leaves the device as it found it. rename_resume_device || exit 1 rebuild_renamed_resume_device || exit 1 + setup_critical_battery_hibernate || exit 1 echo "Hibernation is already set up" exit 0 fi @@ -233,16 +263,22 @@ if grep -q "\[s2idle\]" /sys/power/mem_sleep 2>/dev/null; then fi fi +rebuilt=true if ! $NO_REBUILD; then # limine-mkinitcpio rebuilds initramfs/UKI for all kernels and updates the # /boot/limine.conf entries via limine-entry-tool. The limine bootloader # binary on the ESP doesn't change here, so we don't need limine-update # (which would also re-deploy the binary and rebuild a second time). echo "Regenerating initramfs..." - sudo limine-mkinitcpio + sudo limine-mkinitcpio || rebuilt=false echo fi +# With --no-rebuild the caller rebuilds, so the resume parameters land then. +if $rebuilt; then + setup_critical_battery_hibernate +fi + if ! $FORCE && ! $NO_REBUILD && gum confirm "Reboot to enable hibernation?"; then omarchy-system reboot fi diff --git a/default/upower/omarchy-critical-hibernate.conf b/default/upower/omarchy-critical-hibernate.conf new file mode 100644 index 00000000000..e8db3166df2 --- /dev/null +++ b/default/upower/omarchy-critical-hibernate.conf @@ -0,0 +1,12 @@ +# Installed by omarchy-hibernation-setup on laptops. UPower's Auto action is +# logind's Sleep(), which is suspend-then-hibernate: at the last few percent the +# suspend can drain the battery before the hibernate ever runs. Hibernate +# directly, early enough for the image to be written on battery. +# +# To pick another level, put PercentageAction under [UPower] in a later drop-in, +# such as /etc/UPower/UPower.conf.d/99-local.conf, and keep it below +# PercentageCritical. Setup leaves that file alone. +[UPower] +CriticalPowerAction=Hibernate +PercentageCritical=8.0 +PercentageAction=5.0 diff --git a/manual/36-system-sleep.md b/manual/36-system-sleep.md index aa4254e85d9..443338ec6bd 100644 --- a/manual/36-system-sleep.md +++ b/manual/36-system-sleep.md @@ -19,3 +19,5 @@ 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`. + +On a laptop, setup also makes the machine hibernate when the battery reaches 5% instead of suspending with a nearly empty battery. To pick another level, put `PercentageAction=3.0` under `[UPower]` in `/etc/UPower/UPower.conf.d/99-local.conf` (keep it below `PercentageCritical`, which is 8), then run `sudo systemctl restart upower`. diff --git a/migrations/1793289056.sh b/migrations/1793289056.sh new file mode 100644 index 00000000000..1a86a78f950 --- /dev/null +++ b/migrations/1793289056.sh @@ -0,0 +1,12 @@ +echo "Hibernate on critical battery on laptops that already set up hibernation" + +# omarchy-hibernation-setup now installs a UPower drop-in that hibernates on +# critical battery, but only when it runs. A laptop set up before keeps UPower's +# Auto action, which suspends into a dying battery (#13256). On a machine that +# already has hibernation, setup skips the swap and boot steps and only adds +# what is missing, and it leaves an installed drop-in alone. +resume_conf="${OMARCHY_RESUME_CONF:-/etc/mkinitcpio.conf.d/omarchy_resume.conf}" + +if [[ -f $resume_conf ]] && grep -q "^HOOKS+=(resume)$" "$resume_conf" && omarchy-battery-present; then + omarchy-hibernation-setup +fi diff --git a/test/shell.d/hibernation-setup-test.sh b/test/shell.d/hibernation-setup-test.sh index 4ea65eef149..ddd70c9aaa7 100755 --- a/test/shell.d/hibernation-setup-test.sh +++ b/test/shell.d/hibernation-setup-test.sh @@ -16,6 +16,7 @@ sed -e "s|/sys/power/|$rtmp/sys/|g" \ -e "s|/swap/swapfile|$rtmp/swapfile|g" \ -e "s|/usr/lib/systemd/system-sleep|$rtmp/sleep|g" \ -e "s|/var/lib/omarchy|$rtmp/var-lib|g" \ + -e "s|/etc/UPower|$rtmp/UPower|g" \ "$ROOT/bin/omarchy-hibernation-setup" >"$rtmp/setup" for command in omarchy-hw-aarch64-apple omarchy-sleep-disabled omarchy-cmd-missing gum; do @@ -23,7 +24,10 @@ for command in omarchy-hw-aarch64-apple omarchy-sleep-disabled omarchy-cmd-missi done printf '#!/bin/bash\nexit 0\n' >"$rtmp/bin/swaplabel" printf '#!/bin/bash\n[[ $1 == --show ]] && echo "$SWAP_FILE_PATH"\nexit 0\n' >"$rtmp/bin/swapon" -printf '#!/bin/bash\n[[ $1 == inspect-internal ]] && echo 4242\nexit 0\n' >"$rtmp/bin/btrfs" +printf '#!/bin/bash\n[[ $1 == inspect-internal ]] && echo "${MAP_OFFSET-4242}"\nexit 0\n' >"$rtmp/bin/btrfs" +# Not a laptop unless a case says so, and service restarts are only recorded. +printf '#!/bin/bash\n[[ ${BATTERY:-0} == 1 ]]\n' >"$rtmp/bin/omarchy-battery-present" +printf '#!/bin/bash\necho "systemctl $*" >>"$CALL_LOG"\n' >"$rtmp/bin/systemctl" printf '#!/bin/bash\n[[ $2 == SOURCE ]] && echo "$SWAP_SOURCE"\n[[ $2 == UUID ]] && echo "$SWAP_UUID"\nexit 0\n' >"$rtmp/bin/findmnt" # Root ownership is not the test's to give. @@ -75,7 +79,7 @@ machine() { } setup() { - env PATH="$rtmp/bin:$PATH" SWAP_FILE_PATH="$rtmp/swapfile" SWAP_SOURCE="$SWAP_SOURCE" SWAP_UUID="$uuid" \ + env PATH="$rtmp/bin:$PATH" OMARCHY_PATH="$ROOT" CALL_LOG="$rtmp/calls" SWAP_FILE_PATH="$rtmp/swapfile" SWAP_SOURCE="$SWAP_SOURCE" SWAP_UUID="$uuid" \ DROP_IN="$drop_in" REBUILD_LOG="$rtmp/rebuilds" REBUILD_RESULT="${REBUILD_RESULT:-ok}" \ bash "$rtmp/setup" "$@" >"$rtmp/out" 2>&1 } @@ -131,6 +135,62 @@ for line in 'KERNEL_CMDLINE[default]+=" resume=/dev/mapper/root resume_offset=42 [[ $(<"$drop_in") == "$line" && ! -s $rtmp/rebuilds ]] || fail "setup leaves a drop-in it does not own alone" "$(<"$drop_in")" done pass "setup leaves device-mapper and hand-edited drop-ins alone" + +# On a laptop, setup makes a critical battery hibernate instead of suspending +# into it, but only once resume can restore the session. +upower=$rtmp/UPower/UPower.conf.d/70-omarchy-critical-hibernate.conf +laptop() { BATTERY=1 SWAP_SOURCE="/dev/nvme0n1p2[/@]" setup "$@"; } + +machine 'KERNEL_CMDLINE[default]+=" resume=UUID='"$uuid"' resume_offset="' +MAP_OFFSET= laptop || fail "setup with an empty resume offset succeeds" "$(<"$rtmp/out")" +[[ ! -e $upower ]] || fail "a laptop with an empty resume offset gets no critical battery action" +rm -f "$drop_in" +laptop || fail "setup without resume parameters succeeds" "$(<"$rtmp/out")" +[[ ! -e $upower ]] || fail "a laptop without resume parameters gets no critical battery action" +pass "the critical battery action waits until resume can restore the session" + +machine "$uuid_line" +: >"$rtmp/calls" +laptop || fail "setup on a laptop set up before succeeds" "$(<"$rtmp/out")" +cmp -s "$ROOT/default/upower/omarchy-critical-hibernate.conf" "$upower" || + fail "a laptop set up before gets the critical battery action" "$(<"$rtmp/out")" +grep -qx 'systemctl try-restart upower.service' "$rtmp/calls" || fail "UPower is restarted to read the new action" +: >"$rtmp/calls" +laptop +[[ ! -s $rtmp/calls ]] || fail "an installed action is left alone" "$(<"$rtmp/calls")" +pass "a rerun on a laptop installs the critical battery action once" + +rm -rf "$rtmp/UPower" +SWAP_SOURCE="/dev/nvme0n1p2[/@]" setup +[[ ! -e $upower ]] || fail "a machine without a battery gets no critical battery action" +pass "a machine without a battery gets no critical battery action" + +# The migration reruns setup on laptops; a failed install fails setup, so the +# migration stays pending instead of being marked done. +mkdir -p "$rtmp/UPower" && : >"$rtmp/UPower/UPower.conf.d" +if laptop; then + fail "setup reports a failed install of the critical battery action" "$(<"$rtmp/out")" +fi +rm -rf "$rtmp/UPower" +pass "a failed install of the critical battery action fails setup" + +# Removing hibernation takes the action back. +laptop +sed -e "s|/etc/mkinitcpio.conf.d|$rtmp/mkinitcpio.conf.d|g" \ + -e "s|/etc/fstab|$rtmp/fstab|g" \ + -e "s|/etc/UPower|$rtmp/UPower|g" \ + -e "s|SWAP_SUBVOLUME=\"/swap\"|SWAP_SUBVOLUME=\"$rtmp/swap\"|" \ + "$ROOT/bin/omarchy-hibernation-remove" >"$rtmp/remove" +! grep -Eq '/etc/|"/swap"' "$rtmp/remove" || fail "remove reaches only the scratch root" "$(grep -E '/etc/|\"/swap\"' "$rtmp/remove")" +mkdir -p "$rtmp/yes" +printf '#!/bin/bash\nexit 0\n' >"$rtmp/yes/gum" +chmod +x "$rtmp/yes/gum" +: >"$rtmp/calls" +env PATH="$rtmp/yes:$rtmp/bin:$PATH" CALL_LOG="$rtmp/calls" DROP_IN="$drop_in" REBUILD_LOG="$rtmp/rebuilds" \ + bash "$rtmp/remove" >"$rtmp/out" 2>&1 || fail "remove succeeds" "$(<"$rtmp/out")" +[[ ! -e $upower ]] || fail "removing hibernation removes the critical battery action" +grep -qx 'systemctl try-restart upower.service' "$rtmp/calls" || fail "UPower is restarted after the action is removed" +pass "removing hibernation removes the critical battery action" rm -rf "$rtmp" if [[ ! -f /sys/power/image_size ]]; then