From 33dad30e3b54ff8c7f27d448eb702eef865c9dd8 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Fri, 11 Sep 2026 01:03:23 +0530 Subject: [PATCH 1/2] Add emergency battery discharge guard --- bin/omarchy-battery-guard | 504 ++++++++++++++++++ .../system/omarchy-battery-guard.service | 15 + install/config/enable-services.sh | 5 + manual/36-system-sleep.md | 6 + migrations/1789066533.sh | 12 + test/shell.d/battery-guard-test.sh | 164 ++++++ test/shell.d/systemd-test.sh | 20 + 7 files changed, 726 insertions(+) create mode 100755 bin/omarchy-battery-guard create mode 100644 default/systemd/system/omarchy-battery-guard.service create mode 100644 migrations/1789066533.sh create mode 100644 test/shell.d/battery-guard-test.sh diff --git a/bin/omarchy-battery-guard b/bin/omarchy-battery-guard new file mode 100755 index 00000000000..5cabbecaa33 --- /dev/null +++ b/bin/omarchy-battery-guard @@ -0,0 +1,504 @@ +#!/bin/bash + +# omarchy:summary=Monitor battery discharge and prevent hard cutoff +# omarchy:group=battery +# omarchy:args=[--oneshot] +# omarchy:examples=omarchy battery guard +# omarchy:hidden=true + +set -euo pipefail + +POWER_SUPPLY_PATH="${OMARCHY_POWER_SUPPLY_PATH:-/sys/class/power_supply}" +if (( EUID == 0 )); then + STATE_ROOT="${OMARCHY_BATTERY_GUARD_STATE_DIR:-/var/lib/omarchy-battery-guard}" +else + STATE_ROOT="${OMARCHY_BATTERY_GUARD_STATE_DIR:-$HOME/.local/state/omarchy/battery-guard}" +fi +STATE_FILE="$STATE_ROOT/state" +BOOT_MARKER_FILE="$STATE_ROOT/boot-preserve-required" + +POLL_INTERVAL="${OMARCHY_BATTERY_GUARD_POLL_INTERVAL:-1}" +COUNTDOWN_SECONDS="${OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS:-60}" +THRESHOLD_PERCENT="${OMARCHY_BATTERY_GUARD_THRESHOLD_PERCENT:-10}" +TIMELEFT_TRIGGER_SECONDS="${OMARCHY_BATTERY_GUARD_TIMELEFT_TRIGGER_SECONDS:-90}" +MAX_ONESHOT_CYCLES="${OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES:-1}" +DRY_RUN="${OMARCHY_BATTERY_GUARD_DRY_RUN:-false}" + +normalize_int() { + local value="$1" + local fallback="$2" + local min="$3" + local max="${4:-}" + local normalized + + if [[ $value =~ ^-?[0-9]+$ ]]; then + normalized=$value + if (( normalized < min )); then + normalized=$min + elif [[ -n $max ]] && (( normalized > max )); then + normalized=$max + fi + else + normalized=$fallback + fi + + printf '%s\n' "$normalized" +} + +POLL_INTERVAL=$(normalize_int "$POLL_INTERVAL" 1 1) +COUNTDOWN_SECONDS=$(normalize_int "$COUNTDOWN_SECONDS" 60 1) +THRESHOLD_PERCENT=$(normalize_int "$THRESHOLD_PERCENT" 10 0 100) +TIMELEFT_TRIGGER_SECONDS=$(normalize_int "$TIMELEFT_TRIGGER_SECONDS" 90 1) +MAX_ONESHOT_CYCLES=$(normalize_int "$MAX_ONESHOT_CYCLES" 1 1) + +oneshot=false +if (($# > 0)); then + while (($# > 0)); do + case "$1" in + --oneshot) + oneshot=true + ;; + --help|-h) + echo "Usage: omarchy-battery-guard [--oneshot]" + exit 0 + ;; + *) + echo "Usage: omarchy-battery-guard [--oneshot]" >&2 + exit 2 + ;; + esac + shift + done +fi + +mkdir -p "$STATE_ROOT" +chmod 700 "$STATE_ROOT" +exec 9>"$STATE_ROOT/lock" +if ! flock -n 9; then + exit 0 +fi + +is_battery_monitor_running() { + [[ -d $POWER_SUPPLY_PATH ]] || return 1 + return 0 +} + +find_battery_supply() { + local path="" + + for path in "$POWER_SUPPLY_PATH"/*; do + [[ -r $path/type ]] || continue + + if [[ -r $path/present && $(<"$path/present") != "1" ]]; then + continue + fi + + [[ $(<"$path/type") == "Battery" ]] || continue + echo "$path" + return 0 + done + + return 1 +} + +find_ac_online() { + local path="" + local type + + for path in "$POWER_SUPPLY_PATH"/*; do + [[ -r $path/type ]] || continue + [[ -r $path/online ]] || continue + + type=$(<"$path/type") + case "$type" in + Mains|USB|Wireless) + if [[ $(<"$path/online") == "1" ]]; then + return 0 + fi + ;; + esac + done + + return 1 +} + +read_battery_percentage() { + local path="$1" + local capacity + local energy_now + local energy_full + + if [[ -r $path/capacity ]]; then + capacity=$(<"$path/capacity") + if [[ $capacity =~ ^[0-9]+$ ]]; then + echo "$capacity" + return 0 + fi + fi + + if [[ -r $path/charge_now && -r $path/charge_full ]]; then + energy_now=$(<"$path/charge_now") + energy_full=$(<"$path/charge_full") + elif [[ -r $path/energy_now && -r $path/energy_full ]]; then + energy_now=$(<"$path/energy_now") + energy_full=$(<"$path/energy_full") + else + return 1 + fi + + [[ -n $energy_now && -n $energy_full && $energy_full != 0 ]] || return 1 + + awk -v now="$energy_now" -v full="$energy_full" 'BEGIN { + value = (now / full) * 100 + if (value < 0) value = 0 + if (value > 100) value = 100 + print int(value + 0.5) + }' +} + +read_positive_magnitude() { + local path="$1" + local value + + [[ -r $path ]] || return 1 + value=$(<"$path") + value=${value#-} + [[ $value =~ ^[0-9]+$ ]] || return 1 + (( value > 0 )) || return 1 + printf '%s\n' "$value" +} + +read_battery_state() { + local battery_path="$1" + local percentage_raw + local charge_now + local current_now + local energy_now + local power_now + local state + local time_to_empty + + percentage_raw=$(read_battery_percentage "$battery_path") || { + percent=-1 + return 1 + } + percent=$percentage_raw + + if [[ -r $battery_path/status ]]; then + state=$(<"$battery_path/status") + else + state="Unknown" + fi + + if [[ $state == "Discharging" ]]; then + discharging=true + else + discharging=false + fi + + seconds_to_empty=0 + if time_to_empty=$(read_positive_magnitude "$battery_path/time_to_empty_now"); then + seconds_to_empty=$time_to_empty + elif energy_now=$(read_positive_magnitude "$battery_path/energy_now") && + power_now=$(read_positive_magnitude "$battery_path/power_now"); then + seconds_to_empty=$(awk -v energy="$energy_now" -v power="$power_now" 'BEGIN { + print int((energy * 3600) / power) + }') + elif charge_now=$(read_positive_magnitude "$battery_path/charge_now") && + current_now=$(read_positive_magnitude "$battery_path/current_now"); then + seconds_to_empty=$(awk -v charge="$charge_now" -v current="$current_now" 'BEGIN { + print int((charge * 3600) / current) + }') + fi + + return 0 +} + +notify_critical() { + local message="$1" + local delivered=false + local runtime_dir + local uid + local user + + if (( EUID != 0 )); then + omarchy-notification-send -i battery-caution -u critical "$message" >/dev/null 2>&1 || true + return 0 + fi + + while read -r uid user _; do + [[ $uid =~ ^[0-9]+$ ]] || continue + (( uid >= 1000 )) || continue + runtime_dir="/run/user/$uid" + [[ -S $runtime_dir/bus ]] || continue + + if timeout 2s runuser -u "$user" -- env \ + XDG_RUNTIME_DIR="$runtime_dir" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime_dir/bus" \ + /usr/bin/omarchy-notification-send -i battery-caution -u critical "$message" >/dev/null 2>&1; then + delivered=true + fi + done < <(loginctl list-users --no-legend 2>/dev/null || true) + + if ! $delivered; then + timeout 2s wall -n "$message" >/dev/null 2>&1 || true + fi +} + +mark_boot_recovery() { + local marker_tmp + + mkdir -p "$STATE_ROOT" + chmod 700 "$STATE_ROOT" + marker_tmp=$(mktemp --tmpdir="$STATE_ROOT" .boot-preserve-required.XXXXXX) + printf '%s\n' "protected-action" >"$marker_tmp" + mv -f "$marker_tmp" "$BOOT_MARKER_FILE" +} + +clear_boot_marker() { + [[ -f $BOOT_MARKER_FILE ]] || return 0 + rm -f "$BOOT_MARKER_FILE" +} + +write_state() { + local mode="$1" + local percent_value="$2" + local remaining_value="$3" + local deadline_value="${4:-0}" + local state_tmp + + mkdir -p "$STATE_ROOT" + chmod 700 "$STATE_ROOT" + state_tmp=$(mktemp --tmpdir="$STATE_ROOT" .state.XXXXXX) + printf 'mode=%s\npercentage=%s\nremaining=%s\ndeadline=%s\nupdated=%s\n' \ + "$mode" "$percent_value" "$remaining_value" "$deadline_value" "$(date +%s)" >"$state_tmp" + mv -f "$state_tmp" "$STATE_FILE" +} + +read_persisted_countdown() { + local key + local value + local persisted_mode="" + local persisted_remaining="" + local persisted_deadline="" + local now + + [[ -r $STATE_FILE ]] || return 1 + + while IFS='=' read -r key value; do + case "$key" in + mode) persisted_mode=$value ;; + remaining) persisted_remaining=$value ;; + deadline) persisted_deadline=$value ;; + esac + done <"$STATE_FILE" + + [[ $persisted_mode == "countdown" ]] || return 1 + [[ $persisted_remaining =~ ^[0-9]+$ ]] || return 1 + + if [[ $persisted_deadline =~ ^[0-9]+$ ]]; then + countdown_deadline=$persisted_deadline + now=$(date +%s) + countdown_remaining=$((countdown_deadline - now)) + if (( countdown_remaining < 0 )); then + countdown_remaining=0 + elif (( countdown_remaining > COUNTDOWN_SECONDS )); then + countdown_remaining=$COUNTDOWN_SECONDS + countdown_deadline=$((now + countdown_remaining)) + fi + else + countdown_remaining=$persisted_remaining + if (( countdown_remaining > COUNTDOWN_SECONDS )); then + countdown_remaining=$COUNTDOWN_SECONDS + fi + countdown_deadline=$(($(date +%s) + countdown_remaining)) + fi + + countdown_active=true + return 0 +} + +run_action_once() { + local action="poweroff" + + if find_ac_online; then + clear_boot_marker + action_result="cancelled" + return 0 + fi + + if omarchy-hibernation-available >/dev/null 2>&1; then + action="hibernate" + fi + + notify_critical "Battery guard activated. Preserving session where possible." + + if [[ $DRY_RUN == "true" ]]; then + mark_boot_recovery + action_result="dry-run" + return 0 + fi + + if [[ $action == "hibernate" ]]; then + if find_ac_online; then + clear_boot_marker + action_result="cancelled" + return 0 + fi + + mark_boot_recovery + if systemctl hibernate --no-wall; then + action_result="resumed" + return 0 + fi + + if find_ac_online; then + clear_boot_marker + action_result="cancelled" + return 0 + fi + fi + + if find_ac_online; then + clear_boot_marker + action_result="cancelled" + return 0 + fi + + mark_boot_recovery + systemctl poweroff --no-wall + action_result="poweroff" +} + +emit_boot_recovery_notice() { + if [[ ! -f $BOOT_MARKER_FILE ]]; then + return + fi + + notify_critical "Battery guard previously took protective action. The session was restored only if hibernation completed." + + clear_boot_marker +} + +if ! is_battery_monitor_running; then + clear_boot_marker + exit 0 +fi + +emit_boot_recovery_notice + +countdown_active=false +countdown_remaining=$COUNTDOWN_SECONDS +countdown_deadline=0 + +read_persisted_countdown || true + +cycle=0 + +while true; do + battery_path=$(find_battery_supply || true) + if [[ -z $battery_path ]]; then + countdown_active=false + sleep "$POLL_INTERVAL" + cycle=$((cycle + 1)) + if $oneshot && (( cycle >= MAX_ONESHOT_CYCLES )); then + exit 0 + fi + continue + fi + + if ! read_battery_state "$battery_path"; then + countdown_active=false + sleep "$POLL_INTERVAL" + cycle=$((cycle + 1)) + if $oneshot && (( cycle >= MAX_ONESHOT_CYCLES )); then + exit 0 + fi + continue + fi + + if find_ac_online; then + if $countdown_active; then + countdown_active=false + countdown_remaining=$COUNTDOWN_SECONDS + write_state idle "$percent" 0 + notify_critical "Battery guard cancelled because AC power is available." + fi + else + should_guard=false + if $discharging; then + if (( percent <= THRESHOLD_PERCENT )); then + should_guard=true + elif (( seconds_to_empty > 0 && seconds_to_empty <= TIMELEFT_TRIGGER_SECONDS )); then + should_guard=true + fi + fi + + if $should_guard; then + if ! $countdown_active; then + countdown_active=true + countdown_remaining=$COUNTDOWN_SECONDS + countdown_deadline=$(($(date +%s) + COUNTDOWN_SECONDS)) + write_state countdown "$percent" "$countdown_remaining" "$countdown_deadline" + if (( EUID != 0 )); then + timeout 2s omarchy-hook battery-guard "$percent" "$countdown_remaining" || true + fi + notify_critical "Battery low. Emergency shutdown in ${countdown_remaining}s unless AC power arrives." + else + countdown_remaining=$((countdown_deadline - $(date +%s))) + if (( countdown_remaining < 0 )); then + countdown_remaining=0 + fi + + write_state countdown "$percent" "$countdown_remaining" "$countdown_deadline" + if (( countdown_remaining > 0 && countdown_remaining % 10 == 0 )); then + notify_critical "Battery guard: ${countdown_remaining}s remain before protected shutdown." + fi + + if (( countdown_remaining <= 0 )); then + if find_ac_online; then + countdown_active=false + countdown_remaining=$COUNTDOWN_SECONDS + write_state idle "$percent" 0 + notify_critical "Battery guard cancelled because AC power is available." + else + countdown_active=false + run_action_once + case "$action_result" in + cancelled) + countdown_remaining=$COUNTDOWN_SECONDS + write_state idle "$percent" 0 + notify_critical "Battery guard cancelled because AC power is available." + ;; + resumed) + clear_boot_marker + countdown_remaining=$COUNTDOWN_SECONDS + write_state idle "$percent" 0 + notify_critical "Battery guard restored this session from hibernation." + ;; + dry-run) + countdown_remaining=$COUNTDOWN_SECONDS + write_state idle "$percent" 0 + ;; + poweroff) + write_state actioned "$percent" 0 + exit 0 + ;; + esac + fi + fi + fi + elif $countdown_active; then + countdown_active=false + countdown_remaining=$COUNTDOWN_SECONDS + write_state idle "$percent" 0 + fi + fi + + cycle=$((cycle + 1)) + if $oneshot && (( cycle >= MAX_ONESHOT_CYCLES )); then + exit 0 + fi + + sleep "$POLL_INTERVAL" +done diff --git a/default/systemd/system/omarchy-battery-guard.service b/default/systemd/system/omarchy-battery-guard.service new file mode 100644 index 00000000000..ab11aa45ec1 --- /dev/null +++ b/default/systemd/system/omarchy-battery-guard.service @@ -0,0 +1,15 @@ +[Unit] +Description=Protect the session from 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 diff --git a/install/config/enable-services.sh b/install/config/enable-services.sh index 964f08675f9..b526995347c 100644 --- a/install/config/enable-services.sh +++ b/install/config/enable-services.sh @@ -16,3 +16,8 @@ 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 + +# Keep the low-battery cutoff guard alive before login and after logout. The +# packaged default tree is root-owned, so it is safe to link as a system unit. +systemctl link --force /usr/share/omarchy/default/systemd/system/omarchy-battery-guard.service +systemctl enable omarchy-battery-guard.service diff --git a/manual/36-system-sleep.md b/manual/36-system-sleep.md index df6c8174518..6ffeb9ac7d7 100644 --- a/manual/36-system-sleep.md +++ b/manual/36-system-sleep.md @@ -17,3 +17,9 @@ 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 from system startup, including before login and after logout. At 10% remaining, or when the battery reports no more than 90 seconds left, it gives you one minute to connect power. Connecting power cancels the countdown immediately. If power is still absent, Omarchy hibernates when configured hibernation support is available and otherwise performs an orderly shutdown before the battery reaches its hardware cutoff. + +Hibernation preserves the running session across the protection event. Omarchy Mac currently does not offer hibernation on Apple Silicon, so its safe fallback closes the session during shutdown; applications can restore only the state they saved themselves. Apple Silicon Mac laptops normally start when power is connected unless that firmware behavior has been disabled. diff --git a/migrations/1789066533.sh b/migrations/1789066533.sh new file mode 100644 index 00000000000..093f2981593 --- /dev/null +++ b/migrations/1789066533.sh @@ -0,0 +1,12 @@ +echo "Enable battery guard service for existing installs" + +unit_name="omarchy-battery-guard.service" +unit_source="/usr/share/omarchy/default/systemd/system/$unit_name" + +if systemctl is-enabled --quiet "$unit_name" 2>/dev/null && systemctl is-active --quiet "$unit_name" 2>/dev/null; then + exit 0 +fi + +sudo systemctl link --force "$unit_source" +sudo systemctl daemon-reload +sudo systemctl enable --now "$unit_name" diff --git a/test/shell.d/battery-guard-test.sh b/test/shell.d/battery-guard-test.sh new file mode 100644 index 00000000000..cf5b7e26421 --- /dev/null +++ b/test/shell.d/battery-guard-test.sh @@ -0,0 +1,164 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT + +power_dir="$tmp_dir/power" +state_dir="$tmp_dir/state" +export HOME="$tmp_dir/home" + +mkdir -p "$power_dir/BAT0" "$power_dir/AC" "$state_dir" "$HOME" + +empty_power_dir="$tmp_dir/empty-power" +empty_state_dir="$tmp_dir/empty-state" +mkdir -p "$empty_power_dir" +OMARCHY_POWER_SUPPLY_PATH="$empty_power_dir" OMARCHY_BATTERY_GUARD_STATE_DIR="$empty_state_dir" \ + OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=1 "$ROOT/bin/omarchy-battery-guard" --oneshot +[[ ! -e $empty_state_dir/state ]] || fail "a machine without a battery must not receive periodic state writes" + +cat >"$power_dir/BAT0/type" <<'EOF' +Battery +EOF +printf '%s\n' 1 >"$power_dir/BAT0/present" +printf '%s\n' 5 >"$power_dir/BAT0/capacity" +printf '%s\n' Discharging >"$power_dir/BAT0/status" +printf '%s\n' 1000000 >"$power_dir/BAT0/power_now" +printf '%s\n' 5000 >"$power_dir/BAT0/energy_now" + +export OMARCHY_POWER_SUPPLY_PATH="$power_dir" +export OMARCHY_BATTERY_GUARD_STATE_DIR="$state_dir" +export OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS=2 +export OMARCHY_BATTERY_GUARD_POLL_INTERVAL=1 +export OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=4 +export OMARCHY_BATTERY_GUARD_THRESHOLD_PERCENT=10 +export OMARCHY_BATTERY_GUARD_DRY_RUN=true + +"$ROOT/bin/omarchy-battery-guard" --oneshot + +action_file="$state_dir/boot-preserve-required" +[[ -f $action_file ]] || fail "battery guard marks protective action when critical" + +grep -F 'mode=' "$state_dir/state" >/dev/null || fail "battery guard writes runtime mode" +grep -F 'percentage=' "$state_dir/state" >/dev/null || fail "battery guard writes battery percentage into state" + +printf '%s\n' Mains >"$power_dir/AC/type" +printf '%s\n' 1 >"$power_dir/AC/online" +rm -f "$action_file" + +"$ROOT/bin/omarchy-battery-guard" --oneshot +[[ ! -f $action_file ]] || fail "battery guard does not mark action when AC is present" + +cat >"$state_dir/state" </dev/null || fail "battery guard clears pending countdown after plug-in on boot" + +printf '%s\n' 0 >"$power_dir/AC/online" +printf '%s\n' 50 >"$power_dir/BAT0/capacity" +rm -f "$power_dir/BAT0/energy_now" "$power_dir/BAT0/power_now" "$action_file" +printf '%s\n' 5000000 >"$power_dir/BAT0/charge_now" +printf '%s\n' 1000 >"$power_dir/BAT0/current_now" +printf '%s\n' 1000000 >"$power_dir/BAT0/voltage_now" + +"$ROOT/bin/omarchy-battery-guard" --oneshot +[[ ! -f $action_file ]] || fail "charge and current units do not create a false time-to-empty action" + +injected_file="$tmp_dir/state-was-executed" +printf 'mode=$(touch %s)\nremaining=5\n' "$injected_file" >"$state_dir/state" +printf '%s\n' 1 >"$power_dir/AC/online" + +"$ROOT/bin/omarchy-battery-guard" --oneshot +[[ ! -e $injected_file ]] || fail "battery guard must treat persisted state as data" + +mock_bin="$tmp_dir/mock-bin" +migration_home="$tmp_dir/migration-home" +systemctl_log="$tmp_dir/systemctl.log" +mkdir -p "$mock_bin" "$migration_home" + +cat >"$mock_bin/systemctl" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >>"$SYSTEMCTL_LOG" + +if [[ $1 == "is-enabled" ]]; then + [[ ${SYSTEMCTL_ENABLED:-false} == "true" ]] +elif [[ $1 == "is-active" ]]; then + [[ ${SYSTEMCTL_ACTIVE:-false} == "true" ]] +elif [[ $1 == "hibernate" ]]; then + [[ ${SYSTEMCTL_HIBERNATE_SUCCESS:-false} == "true" ]] +elif [[ $1 == "poweroff" ]]; then + exit 1 +fi +EOF +chmod +x "$mock_bin/systemctl" + +cat >"$mock_bin/sudo" <<'EOF' +#!/bin/bash +exec "$@" +EOF + +cat >"$mock_bin/omarchy-hibernation-available" <<'EOF' +#!/bin/bash +if [[ -n ${PLUG_AC_ON_CHECK:-} ]]; then + printf '%s\n' 1 >"$PLUG_AC_ON_CHECK" +fi +[[ ${HIBERNATION_AVAILABLE:-false} == "true" ]] +EOF +cat >"$mock_bin/omarchy-hook" <<'EOF' +#!/bin/bash +exit 0 +EOF +cat >"$mock_bin/omarchy-notification-send" <<'EOF' +#!/bin/bash +exit 0 +EOF +chmod +x "$mock_bin/sudo" "$mock_bin/omarchy-hibernation-available" "$mock_bin/omarchy-hook" "$mock_bin/omarchy-notification-send" + +printf '%s\n' 0 >"$power_dir/AC/online" +printf '%s\n' 5 >"$power_dir/BAT0/capacity" +rm -f "$state_dir/state" "$action_file" +: >"$systemctl_log" +PATH="$mock_bin:$PATH" SYSTEMCTL_LOG="$systemctl_log" PLUG_AC_ON_CHECK="$power_dir/AC/online" \ + OMARCHY_BATTERY_GUARD_DRY_RUN=false OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS=1 \ + OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=3 "$ROOT/bin/omarchy-battery-guard" --oneshot +! grep -Fx -- 'poweroff --no-wall' "$systemctl_log" >/dev/null || fail "AC arriving at the action boundary must cancel poweroff" + +: >"$systemctl_log" +printf '%s\n' 0 >"$power_dir/AC/online" +rm -f "$state_dir/state" "$action_file" +if PATH="$mock_bin:$PATH" SYSTEMCTL_LOG="$systemctl_log" OMARCHY_BATTERY_GUARD_DRY_RUN=false \ + OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS=1 OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=3 \ + "$ROOT/bin/omarchy-battery-guard" --oneshot; then + fail "battery guard must propagate a failed poweroff" +fi +grep -Fx -- 'poweroff --no-wall' "$systemctl_log" >/dev/null || fail "battery guard attempts poweroff directly" + +: >"$systemctl_log" +rm -f "$state_dir/state" "$action_file" +PATH="$mock_bin:$PATH" SYSTEMCTL_LOG="$systemctl_log" HIBERNATION_AVAILABLE=true SYSTEMCTL_HIBERNATE_SUCCESS=true \ + OMARCHY_BATTERY_GUARD_DRY_RUN=false OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS=1 \ + OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=3 "$ROOT/bin/omarchy-battery-guard" --oneshot +grep -Fx -- 'hibernate --no-wall' "$systemctl_log" >/dev/null || fail "battery guard waits for hibernation to return" +! grep -Fx -- 'poweroff --no-wall' "$systemctl_log" >/dev/null || fail "successful hibernation must not fall through to poweroff" +[[ ! -f $action_file ]] || fail "resumed hibernation clears the recovery marker" + +HOME="$migration_home" PATH="$mock_bin:$PATH" SYSTEMCTL_LOG="$systemctl_log" \ + bash -euo pipefail "$ROOT/migrations/1789066533.sh" >/dev/null +grep -Fx -- 'link --force /usr/share/omarchy/default/systemd/system/omarchy-battery-guard.service' "$systemctl_log" >/dev/null || fail "migration links the packaged system unit" +grep -Fx -- 'daemon-reload' "$systemctl_log" >/dev/null || fail "migration reloads the system manager" +grep -Fx -- 'enable --now omarchy-battery-guard.service' "$systemctl_log" >/dev/null || fail "migration enables and starts the system guard" + +: >"$systemctl_log" +HOME="$migration_home" PATH="$mock_bin:$PATH" SYSTEMCTL_LOG="$systemctl_log" SYSTEMCTL_ENABLED=true SYSTEMCTL_ACTIVE=true \ + bash -euo pipefail "$ROOT/migrations/1789066533.sh" >/dev/null +[[ $(wc -l <"$systemctl_log") == 2 ]] || fail "migration is idempotent once the system guard is running" + +pass "battery guard reacts safely in mocked low battery scenarios" diff --git a/test/shell.d/systemd-test.sh b/test/shell.d/systemd-test.sh index 8e876fe8a8f..710cbabfb4d 100755 --- a/test/shell.d/systemd-test.sh +++ b/test/shell.d/systemd-test.sh @@ -31,6 +31,26 @@ grep -Fx 'systemctl --user daemon-reload' "$first_run_units" >/dev/null grep -F 'omarchy-sleep-lock.service' "$first_run_units" >/dev/null pass "first-run reloads and enables the sleep lock service" +battery_guard_service="$ROOT/default/systemd/system/omarchy-battery-guard.service" +grep -Fx 'ExecStart=/usr/bin/omarchy-battery-guard' "$battery_guard_service" >/dev/null +grep -Fx 'WantedBy=multi-user.target' "$battery_guard_service" >/dev/null +grep -Fx 'StateDirectory=omarchy-battery-guard' "$battery_guard_service" >/dev/null +pass "battery guard service follows the system lifetime" + +grep -F 'systemctl hibernate --no-wall' "$ROOT/bin/omarchy-battery-guard" >/dev/null +grep -F 'systemctl poweroff --no-wall' "$ROOT/bin/omarchy-battery-guard" >/dev/null +! grep -F 'systemd-run' "$ROOT/bin/omarchy-battery-guard" >/dev/null +pass "battery guard observes power action results directly" + +grep -F 'while read -r uid user _' "$ROOT/bin/omarchy-battery-guard" >/dev/null +grep -F 'timeout 2s runuser -u "$user"' "$ROOT/bin/omarchy-battery-guard" >/dev/null +pass "system battery warnings target logged-in users with a deadline" + +enable_services="$ROOT/install/config/enable-services.sh" +grep -F 'systemctl link --force /usr/share/omarchy/default/systemd/system/omarchy-battery-guard.service' "$enable_services" >/dev/null +grep -F 'systemctl enable omarchy-battery-guard.service' "$enable_services" >/dev/null +pass "system setup enables the battery guard service" + als_kbd_service="$ROOT/default/systemd/user/omarchy-brightness-keyboard-auto.service" grep -Fx 'ExecStart=/usr/bin/omarchy-brightness-keyboard-auto' "$als_kbd_service" >/dev/null grep -Fx 'ExecCondition=/usr/bin/omarchy-brightness-keyboard-auto --available' "$als_kbd_service" >/dev/null From 5e4f0e959c2e4545473512efd818be8df39ea3c4 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Fri, 11 Sep 2026 02:58:10 +0530 Subject: [PATCH 2/2] Refine battery reserve display and graceful shutdown --- bin/omarchy-battery-guard | 457 ++++++++---------- bin/omarchy-battery-low | 16 +- bin/omarchy-battery-status | 13 +- default/battery-guard/close-windows | 58 +++ .../system/omarchy-battery-guard.service | 2 +- install/config/enable-services.sh | 6 +- install/helpers/battery-guard.sh | 57 +++ manual/36-system-sleep.md | 8 +- migrations/1789066533.sh | 34 +- migrations/1789068803.sh | 30 ++ shell/plugins/panels/power/Model.js | 9 +- shell/plugins/panels/power/Panel.qml | 7 +- .../plugins/services/battery/BatteryModel.js | 10 +- shell/plugins/services/battery/Service.qml | 2 + test/shell.d/battery-guard-close-test.sh | 67 +++ test/shell.d/battery-guard-deployment-test.sh | 57 +++ test/shell.d/battery-guard-test.sh | 359 ++++++++------ test/shell.d/battery-status-test.sh | 6 +- test/shell.d/battery-test.sh | 2 +- test/shell.d/battery-usable-test.sh | 63 +++ test/shell.d/systemd-test.sh | 6 +- 21 files changed, 832 insertions(+), 437 deletions(-) create mode 100755 default/battery-guard/close-windows create mode 100644 install/helpers/battery-guard.sh create mode 100644 migrations/1789068803.sh create mode 100644 test/shell.d/battery-guard-close-test.sh create mode 100644 test/shell.d/battery-guard-deployment-test.sh create mode 100644 test/shell.d/battery-usable-test.sh diff --git a/bin/omarchy-battery-guard b/bin/omarchy-battery-guard index 5cabbecaa33..582c8c9dcda 100755 --- a/bin/omarchy-battery-guard +++ b/bin/omarchy-battery-guard @@ -1,6 +1,6 @@ #!/bin/bash -# omarchy:summary=Monitor battery discharge and prevent hard cutoff +# omarchy:summary=Warn and shut down gracefully before battery cutoff # omarchy:group=battery # omarchy:args=[--oneshot] # omarchy:examples=omarchy battery guard @@ -15,14 +15,14 @@ else STATE_ROOT="${OMARCHY_BATTERY_GUARD_STATE_DIR:-$HOME/.local/state/omarchy/battery-guard}" fi STATE_FILE="$STATE_ROOT/state" -BOOT_MARKER_FILE="$STATE_ROOT/boot-preserve-required" POLL_INTERVAL="${OMARCHY_BATTERY_GUARD_POLL_INTERVAL:-1}" COUNTDOWN_SECONDS="${OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS:-60}" -THRESHOLD_PERCENT="${OMARCHY_BATTERY_GUARD_THRESHOLD_PERCENT:-10}" +THRESHOLD_PERCENT="${OMARCHY_BATTERY_GUARD_THRESHOLD_PERCENT:-5}" TIMELEFT_TRIGGER_SECONDS="${OMARCHY_BATTERY_GUARD_TIMELEFT_TRIGGER_SECONDS:-90}" MAX_ONESHOT_CYCLES="${OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES:-1}" DRY_RUN="${OMARCHY_BATTERY_GUARD_DRY_RUN:-false}" +CLOCK_PATH="${OMARCHY_BATTERY_GUARD_CLOCK_PATH:-/proc/uptime}" normalize_int() { local value="$1" @@ -47,7 +47,7 @@ normalize_int() { POLL_INTERVAL=$(normalize_int "$POLL_INTERVAL" 1 1) COUNTDOWN_SECONDS=$(normalize_int "$COUNTDOWN_SECONDS" 60 1) -THRESHOLD_PERCENT=$(normalize_int "$THRESHOLD_PERCENT" 10 0 100) +THRESHOLD_PERCENT=$(normalize_int "$THRESHOLD_PERCENT" 5 0 100) TIMELEFT_TRIGGER_SECONDS=$(normalize_int "$TIMELEFT_TRIGGER_SECONDS" 90 1) MAX_ONESHOT_CYCLES=$(normalize_int "$MAX_ONESHOT_CYCLES" 1 1) @@ -94,6 +94,7 @@ find_battery_supply() { fi [[ $(<"$path/type") == "Battery" ]] || continue + [[ ! -r $path/scope || $(<"$path/scope") != "Device" ]] || continue echo "$path" return 0 done @@ -214,291 +215,259 @@ read_battery_state() { return 0 } -notify_critical() { - local message="$1" - local delivered=false - local runtime_dir - local uid - local user - - if (( EUID != 0 )); then - omarchy-notification-send -i battery-caution -u critical "$message" >/dev/null 2>&1 || true - return 0 +# Notification IDs belong to each user's notification server, never to root. +declare -A notification_ids=() +declare -A close_plans=() close_done=() close_complete=() + +session_users() { + if (( EUID == 0 )); then + loginctl list-users --no-legend 2>/dev/null || true + else + printf '%s %s\n' "$EUID" "$(id -un)" + fi +} + +as_session_user() { + local uid="$1" user="$2" + shift 2 + if (( EUID == 0 )); then + timeout 2s runuser -u "$user" -- env -i \ + PATH=/usr/bin:/bin USER="$user" LOGNAME="$user" \ + XDG_RUNTIME_DIR="/run/user/$uid" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" "$@" 9>&- + else + timeout 2s "$@" 9>&- fi +} +notify_critical() { + local message="$1" lifetime="${2:-0}" + local uid user result id while read -r uid user _; do - [[ $uid =~ ^[0-9]+$ ]] || continue + [[ $uid =~ ^[0-9]+$ && -n $user ]] || continue (( uid >= 1000 )) || continue - runtime_dir="/run/user/$uid" - [[ -S $runtime_dir/bus ]] || continue - - if timeout 2s runuser -u "$user" -- env \ - XDG_RUNTIME_DIR="$runtime_dir" \ - DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime_dir/bus" \ - /usr/bin/omarchy-notification-send -i battery-caution -u critical "$message" >/dev/null 2>&1; then - delivered=true + id=${notification_ids[$uid]:-0} + if result=$(as_session_user "$uid" "$user" omarchy-notification-send \ + --app-name omarchy-battery-guard -i battery-caution -u critical \ + -p -r "$id" -t "$lifetime" "Battery protection" "$message" 2>/dev/null); then + if [[ $result =~ ^[0-9]+$ ]]; then + notification_ids[$uid]=$result + fi fi - done < <(loginctl list-users --no-legend 2>/dev/null || true) - - if ! $delivered; then - timeout 2s wall -n "$message" >/dev/null 2>&1 || true - fi + done < <(session_users) } -mark_boot_recovery() { - local marker_tmp - - mkdir -p "$STATE_ROOT" - chmod 700 "$STATE_ROOT" - marker_tmp=$(mktemp --tmpdir="$STATE_ROOT" .boot-preserve-required.XXXXXX) - printf '%s\n' "protected-action" >"$marker_tmp" - mv -f "$marker_tmp" "$BOOT_MARKER_FILE" +# An online adapter may be too weak to stop discharge. Re-read the status at +# every action boundary, rather than relying on the preceding polling sample. +power_recovered() { + find_ac_online || return 1 + [[ -r $battery_path/status ]] || return 1 + case $(<"$battery_path/status") in + Charging|Full) return 0 ;; + *) return 1 ;; + esac } -clear_boot_marker() { - [[ -f $BOOT_MARKER_FILE ]] || return 0 - rm -f "$BOOT_MARKER_FILE" +request_window_close() { + local uid user output signature pid wayland address extra plan valid result key + [[ $DRY_RUN != "true" ]] || return 0 + while read -r uid user _; do + [[ $uid =~ ^[0-9]+$ && -n $user ]] || continue + (( uid >= 1000 )) || continue + power_recovered && return 0 + (( $(now_seconds) + 2 < countdown_deadline )) || return 0 + if [[ ${close_complete[$uid]:-false} != "true" ]]; then + # Snapshot is read-only. A failed/partial result cannot dispatch anything. + if ! output=$(as_session_user "$uid" "$user" /bin/bash /usr/share/omarchy/default/battery-guard/close-windows \ + "$battery_path" --snapshot 2>/dev/null); then + continue + fi + plan="" valid=true + while read -r signature pid wayland address extra; do + [[ -n $signature ]] || continue + if [[ $signature =~ ^[a-zA-Z0-9_-]+$ && $pid =~ ^[0-9]+$ && $wayland =~ ^wayland-[0-9]+$ && $address =~ ^0x[0-9a-fA-F]+$ && -z $extra ]]; then + plan+="$signature $pid $wayland $address"$'\n' + else + valid=false + fi + done <<<"$output" + $valid || continue + close_plans[$uid]=$plan + # This sentinel also preserves an empty snapshot across daemon restarts. + close_complete[$uid]=true + write_state countdown + fi + while read -r signature pid wayland address; do + [[ -n $signature ]] || continue + key="$signature $address" + if grep -Fxq -- "$key" <<<"${close_done[$uid]:-}"; then continue; fi + power_recovered && return 0 + (( $(now_seconds) + 2 < countdown_deadline )) || return 0 + # Persist before any possible dispatch. If the daemon or RPC dies now, + # restarting must not resend a request that may already show a save dialog. + close_done[$uid]+="$key"$'\n' + close_requested=true + write_state countdown + result=0 + as_session_user "$uid" "$user" /bin/bash /usr/share/omarchy/default/battery-guard/close-windows \ + "$battery_path" --close-one "$signature" "$pid" "$wayland" "$address" >/dev/null 2>&1 || result=$? + # Only a definite rejection is retryable. Timeout/transport failures have + # an uncertain outcome and retain their persisted attempt marker. + if (( result == 2 )); then + close_done[$uid]=$(grep -Fxv -- "$key" <<<"${close_done[$uid]}") || true + [[ -z ${close_done[$uid]} ]] || close_done[$uid]+=$'\n' + write_state countdown + fi + done <<<"${close_plans[$uid]:-}" + done < <(session_users) } write_state() { - local mode="$1" - local percent_value="$2" - local remaining_value="$3" - local deadline_value="${4:-0}" - local state_tmp - - mkdir -p "$STATE_ROOT" - chmod 700 "$STATE_ROOT" + local mode="$1" state_tmp uid line state_tmp=$(mktemp --tmpdir="$STATE_ROOT" .state.XXXXXX) - printf 'mode=%s\npercentage=%s\nremaining=%s\ndeadline=%s\nupdated=%s\n' \ - "$mode" "$percent_value" "$remaining_value" "$deadline_value" "$(date +%s)" >"$state_tmp" + printf 'mode=%s\ndeadline=%s\nboot=%s\nclosed=%s\n' \ + "$mode" "$countdown_deadline" "$boot_id" "$close_requested" >"$state_tmp" + for uid in "${!close_plans[@]}"; do + while read -r line; do + [[ -z $line ]] || printf 'plan:%s=%s\n' "$uid" "$line" >>"$state_tmp" + done <<<"${close_plans[$uid]}" + done + for uid in "${!close_complete[@]}"; do + [[ ${close_complete[$uid]} != "true" ]] || printf 'snapshot:%s=1\n' "$uid" >>"$state_tmp" + done + for uid in "${!close_done[@]}"; do + while read -r line; do + [[ -z $line ]] || printf 'done:%s=%s\n' "$uid" "$line" >>"$state_tmp" + done <<<"${close_done[$uid]}" + done mv -f "$state_tmp" "$STATE_FILE" } -read_persisted_countdown() { - local key - local value - local persisted_mode="" - local persisted_remaining="" - local persisted_deadline="" - local now - - [[ -r $STATE_FILE ]] || return 1 +# Deadlines use uptime: wall-clock adjustments must not extend the save window. +now_seconds() { + local uptime _ + read -r uptime _ <"$CLOCK_PATH" + printf '%s\n' "${uptime%%.*}" +} +read_persisted_countdown() { + local key value mode="" deadline="" boot="" closed="false" + [[ -r $STATE_FILE ]] || return 0 while IFS='=' read -r key value; do case "$key" in - mode) persisted_mode=$value ;; - remaining) persisted_remaining=$value ;; - deadline) persisted_deadline=$value ;; + mode) mode=$value ;; + deadline) deadline=$value ;; + boot) boot=$value ;; + closed) closed=$value ;; + snapshot:*) + [[ ${key#*:} =~ ^[0-9]+$ && $value == "1" ]] || continue + close_complete[${key#*:}]=true + ;; + plan:*) + [[ ${key#*:} =~ ^[0-9]+$ ]] || continue + close_plans[${key#*:}]+="$value"$'\n' + ;; + done:*) + [[ ${key#*:} =~ ^[0-9]+$ ]] || continue + close_done[${key#*:}]+="$value"$'\n' + ;; esac done <"$STATE_FILE" - - [[ $persisted_mode == "countdown" ]] || return 1 - [[ $persisted_remaining =~ ^[0-9]+$ ]] || return 1 - - if [[ $persisted_deadline =~ ^[0-9]+$ ]]; then - countdown_deadline=$persisted_deadline - now=$(date +%s) - countdown_remaining=$((countdown_deadline - now)) - if (( countdown_remaining < 0 )); then - countdown_remaining=0 - elif (( countdown_remaining > COUNTDOWN_SECONDS )); then - countdown_remaining=$COUNTDOWN_SECONDS - countdown_deadline=$((now + countdown_remaining)) - fi + if [[ $mode == "countdown" && $boot == "$boot_id" && $deadline =~ ^[0-9]{1,10}$ ]]; then + countdown_deadline=$deadline + countdown_active=true + [[ $closed != "true" ]] || close_requested=true + # Replacement IDs are intentionally not reused across a daemon/server restart. else - countdown_remaining=$persisted_remaining - if (( countdown_remaining > COUNTDOWN_SECONDS )); then - countdown_remaining=$COUNTDOWN_SECONDS - fi - countdown_deadline=$(($(date +%s) + countdown_remaining)) + close_plans=() close_done=() close_complete=() fi - - countdown_active=true - return 0 } -run_action_once() { - local action="poweroff" - - if find_ac_online; then - clear_boot_marker - action_result="cancelled" - return 0 - fi - - if omarchy-hibernation-available >/dev/null 2>&1; then - action="hibernate" - fi - - notify_critical "Battery guard activated. Preserving session where possible." - - if [[ $DRY_RUN == "true" ]]; then - mark_boot_recovery - action_result="dry-run" - return 0 - fi - - if [[ $action == "hibernate" ]]; then - if find_ac_online; then - clear_boot_marker - action_result="cancelled" - return 0 - fi - - mark_boot_recovery - if systemctl hibernate --no-wall; then - action_result="resumed" - return 0 - fi - - if find_ac_online; then - clear_boot_marker - action_result="cancelled" - return 0 - fi - fi - - if find_ac_online; then - clear_boot_marker - action_result="cancelled" - return 0 +cancel_countdown() { + local apps_were_asked_to_close=$close_requested + countdown_active=false + close_plans=() close_done=() close_complete=() + close_requested=false + countdown_deadline=0 + write_state idle + if $apps_were_asked_to_close; then + notify_critical "Charging resumed. Shutdown cancelled. Reopen any closed apps." 5000 + else + notify_critical "Charging resumed. Shutdown cancelled." 5000 fi - - mark_boot_recovery - systemctl poweroff --no-wall - action_result="poweroff" } -emit_boot_recovery_notice() { - if [[ ! -f $BOOT_MARKER_FILE ]]; then - return +run_action_once() { + power_recovered && { cancel_countdown; return; } + notify_critical "Shutting down…" + power_recovered && { cancel_countdown; return; } + if [[ $DRY_RUN == "true" ]]; then + write_state dry-run + else + # The regular shutdown transaction gives services their normal stop timeout + # and unmounts filesystems. Never use --force or terminate app PIDs here. + systemctl poweroff --no-wall + write_state actioned fi - - notify_critical "Battery guard previously took protective action. The session was restored only if hibernation completed." - - clear_boot_marker -} - -if ! is_battery_monitor_running; then - clear_boot_marker exit 0 -fi - -emit_boot_recovery_notice +} +[[ -d $POWER_SUPPLY_PATH ]] || exit 0 +boot_id=$(= MAX_ONESHOT_CYCLES )); then - exit 0 - fi - continue - fi - - if ! read_battery_state "$battery_path"; then - countdown_active=false - sleep "$POLL_INTERVAL" - cycle=$((cycle + 1)) - if $oneshot && (( cycle >= MAX_ONESHOT_CYCLES )); then - exit 0 - fi - continue - fi - - if find_ac_online; then - if $countdown_active; then - countdown_active=false - countdown_remaining=$COUNTDOWN_SECONDS - write_state idle "$percent" 0 - notify_critical "Battery guard cancelled because AC power is available." - fi - else - should_guard=false - if $discharging; then - if (( percent <= THRESHOLD_PERCENT )); then - should_guard=true - elif (( seconds_to_empty > 0 && seconds_to_empty <= TIMELEFT_TRIGGER_SECONDS )); then - should_guard=true - fi - fi - - if $should_guard; then - if ! $countdown_active; then + if [[ -n $battery_path ]] && read_battery_state "$battery_path"; then + if power_recovered; then + if $countdown_active; then cancel_countdown; fi + elif $discharging || $countdown_active; then + now=$(now_seconds) + if ! $countdown_active && (( percent <= THRESHOLD_PERCENT || (seconds_to_empty > 0 && seconds_to_empty <= TIMELEFT_TRIGGER_SECONDS) )); then countdown_active=true - countdown_remaining=$COUNTDOWN_SECONDS - countdown_deadline=$(($(date +%s) + COUNTDOWN_SECONDS)) - write_state countdown "$percent" "$countdown_remaining" "$countdown_deadline" - if (( EUID != 0 )); then - timeout 2s omarchy-hook battery-guard "$percent" "$countdown_remaining" || true - fi - notify_critical "Battery low. Emergency shutdown in ${countdown_remaining}s unless AC power arrives." - else - countdown_remaining=$((countdown_deadline - $(date +%s))) - if (( countdown_remaining < 0 )); then - countdown_remaining=0 - fi - - write_state countdown "$percent" "$countdown_remaining" "$countdown_deadline" - if (( countdown_remaining > 0 && countdown_remaining % 10 == 0 )); then - notify_critical "Battery guard: ${countdown_remaining}s remain before protected shutdown." + close_requested=false + close_plans=() close_done=() close_complete=() + countdown_deadline=$((now + COUNTDOWN_SECONDS)) + last_notice=61 + fi + if $countdown_active; then + # Leave 30 seconds for the OS to stop services and flush storage. + # Runtime estimates can deteriorate; shorten, never extend a deadline. + if (( seconds_to_empty > 0 && now + seconds_to_empty - 30 < countdown_deadline )); then + countdown_deadline=$((now + seconds_to_empty - 30)) fi - - if (( countdown_remaining <= 0 )); then - if find_ac_online; then - countdown_active=false - countdown_remaining=$COUNTDOWN_SECONDS - write_state idle "$percent" 0 - notify_critical "Battery guard cancelled because AC power is available." + remaining=$((countdown_deadline - now)) + (( remaining >= 0 )) || remaining=0 + write_state countdown + if (( remaining == 0 )); then + run_action_once + elif (( last_notice == 61 || (remaining <= 30 && last_notice > 30) || (remaining <= 10 && last_notice > 10) )); then + if (( remaining <= 10 )); then + notify_critical "Finish any save dialogs. Shutdown in ${remaining}s." else - countdown_active=false - run_action_once - case "$action_result" in - cancelled) - countdown_remaining=$COUNTDOWN_SECONDS - write_state idle "$percent" 0 - notify_critical "Battery guard cancelled because AC power is available." - ;; - resumed) - clear_boot_marker - countdown_remaining=$COUNTDOWN_SECONDS - write_state idle "$percent" 0 - notify_critical "Battery guard restored this session from hibernation." - ;; - dry-run) - countdown_remaining=$COUNTDOWN_SECONDS - write_state idle "$percent" 0 - ;; - poweroff) - write_state actioned "$percent" 0 - exit 0 - ;; - esac + notify_critical "Save your work and connect your charger. Shutdown in ${remaining}s." fi + last_notice=$remaining + fi + # Notification delivery also consumes time; check the deadline again. + now=$(now_seconds) + remaining=$((countdown_deadline - now)) + if power_recovered; then + cancel_countdown + elif (( remaining <= 0 )); then + run_action_once + elif (( remaining <= 15 )); then + request_window_close fi fi - elif $countdown_active; then - countdown_active=false - countdown_remaining=$COUNTDOWN_SECONDS - write_state idle "$percent" 0 fi fi - cycle=$((cycle + 1)) - if $oneshot && (( cycle >= MAX_ONESHOT_CYCLES )); then - exit 0 - fi - + if $oneshot && (( cycle >= MAX_ONESHOT_CYCLES )); then exit 0; fi sleep "$POLL_INTERVAL" done diff --git a/bin/omarchy-battery-low b/bin/omarchy-battery-low index d107d4f8dc4..e701461c7e6 100755 --- a/bin/omarchy-battery-low +++ b/bin/omarchy-battery-low @@ -1,17 +1,25 @@ #!/bin/bash # omarchy:summary=Send the low battery warning notification and run battery-low hooks. -# omarchy:args= +# omarchy:args= [--quiet] # omarchy:hidden=true set -euo pipefail -if (($# != 1)); then - echo "Usage: omarchy-battery-low " >&2 +if (( $# < 1 || $# > 2 )) || [[ ${2:-} != "" && ${2:-} != "--quiet" ]]; then + echo "Usage: omarchy-battery-low [--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" diff --git a/bin/omarchy-battery-status b/bin/omarchy-battery-status index e4ccceac04f..acb3cbeeea5 100755 --- a/bin/omarchy-battery-status +++ b/bin/omarchy-battery-status @@ -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 diff --git a/default/battery-guard/close-windows b/default/battery-guard/close-windows new file mode 100755 index 00000000000..4b39ef37713 --- /dev/null +++ b/default/battery-guard/close-windows @@ -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 diff --git a/default/systemd/system/omarchy-battery-guard.service b/default/systemd/system/omarchy-battery-guard.service index ab11aa45ec1..14bd94c8622 100644 --- a/default/systemd/system/omarchy-battery-guard.service +++ b/default/systemd/system/omarchy-battery-guard.service @@ -1,5 +1,5 @@ [Unit] -Description=Protect the session from hard battery cutoff +Description=Graceful shutdown before hard battery cutoff ConditionPathExists=/sys/class/power_supply After=systemd-udevd.service diff --git a/install/config/enable-services.sh b/install/config/enable-services.sh index b526995347c..5a6c53f7e5e 100644 --- a/install/config/enable-services.sh +++ b/install/config/enable-services.sh @@ -17,7 +17,5 @@ systemctl enable sddm.service # is what the user manager reports app.slice candidacy over. systemctl enable systemd-oomd.service -# Keep the low-battery cutoff guard alive before login and after logout. The -# packaged default tree is root-owned, so it is safe to link as a system unit. -systemctl link --force /usr/share/omarchy/default/systemd/system/omarchy-battery-guard.service -systemctl enable omarchy-battery-guard.service +# Deploy the root-owned guard before enabling it; do not start it mid-install. +bash "$OMARCHY_INSTALL/helpers/battery-guard.sh" diff --git a/install/helpers/battery-guard.sh b/install/helpers/battery-guard.sh new file mode 100644 index 00000000000..62f4e73a1d3 --- /dev/null +++ b/install/helpers/battery-guard.sh @@ -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 diff --git a/manual/36-system-sleep.md b/manual/36-system-sleep.md index 6ffeb9ac7d7..28aa47124d3 100644 --- a/manual/36-system-sleep.md +++ b/manual/36-system-sleep.md @@ -20,6 +20,10 @@ When set up, you'll see the hibernate option under _System_ (or `Super + Esc`), ### Emergency battery protection -Omarchy watches a discharging laptop from system startup, including before login and after logout. At 10% remaining, or when the battery reports no more than 90 seconds left, it gives you one minute to connect power. Connecting power cancels the countdown immediately. If power is still absent, Omarchy hibernates when configured hibernation support is available and otherwise performs an orderly shutdown before the battery reaches its hardware cutoff. +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. -Hibernation preserves the running session across the protection event. Omarchy Mac currently does not offer hibernation on Apple Silicon, so its safe fallback closes the session during shutdown; applications can restore only the state they saved themselves. Apple Silicon Mac laptops normally start when power is connected unless that firmware behavior has been disabled. +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. diff --git a/migrations/1789066533.sh b/migrations/1789066533.sh index 093f2981593..7284d059dea 100644 --- a/migrations/1789066533.sh +++ b/migrations/1789066533.sh @@ -1,12 +1,30 @@ -echo "Enable battery guard service for existing installs" +echo "Deploy the shutdown-only battery guard from trusted packages" -unit_name="omarchy-battery-guard.service" -unit_source="/usr/share/omarchy/default/systemd/system/$unit_name" - -if systemctl is-enabled --quiet "$unit_name" 2>/dev/null && systemctl is-active --quiet "$unit_name" 2>/dev/null; then +# 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 -sudo systemctl link --force "$unit_source" -sudo systemctl daemon-reload -sudo systemctl enable --now "$unit_name" +# 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 +' diff --git a/migrations/1789068803.sh b/migrations/1789068803.sh new file mode 100644 index 00000000000..7284d059dea --- /dev/null +++ b/migrations/1789068803.sh @@ -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 +' diff --git a/shell/plugins/panels/power/Model.js b/shell/plugins/panels/power/Model.js index 8a11dfe7ee5..15b473eb422 100644 --- a/shell/plugins/panels/power/Model.js +++ b/shell/plugins/panels/power/Model.js @@ -46,7 +46,9 @@ 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) { @@ -54,7 +56,8 @@ function chargeThresholdActive(device, onBattery, states) { 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 @@ -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] diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index b1c34da26f2..d27d5b55c4b 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -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) @@ -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 diff --git a/shell/plugins/services/battery/BatteryModel.js b/shell/plugins/services/battery/BatteryModel.js index 2aca5dbd92b..e2990fa3ea8 100644 --- a/shell/plugins/services/battery/BatteryModel.js +++ b/shell/plugins/services/battery/BatteryModel.js @@ -1,6 +1,7 @@ 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) { @@ -8,12 +9,15 @@ function isDischarging(device, onBattery, 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 } diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml index 37fdf5c72f1..3968fed573f 100644 --- a/shell/plugins/services/battery/Service.qml +++ b/shell/plugins/services/battery/Service.qml @@ -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 } diff --git a/test/shell.d/battery-guard-close-test.sh b/test/shell.d/battery-guard-close-test.sh new file mode 100644 index 00000000000..7ce68515396 --- /dev/null +++ b/test/shell.d/battery-guard-close-test.sh @@ -0,0 +1,67 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +mkdir -p "$tmp_dir/bin" "$tmp_dir/runtime/hypr/fixture_1" "$tmp_dir/power/BAT0" "$tmp_dir/power/AC" +export XDG_RUNTIME_DIR="$tmp_dir/runtime" HELPER_EVENTS="$tmp_dir/events" FIXTURE_PID=$$ +export FIXTURE_POWER="$tmp_dir/power" PLUG_AFTER_CLOSE=false +export REJECT_FIRST=false +printf 'Discharging\n' >"$FIXTURE_POWER/BAT0/status" +printf '0\n' >"$FIXTURE_POWER/AC/online" +cat >"$tmp_dir/bin/hyprctl" <<'SH' +#!/bin/bash +if [[ $* == "-j instances" ]]; then + printf '[{"instance":"fixture_1","pid":%s,"wl_socket":"wayland-1"}]\n' "$FIXTURE_PID" +elif [[ $* == "-j clients" ]]; then + printf '[{"address":"0xabc"},{"address":"0xdef"},{"address":"invalid;code"}]\n' +else + printf '%s|%s|%s\n' "$HYPRLAND_INSTANCE_SIGNATURE" "$WAYLAND_DISPLAY" "$*" >>"$HELPER_EVENTS" + if [[ $REJECT_FIRST == "true" && $* == *"0xabc"* ]]; then + printf 'error: temporary rejection\n' + exit 0 + fi + if [[ $PLUG_AFTER_CLOSE == "true" ]]; then + printf '1\n' >"$FIXTURE_POWER/AC/online" + printf 'Charging\n' >"$FIXTURE_POWER/BAT0/status" + fi +fi +SH +chmod +x "$tmp_dir/bin/hyprctl" +if python3 - "$tmp_dir" <<'PY' +import os, socket, sys +os.chdir(sys.argv[1]) +try: + with socket.socket(socket.AF_UNIX) as s: + # A relative bind path avoids Linux's 108-byte AF_UNIX pathname limit. + s.bind('runtime/hypr/fixture_1/.socket.sock') +except PermissionError: + sys.exit(77) +PY +then + : +else + result=$? + if (( result == 77 )); then + printf 'ok - SKIP helper socket fixture denied by sandbox\n' + exit 0 + fi + fail "helper socket fixture creation failed" "$result" +fi +plan=$(PATH="$tmp_dir/bin:$PATH" bash "$ROOT/default/battery-guard/close-windows" "$FIXTURE_POWER/BAT0" --snapshot) +[[ ! -s $HELPER_EVENTS ]] || fail "snapshot must never close any window" +[[ $(wc -l <<<"$plan") == 2 ]] || fail "snapshot includes only validated original windows" +while read -r signature pid wayland address; do + PATH="$tmp_dir/bin:$PATH" bash "$ROOT/default/battery-guard/close-windows" "$FIXTURE_POWER/BAT0" --close-one "$signature" "$pid" "$wayland" "$address" +done <<<"$plan" +[[ $(wc -l <"$HELPER_EVENTS") == 2 ]] || fail "one invocation closes exactly one window" +grep -Fx 'fixture_1|wayland-1|dispatch hl.dsp.window.close({ window = "address:0xabc" })' "$HELPER_EVENTS" >/dev/null || fail "helper uses verified Lua API and instance environment" +result=0 +REJECT_FIRST=true PATH="$tmp_dir/bin:$PATH" bash "$ROOT/default/battery-guard/close-windows" "$FIXTURE_POWER/BAT0" --close-one fixture_1 "$FIXTURE_PID" wayland-1 0xabc || result=$? +[[ $result == 2 ]] || fail "explicit Hyprland rejection has retryable status" +: >"$HELPER_EVENTS" +PLUG_AFTER_CLOSE=true PATH="$tmp_dir/bin:$PATH" bash "$ROOT/default/battery-guard/close-windows" "$FIXTURE_POWER/BAT0" --close-one fixture_1 "$FIXTURE_PID" wayland-1 0xabc +result=0 +PATH="$tmp_dir/bin:$PATH" bash "$ROOT/default/battery-guard/close-windows" "$FIXTURE_POWER/BAT0" --close-one fixture_1 "$FIXTURE_PID" wayland-1 0xdef || result=$? +[[ $result == 3 && $(wc -l <"$HELPER_EVENTS") == 1 ]] || fail "AC aborts subsequent window requests" +pass "two-phase user helper snapshots without dispatch and closes only the requested window" diff --git a/test/shell.d/battery-guard-deployment-test.sh b/test/shell.d/battery-guard-deployment-test.sh new file mode 100644 index 00000000000..84a73eadc97 --- /dev/null +++ b/test/shell.d/battery-guard-deployment-test.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +mkdir -p "$tmp_dir/bin" + +# Exercise the production trust predicate without installing or invoking sudo. +sed -n '/^trusted_path() {$/,/^}$/p' "$ROOT/install/helpers/battery-guard.sh" >"$tmp_dir/trust.sh" +cat >"$tmp_dir/bin/realpath" <<'SH' +#!/bin/bash +printf '/usr/share/package/file\n' +SH +cat >"$tmp_dir/bin/stat" <<'SH' +#!/bin/bash +if [[ $* == *"/fixture" ]]; then + printf '%s %s\n' "$OWNER" "$MODE" +else + printf '0 755\n' +fi +SH +chmod +x "$tmp_dir/bin/"* +PATH="$tmp_dir/bin:$PATH" OWNER=0 MODE=755 bash -c 'source "$1"; trusted_path /fixture' _ "$tmp_dir/trust.sh" || fail "root-owned package accepted" +if PATH="$tmp_dir/bin:$PATH" OWNER=1000 MODE=755 bash -c 'source "$1"; trusted_path /fixture' _ "$tmp_dir/trust.sh"; then + fail "user-owned ancestor rejected" +fi +if PATH="$tmp_dir/bin:$PATH" OWNER=0 MODE=775 bash -c 'source "$1"; trusted_path /fixture' _ "$tmp_dir/trust.sh"; then + fail "group-writable ancestor rejected" +fi +for migration in 1789066533 1789068803; do + grep -F 'source_path=/usr/share/omarchy/install/helpers/battery-guard.sh' "$ROOT/migrations/$migration.sh" >/dev/null || fail "migration validates fixed packaged installer" + grep -F '8#$mode & 0022' "$ROOT/migrations/$migration.sh" >/dev/null || fail "migration checks trust before root execution" +done +cat >"$tmp_dir/bin/cmp" <<'SH' +#!/bin/bash +[[ -e $DEPLOYED ]] +SH +cat >"$tmp_dir/bin/systemctl" <<'SH' +#!/bin/bash +[[ -e $DEPLOYED ]] +SH +cat >"$tmp_dir/bin/sha256sum" <<'SH' +#!/bin/bash +[[ -e $DEPLOYED ]] +SH +cat >"$tmp_dir/bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo\n' >>"$DEPLOY_LOG" +touch "$DEPLOYED" +SH +chmod +x "$tmp_dir/bin/"* +export DEPLOYED="$tmp_dir/deployed" DEPLOY_LOG="$tmp_dir/deploy-log" +for migration in 1789066533 1789068803 1789068803; do + PATH="$tmp_dir/bin:$PATH" bash -euo pipefail "$ROOT/migrations/$migration.sh" >/dev/null +done +[[ $(wc -l <"$DEPLOY_LOG") == 1 ]] || fail "second migration and second user do not prompt again" +pass "deployment rejects writable package ancestry and covers previously migrated users" diff --git a/test/shell.d/battery-guard-test.sh b/test/shell.d/battery-guard-test.sh index cf5b7e26421..a202f86c8f3 100644 --- a/test/shell.d/battery-guard-test.sh +++ b/test/shell.d/battery-guard-test.sh @@ -1,164 +1,217 @@ #!/bin/bash - set -euo pipefail - source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT - -power_dir="$tmp_dir/power" -state_dir="$tmp_dir/state" -export HOME="$tmp_dir/home" - -mkdir -p "$power_dir/BAT0" "$power_dir/AC" "$state_dir" "$HOME" - -empty_power_dir="$tmp_dir/empty-power" -empty_state_dir="$tmp_dir/empty-state" -mkdir -p "$empty_power_dir" -OMARCHY_POWER_SUPPLY_PATH="$empty_power_dir" OMARCHY_BATTERY_GUARD_STATE_DIR="$empty_state_dir" \ - OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=1 "$ROOT/bin/omarchy-battery-guard" --oneshot -[[ ! -e $empty_state_dir/state ]] || fail "a machine without a battery must not receive periodic state writes" - -cat >"$power_dir/BAT0/type" <<'EOF' -Battery -EOF -printf '%s\n' 1 >"$power_dir/BAT0/present" -printf '%s\n' 5 >"$power_dir/BAT0/capacity" -printf '%s\n' Discharging >"$power_dir/BAT0/status" -printf '%s\n' 1000000 >"$power_dir/BAT0/power_now" -printf '%s\n' 5000 >"$power_dir/BAT0/energy_now" - -export OMARCHY_POWER_SUPPLY_PATH="$power_dir" -export OMARCHY_BATTERY_GUARD_STATE_DIR="$state_dir" -export OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS=2 -export OMARCHY_BATTERY_GUARD_POLL_INTERVAL=1 -export OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=4 -export OMARCHY_BATTERY_GUARD_THRESHOLD_PERCENT=10 -export OMARCHY_BATTERY_GUARD_DRY_RUN=true - -"$ROOT/bin/omarchy-battery-guard" --oneshot - -action_file="$state_dir/boot-preserve-required" -[[ -f $action_file ]] || fail "battery guard marks protective action when critical" - -grep -F 'mode=' "$state_dir/state" >/dev/null || fail "battery guard writes runtime mode" -grep -F 'percentage=' "$state_dir/state" >/dev/null || fail "battery guard writes battery percentage into state" - -printf '%s\n' Mains >"$power_dir/AC/type" -printf '%s\n' 1 >"$power_dir/AC/online" -rm -f "$action_file" - -"$ROOT/bin/omarchy-battery-guard" --oneshot -[[ ! -f $action_file ]] || fail "battery guard does not mark action when AC is present" - -cat >"$state_dir/state" </dev/null || fail "battery guard clears pending countdown after plug-in on boot" - -printf '%s\n' 0 >"$power_dir/AC/online" -printf '%s\n' 50 >"$power_dir/BAT0/capacity" -rm -f "$power_dir/BAT0/energy_now" "$power_dir/BAT0/power_now" "$action_file" -printf '%s\n' 5000000 >"$power_dir/BAT0/charge_now" -printf '%s\n' 1000 >"$power_dir/BAT0/current_now" -printf '%s\n' 1000000 >"$power_dir/BAT0/voltage_now" - -"$ROOT/bin/omarchy-battery-guard" --oneshot -[[ ! -f $action_file ]] || fail "charge and current units do not create a false time-to-empty action" - -injected_file="$tmp_dir/state-was-executed" -printf 'mode=$(touch %s)\nremaining=5\n' "$injected_file" >"$state_dir/state" -printf '%s\n' 1 >"$power_dir/AC/online" - -"$ROOT/bin/omarchy-battery-guard" --oneshot -[[ ! -e $injected_file ]] || fail "battery guard must treat persisted state as data" - -mock_bin="$tmp_dir/mock-bin" -migration_home="$tmp_dir/migration-home" -systemctl_log="$tmp_dir/systemctl.log" -mkdir -p "$mock_bin" "$migration_home" - -cat >"$mock_bin/systemctl" <<'EOF' +mkdir -p "$tmp_dir/bin" "$tmp_dir/power/BAT0" "$tmp_dir/power/AC" +export OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" +export OMARCHY_BATTERY_GUARD_STATE_DIR="$tmp_dir/state" +export OMARCHY_BATTERY_GUARD_CLOCK_PATH="$tmp_dir/clock" +export OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=70 +export OMARCHY_BATTERY_GUARD_DRY_RUN=false +export EVENTS="$tmp_dir/events" SCENARIO="" +export PATH="$tmp_dir/bin:$PATH" +unset OMARCHY_BATTERY_GUARD_THRESHOLD_PERCENT OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS + +cat >"$tmp_dir/bin/sleep" <<'SH' #!/bin/bash -printf '%s\n' "$*" >>"$SYSTEMCTL_LOG" - -if [[ $1 == "is-enabled" ]]; then - [[ ${SYSTEMCTL_ENABLED:-false} == "true" ]] -elif [[ $1 == "is-active" ]]; then - [[ ${SYSTEMCTL_ACTIVE:-false} == "true" ]] -elif [[ $1 == "hibernate" ]]; then - [[ ${SYSTEMCTL_HIBERNATE_SUCCESS:-false} == "true" ]] -elif [[ $1 == "poweroff" ]]; then - exit 1 +read -r now _ <"$OMARCHY_BATTERY_GUARD_CLOCK_PATH" +printf '%s 0\n' "$((now + 1))" >"$OMARCHY_BATTERY_GUARD_CLOCK_PATH" +if [[ $SCENARIO == "plug" && $now == "46" ]]; then + printf '1\n' >"$OMARCHY_POWER_SUPPLY_PATH/AC/online" + printf 'Charging\n' >"$OMARCHY_POWER_SUPPLY_PATH/BAT0/status" fi -EOF -chmod +x "$mock_bin/systemctl" - -cat >"$mock_bin/sudo" <<'EOF' -#!/bin/bash -exec "$@" -EOF - -cat >"$mock_bin/omarchy-hibernation-available" <<'EOF' -#!/bin/bash -if [[ -n ${PLUG_AC_ON_CHECK:-} ]]; then - printf '%s\n' 1 >"$PLUG_AC_ON_CHECK" +if [[ $SCENARIO == "collapse" && $now == "5" ]]; then + printf '20\n' >"$OMARCHY_POWER_SUPPLY_PATH/BAT0/time_to_empty_now" +fi +if [[ $SCENARIO == "unknown" && $now == "5" ]]; then + printf 'Unknown\n' >"$OMARCHY_POWER_SUPPLY_PATH/BAT0/status" + printf '1\n' >"$OMARCHY_POWER_SUPPLY_PATH/AC/online" fi -[[ ${HIBERNATION_AVAILABLE:-false} == "true" ]] -EOF -cat >"$mock_bin/omarchy-hook" <<'EOF' +SH +cat >"$tmp_dir/bin/timeout" <<'SH' #!/bin/bash -exit 0 -EOF -cat >"$mock_bin/omarchy-notification-send" <<'EOF' +shift +if [[ $1 == "/bin/bash" && $2 == "/usr/share/omarchy/default/battery-guard/close-windows" ]]; then + shift + if [[ $3 == "--snapshot" ]]; then + printf 'snapshot\n' >>"$EVENTS" + if [[ $SCENARIO != "empty-snapshot" ]]; then + printf 'fixture 123 wayland-1 0xabc\n' + if [[ $SCENARIO == "close-partial" || $SCENARIO == "crash" ]]; then + printf 'fixture 123 wayland-1 0xdef\n' + fi + fi + exit 0 + fi + [[ $3 == "--close-one" ]] || exit 1 + printf 'close %s\n' "$(cat "$OMARCHY_BATTERY_GUARD_CLOCK_PATH")" >>"$EVENTS" + printf 'dispatch %s\n' "$7" >>"$EVENTS" + # Assert write-ahead state at the exact entry to a dispatch invocation. + grep -Eq '^snapshot:[0-9]+=1$' "$OMARCHY_BATTERY_GUARD_STATE_DIR/state" || exit 99 + grep -Eq "^done:[0-9]+=fixture $7$" "$OMARCHY_BATTERY_GUARD_STATE_DIR/state" || exit 99 + printf 'persisted-before-dispatch\n' >>"$EVENTS" + if [[ $SCENARIO == "reject-once" && ! -e $EVENTS.rejected ]]; then + touch "$EVENTS.rejected" + exit 2 + fi + if [[ $SCENARIO == "crash" && $7 == "0xabc" ]]; then + kill -KILL "$GUARD_PID" + exit 124 + fi + if [[ $SCENARIO == "close-partial" && $7 == "0xabc" ]]; then exit 124; fi + if [[ $SCENARIO == "close-plug" ]]; then + printf '1\n' >"$OMARCHY_POWER_SUPPLY_PATH/AC/online" + printf 'Charging\n' >"$OMARCHY_POWER_SUPPLY_PATH/BAT0/status" + fi +else + exec "$@" +fi +SH +cat >"$tmp_dir/bin/omarchy-notification-send" <<'SH' #!/bin/bash -exit 0 -EOF -chmod +x "$mock_bin/sudo" "$mock_bin/omarchy-hibernation-available" "$mock_bin/omarchy-hook" "$mock_bin/omarchy-notification-send" - -printf '%s\n' 0 >"$power_dir/AC/online" -printf '%s\n' 5 >"$power_dir/BAT0/capacity" -rm -f "$state_dir/state" "$action_file" -: >"$systemctl_log" -PATH="$mock_bin:$PATH" SYSTEMCTL_LOG="$systemctl_log" PLUG_AC_ON_CHECK="$power_dir/AC/online" \ - OMARCHY_BATTERY_GUARD_DRY_RUN=false OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS=1 \ - OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=3 "$ROOT/bin/omarchy-battery-guard" --oneshot -! grep -Fx -- 'poweroff --no-wall' "$systemctl_log" >/dev/null || fail "AC arriving at the action boundary must cancel poweroff" - -: >"$systemctl_log" -printf '%s\n' 0 >"$power_dir/AC/online" -rm -f "$state_dir/state" "$action_file" -if PATH="$mock_bin:$PATH" SYSTEMCTL_LOG="$systemctl_log" OMARCHY_BATTERY_GUARD_DRY_RUN=false \ - OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS=1 OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=3 \ - "$ROOT/bin/omarchy-battery-guard" --oneshot; then - fail "battery guard must propagate a failed poweroff" +printf 'toast %s\n' "$*" >>"$EVENTS" +if [[ $SCENARIO == "final-plug" && $* == *"Shutting down…"* ]]; then + printf '1\n' >"$OMARCHY_POWER_SUPPLY_PATH/AC/online" + printf 'Charging\n' >"$OMARCHY_POWER_SUPPLY_PATH/BAT0/status" fi -grep -Fx -- 'poweroff --no-wall' "$systemctl_log" >/dev/null || fail "battery guard attempts poweroff directly" - -: >"$systemctl_log" -rm -f "$state_dir/state" "$action_file" -PATH="$mock_bin:$PATH" SYSTEMCTL_LOG="$systemctl_log" HIBERNATION_AVAILABLE=true SYSTEMCTL_HIBERNATE_SUCCESS=true \ - OMARCHY_BATTERY_GUARD_DRY_RUN=false OMARCHY_BATTERY_GUARD_COUNTDOWN_SECONDS=1 \ - OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=3 "$ROOT/bin/omarchy-battery-guard" --oneshot -grep -Fx -- 'hibernate --no-wall' "$systemctl_log" >/dev/null || fail "battery guard waits for hibernation to return" -! grep -Fx -- 'poweroff --no-wall' "$systemctl_log" >/dev/null || fail "successful hibernation must not fall through to poweroff" -[[ ! -f $action_file ]] || fail "resumed hibernation clears the recovery marker" - -HOME="$migration_home" PATH="$mock_bin:$PATH" SYSTEMCTL_LOG="$systemctl_log" \ - bash -euo pipefail "$ROOT/migrations/1789066533.sh" >/dev/null -grep -Fx -- 'link --force /usr/share/omarchy/default/systemd/system/omarchy-battery-guard.service' "$systemctl_log" >/dev/null || fail "migration links the packaged system unit" -grep -Fx -- 'daemon-reload' "$systemctl_log" >/dev/null || fail "migration reloads the system manager" -grep -Fx -- 'enable --now omarchy-battery-guard.service' "$systemctl_log" >/dev/null || fail "migration enables and starts the system guard" - -: >"$systemctl_log" -HOME="$migration_home" PATH="$mock_bin:$PATH" SYSTEMCTL_LOG="$systemctl_log" SYSTEMCTL_ENABLED=true SYSTEMCTL_ACTIVE=true \ - bash -euo pipefail "$ROOT/migrations/1789066533.sh" >/dev/null -[[ $(wc -l <"$systemctl_log") == 2 ]] || fail "migration is idempotent once the system guard is running" - -pass "battery guard reacts safely in mocked low battery scenarios" +printf '77\n' +SH +cat >"$tmp_dir/bin/systemctl" <<'SH' +#!/bin/bash +printf 'power %s %s\n' "$(cat "$OMARCHY_BATTERY_GUARD_CLOCK_PATH")" "$*" >>"$EVENTS" +[[ $SCENARIO != "power-fail" ]] +SH +chmod +x "$tmp_dir/bin/"* + +reset_fixture() { + rm -rf "$tmp_dir/state" "$tmp_dir/power/AAA" + rm -f "$EVENTS.rejected" + printf '0 0\n' >"$tmp_dir/clock" + : >"$EVENTS" + printf 'Battery\n' >"$tmp_dir/power/BAT0/type" + printf 'Discharging\n' >"$tmp_dir/power/BAT0/status" + printf '5\n' >"$tmp_dir/power/BAT0/capacity" + printf '600\n' >"$tmp_dir/power/BAT0/time_to_empty_now" + printf 'Mains\n' >"$tmp_dir/power/AC/type" + printf '0\n' >"$tmp_dir/power/AC/online" +} +run_guard() { bash -c 'export GUARD_PID=$$; exec "$ROOT/bin/omarchy-battery-guard" --oneshot'; } + +reset_fixture +printf '6\n' >"$tmp_dir/power/BAT0/capacity" +run_guard +[[ ! -s $EVENTS ]] || fail "raw 6 percent must not start countdown" + +reset_fixture +run_guard +grep -Fx 'close 45 0' "$EVENTS" >/dev/null || fail "normal close gets last 15 seconds" +grep -Fx 'power 60 0 poweroff --no-wall' "$EVENTS" >/dev/null || fail "default save window lasts 60 seconds" +[[ $(grep -c '^toast ' "$EVENTS") == 4 ]] || fail "only initial, 30, 10, final notices" +[[ $(grep -c -- '-p -r 0 -t 0' "$EVENTS") == 1 ]] || fail "first toast creates ID" +[[ $(grep -c -- '-p -r 77 -t 0' "$EVENTS") == 3 ]] || fail "remaining notices replace same ID" + +reset_fixture +SCENARIO=plug run_guard +! grep '^power ' "$EVENTS" >/dev/null || fail "AC during save window aborts shutdown" +grep -F -- '-r 77 -t 5000' "$EVENTS" >/dev/null || fail "cancel replaces toast and expires" + +reset_fixture +SCENARIO=close-plug run_guard +! grep '^power ' "$EVENTS" >/dev/null || fail "AC during close aborts shutdown" +reset_fixture +SCENARIO=final-plug run_guard +! grep '^power ' "$EVENTS" >/dev/null || fail "AC during final notification aborts shutdown" + +reset_fixture +printf '1\n' >"$tmp_dir/power/AC/online" +run_guard +grep '^power ' "$EVENTS" >/dev/null || fail "weak adapter must not cancel protection" + +reset_fixture +printf '50\n' >"$tmp_dir/power/BAT0/time_to_empty_now" +run_guard +grep -Fx 'power 20 0 poweroff --no-wall' "$EVENTS" >/dev/null || fail "short runtime reserves 30 seconds" +reset_fixture +SCENARIO=collapse run_guard +grep -Fx 'power 6 0 poweroff --no-wall' "$EVENTS" >/dev/null || fail "deteriorating runtime triggers emergency override" +! grep '^close ' "$EVENTS" >/dev/null || fail "no close delay when reserve is exhausted" + +reset_fixture +SCENARIO=close-partial run_guard +[[ $(grep -c '^close ' "$EVENTS") == 2 ]] || fail "unfinished helper requests retry once with acknowledged windows excluded" +grep -Fx 'power 60 0 poweroff --no-wall' "$EVENTS" >/dev/null || fail "close retries cannot extend save deadline" + +reset_fixture +OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=46 SCENARIO=close-partial run_guard +grep -Fx 'done:1000=fixture 0xabc' "$tmp_dir/state/state" >/dev/null || + grep -E '^done:[0-9]+=fixture 0xabc$' "$tmp_dir/state/state" >/dev/null || fail "partial close progress is persisted" +SCENARIO=close-partial run_guard +[[ $(grep -c '^close ' "$EVENTS") == 2 ]] || fail "restart retries unfinished snapshot without re-prompting completed windows" + +reset_fixture +if SCENARIO=crash run_guard; then fail "fixture must interrupt the guard at dispatch entry"; fi +grep -Fx 'persisted-before-dispatch' "$EVENTS" >/dev/null || fail "attempt is on disk before dispatch starts" +SCENARIO=crash run_guard +[[ $(grep -c '^snapshot$' "$EVENTS") == 1 ]] || fail "restart must not resnapshot save dialogs" +[[ $(grep -c '^dispatch 0xabc$' "$EVENTS") == 1 ]] || fail "uncertain attempt must never be resent after crash" +[[ $(grep -c '^dispatch 0xdef$' "$EVENTS") == 1 ]] || fail "restart still closes untouched original windows" + +reset_fixture +OMARCHY_BATTERY_GUARD_MAX_ONESHOT_CYCLES=46 SCENARIO=empty-snapshot run_guard +SCENARIO=empty-snapshot run_guard +[[ $(grep -c '^snapshot$' "$EVENTS") == 1 ]] || fail "empty snapshot sentinel survives restart" +! grep '^close ' "$EVENTS" >/dev/null || fail "empty snapshot never dispatches" + +reset_fixture +SCENARIO=reject-once run_guard +[[ $(grep -c '^dispatch 0xabc$' "$EVENTS") == 2 ]] || fail "explicit rejection unmarks the attempt for one successful retry" +[[ $(grep -c '^snapshot$' "$EVENTS") == 1 ]] || fail "explicit rejection reuses the original snapshot" + +reset_fixture +SCENARIO=unknown run_guard +grep -Fx 'power 60 0 poweroff --no-wall' "$EVENTS" >/dev/null || fail "Unknown with online adapter preserves active deadline" + +reset_fixture +mkdir -p "$tmp_dir/power/AAA" +printf 'Battery\n' >"$tmp_dir/power/AAA/type" +printf 'Device\n' >"$tmp_dir/power/AAA/scope" +printf '95\n' >"$tmp_dir/power/AAA/capacity" +run_guard +grep '^power ' "$EVENTS" >/dev/null || fail "peripheral must not mask system battery" + +reset_fixture +OMARCHY_BATTERY_GUARD_DRY_RUN=true run_guard +! grep -E '^(close|power) ' "$EVENTS" >/dev/null || fail "dry run never closes apps or powers off" +grep -Fx 'mode=dry-run' "$tmp_dir/state/state" >/dev/null || fail "dry run records outcome" + +reset_fixture +if SCENARIO=power-fail run_guard; then fail "poweroff failure must propagate for systemd retry"; fi + +reset_fixture +mkdir -p "$tmp_dir/state" +printf 'mode=$(touch %s)\n' "$tmp_dir/injected" >"$tmp_dir/state/state" +printf '6\n' >"$tmp_dir/power/BAT0/capacity" +run_guard +[[ ! -e $tmp_dir/injected ]] || fail "persisted state is data" + +# Exercise the notification function with two session recipients. It must keep +# independent IDs in its caller, including the cancellation update. +sed -n '/^notify_critical() {$/,/^}$/p' "$ROOT/bin/omarchy-battery-guard" >"$tmp_dir/notify.sh" +( + declare -A notification_ids=() + source "$tmp_dir/notify.sh" + session_users() { printf '1000 alice\n1001 bob\n'; } + as_session_user() { + printf 'user %s %s\n' "$1" "$*" >>"$tmp_dir/recipients" + printf '%s\n' "$(($1 + 100))" + } + notify_critical initial + notify_critical update + notify_critical cancelled 5000 +) +grep -E 'user 1000 .* -r 1100 -t 5000' "$tmp_dir/recipients" >/dev/null || fail "Alice retains her own replacement ID" +grep -E 'user 1001 .* -r 1101 -t 5000' "$tmp_dir/recipients" >/dev/null || fail "Bob retains his own replacement ID" + +! rg 'systemctl hibernate|--force|killall|pkill' "$ROOT/bin/omarchy-battery-guard" "$ROOT/default/battery-guard/close-windows" | rg -v '^.*#' >/dev/null || fail "no forced process kills or hibernation" +pass "battery guard thresholds, toast replacement, save window and emergency actions" diff --git a/test/shell.d/battery-status-test.sh b/test/shell.d/battery-status-test.sh index 91179034002..76c3541c4e3 100644 --- a/test/shell.d/battery-status-test.sh +++ b/test/shell.d/battery-status-test.sh @@ -39,7 +39,7 @@ chmod +x "$tmp_dir/bin/upower" shell_output=$(OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) -grep -Fx $'percentage\t51%' <<<"$shell_output" >/dev/null || fail "battery status reports percentage" +grep -Fx $'percentage\t49%' <<<"$shell_output" >/dev/null || fail "battery status reports usable percentage" grep -Fx $'state\tdischarging' <<<"$shell_output" >/dev/null || fail "battery status reports state" grep -Fx $'rate\t10.8W' <<<"$shell_output" >/dev/null || fail "battery status reports live sysfs power rate" grep -Fx $'size\t56Wh' <<<"$shell_output" >/dev/null || fail "battery status reports full capacity" @@ -93,7 +93,7 @@ grep -Fx $'threshold\t80%' <<<"$asahi_output" >/dev/null || fail "Apple Silicon grep -Fx $'cycles\t405' <<<"$asahi_output" >/dev/null || fail "Apple Silicon charge cycles are read" # Display rounding is half-up to match the bar widget, but the charge-hold -# check must compare UPower's raw percentage: 79.5% displays as 80%, and an +# check must compare UPower's raw percentage: 79.5% displays as 79%, and an # 80% hold threshold must not trip while the raw value is still below it. hold_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir" "$asahi_dir" "$hold_dir"' EXIT @@ -129,7 +129,7 @@ chmod +x "$hold_dir/bin/upower" hold_output=$(OMARCHY_POWER_SUPPLY_PATH="$hold_dir/power" PATH="$hold_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) -grep -Fx $'percentage\t80%' <<<"$hold_output" >/dev/null || fail "display percentage rounds half-up" +grep -Fx $'percentage\t79%' <<<"$hold_output" >/dev/null || fail "display percentage maps usable charge" grep -Fx $'state\tcharging' <<<"$hold_output" >/dev/null || fail "hold threshold compares the raw percentage" pass "battery status reads an Apple Silicon battery" diff --git a/test/shell.d/battery-test.sh b/test/shell.d/battery-test.sh index 0447779a96f..8cd15df4301 100644 --- a/test/shell.d/battery-test.sh +++ b/test/shell.d/battery-test.sh @@ -8,7 +8,7 @@ run_node_test <<'JS' const battery = requireFromRoot('shell/plugins/services/battery/BatteryModel.js') const discharging = 1 -assertEqual(battery.batteryPercentage({ isPresent: true, percentage: 0.126 }), 13, 'battery rounds display percentage') +assertEqual(battery.batteryPercentage({ isPresent: true, percentage: 0.126 }), 9, 'battery rounds usable display percentage') assertEqual(battery.batteryPercentage({ isPresent: false, percentage: 0.5 }), -1, 'battery reports missing battery') assert(battery.isDischarging({ isPresent: true, state: discharging }, true, discharging), 'battery detects discharging state') assert(!battery.isDischarging({ isPresent: true, state: discharging }, false, discharging), 'battery requires on-battery state') diff --git a/test/shell.d/battery-usable-test.sh b/test/shell.d/battery-usable-test.sh new file mode 100644 index 00000000000..5a4be9409d5 --- /dev/null +++ b/test/shell.d/battery-usable-test.sh @@ -0,0 +1,63 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +run_node_test <<'JS' +const battery = requireFromRoot('shell/plugins/services/battery/BatteryModel.js') +const power = requireFromRoot('shell/plugins/panels/power/Model.js') +let previous = 0 +let matches = true +for (let step = 0; step <= 1000; step++) { + const raw = step / 10 + const device = {isPresent: true, percentage: raw / 100} + const expected = raw > 0 ? Math.max(1, Math.round((raw - 4) * 100 / 96)) : 0 + const actual = battery.batteryPercentage(device) + if (actual !== expected || Math.round(power.batteryFraction(device) * 100) !== actual || actual < previous) { + matches = false + throw new Error(`usable scale mismatch at ${raw}`) + } + previous = actual +} +assert(matches, '1001 samples agree across usable display models and remain monotonic') +assertEqual(battery.batteryPercentage({isPresent:true, percentage:0.05}), 1, 'shutdown reserve displays one percent') +assert(battery.shouldWarnLowBattery({isPresent:true, percentage:0.05, state:1}, true, 1, 10, false).notify, 'first critical observation delivers battery-low hook') +assert(!battery.shouldWarnLowBattery({isPresent:true, percentage:0.05, state:1}, true, 1, 10, true).notify, 'critical hook is delivered once') +assert(!battery.shouldWarnLowBattery({isPresent:true, percentage:0.12, state:1}, true, 1, 10, false).notify, 'mapped low percent does not change raw early warning threshold') +const states = {Charging:1, FullyCharged:2} +assert(!power.chargeThresholdActive({isPresent:true, percentage:0.99, state:1, changeRate:0}, false, states), 'charge hold uses raw 99 percent') +assertEqual(power.modeLabel({isPresent:true, percentage:0.999, state:1}, false, states), 'Charging', 'rounded displayed full remains charging') +JS + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +mkdir -p "$tmp_dir/bin" "$tmp_dir/power" +export EVENTS="$tmp_dir/events" +cat >"$tmp_dir/bin/upower" <<'SH' +#!/bin/bash +if [[ $1 == "-e" ]]; then + echo /org/freedesktop/UPower/devices/battery_BAT0 +else + printf 'native-path: BAT0\nstate: discharging\npercentage: %s%%\n' "$RAW" +fi +SH +cat >"$tmp_dir/bin/omarchy-notification-send" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$EVENTS" +SH +cat >"$tmp_dir/bin/omarchy-hook" <<'SH' +#!/bin/bash +printf 'hook %s\n' "$*" >>"$EVENTS" +SH +chmod +x "$tmp_dir/bin/"* +for pair in 0:0 1:1 5:1 10:6 51:49 79.5:79 99.9:100 100:100; do + raw=${pair%:*} + expected=${pair#*:} + result=$(RAW="$raw" PATH="$tmp_dir/bin:$PATH" OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" "$ROOT/bin/omarchy-battery-status" --shell) + grep -Fx $'percentage\t'"$expected%" <<<"$result" >/dev/null || fail "CLI mapping agrees at $raw" +done +PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-low" 10 +grep -F 'Battery is down to 6%' "$EVENTS" >/dev/null || fail "early warning uses usable display" +grep -Fx 'hook battery-low 10' "$EVENTS" >/dev/null || fail "battery-low hook retains raw percentage" +: >"$EVENTS" +PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-low" 5 --quiet +[[ $(cat "$EVENTS") == "hook battery-low 5" ]] || fail "quiet critical delivery runs raw hook without a toast" +pass "usable percentage is consistent while protection and hooks retain raw values" diff --git a/test/shell.d/systemd-test.sh b/test/shell.d/systemd-test.sh index 710cbabfb4d..09114d4120b 100755 --- a/test/shell.d/systemd-test.sh +++ b/test/shell.d/systemd-test.sh @@ -37,7 +37,7 @@ grep -Fx 'WantedBy=multi-user.target' "$battery_guard_service" >/dev/null grep -Fx 'StateDirectory=omarchy-battery-guard' "$battery_guard_service" >/dev/null pass "battery guard service follows the system lifetime" -grep -F 'systemctl hibernate --no-wall' "$ROOT/bin/omarchy-battery-guard" >/dev/null +! grep -F 'systemctl hibernate' "$ROOT/bin/omarchy-battery-guard" >/dev/null grep -F 'systemctl poweroff --no-wall' "$ROOT/bin/omarchy-battery-guard" >/dev/null ! grep -F 'systemd-run' "$ROOT/bin/omarchy-battery-guard" >/dev/null pass "battery guard observes power action results directly" @@ -47,8 +47,8 @@ grep -F 'timeout 2s runuser -u "$user"' "$ROOT/bin/omarchy-battery-guard" >/dev/ pass "system battery warnings target logged-in users with a deadline" enable_services="$ROOT/install/config/enable-services.sh" -grep -F 'systemctl link --force /usr/share/omarchy/default/systemd/system/omarchy-battery-guard.service' "$enable_services" >/dev/null -grep -F 'systemctl enable omarchy-battery-guard.service' "$enable_services" >/dev/null +grep -F 'bash "$OMARCHY_INSTALL/helpers/battery-guard.sh"' "$enable_services" >/dev/null +grep -F 'systemctl enable omarchy-battery-guard.service' "$ROOT/install/helpers/battery-guard.sh" >/dev/null pass "system setup enables the battery guard service" als_kbd_service="$ROOT/default/systemd/user/omarchy-brightness-keyboard-auto.service"