diff --git a/bin/omarchy-system-lid-close b/bin/omarchy-system-lid-close new file mode 100755 index 00000000..eb3dc097 --- /dev/null +++ b/bin/omarchy-system-lid-close @@ -0,0 +1,20 @@ +#!/bin/bash + +# omarchy:summary=Lock and reconcile displays when the laptop lid closes +# omarchy:group=system +# omarchy:hidden=true + +# Locking here rather than waiting for PrepareForSleep is what keeps the lock +# off the critical path. logind's delay inhibitor is a timer that expires +# whether or not the session is secure, so starting the lock the moment the lid +# closes gives Quickshell a head start before logind even decides to suspend. +# omarchy-system-sleep-lock then usually finds the session already secure. +# +# A docked lid close does not suspend (HandleLidSwitchDocked defaults to +# ignore), so it must not lock either: that is clamshell mode, still in use on +# the external display. +if omarchy-hw-laptop-closed && ! omarchy-hw-external-monitors; then + omarchy-system-lock >/dev/null 2>&1 || true +fi + +omarchy-hyprland-monitor-clamshell diff --git a/bin/omarchy-system-sleep-lock b/bin/omarchy-system-sleep-lock index 3d9e5df5..d471cea0 100755 --- a/bin/omarchy-system-sleep-lock +++ b/bin/omarchy-system-sleep-lock @@ -4,29 +4,121 @@ # omarchy:group=system # omarchy:hidden=true -wait_attempts=${1:-100} +# Overrunning the budget is the failure this whole path exists to prevent: +# logind stops honouring the inhibitor and suspends mid-lock. Every call below +# is bounded by what is left of the budget, so the deadline enforces itself +# rather than depending on an estimate of how long a step ought to take. +budget_cap_ms=12000 +lock_timeout_ms=1000 +status_timeout_ms=500 +poll_interval=0.1 -if [[ ! $wait_attempts =~ ^[0-9]+$ ]] || (( wait_attempts < 1 )); then - wait_attempts=100 -fi +# logind decides how long a delay inhibitor may hold the machine, and the +# shipped drop-in only counts once logind has reloaded it, so ask rather than +# assume. Leaving logind a fifth of its own window to deliver PrepareForSleep +# and act on the release gives 4s at the 5s default and 12s at the shipped 15s. +# The cap keeps a hand-raised window from stranding a closed laptop in a bag. +derive_budget_ms() { + local window + window=$(timeout --kill-after=0.1s 1s busctl get-property \ + org.freedesktop.login1 /org/freedesktop/login1 \ + org.freedesktop.login1.Manager InhibitDelayMaxUSec 2>/dev/null) + window=${window##* } -sync_clamshell() { - omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true + # An unreadable window means we cannot know, so assume logind's own default. + [[ $window =~ ^[0-9]+$ ]] && (( window > 0 )) || window=5000000 + window=$((window / 1000)) + + # Never leave logind less than a second, however small its window is. + window=$((window - (window / 5 > 1000 ? window / 5 : 1000))) + + (( window < budget_cap_ms )) && echo "$window" || echo "$budget_cap_ms" } +budget_ms=${1:-$(derive_budget_ms)} +if [[ ! $budget_ms =~ ^[0-9]+$ ]] || (( budget_ms < 1 || budget_ms > budget_cap_ms )); then + budget_ms=$(derive_budget_ms) +fi + +# EPOCHREALTIME renders with the locale's decimal separator, so drop every +# non-digit rather than assuming a period. A comma would otherwise read as +# bash's comma operator and silently void the deadline. +deadline_ms=$((10#${EPOCHREALTIME//[!0-9]/} / 1000 + budget_ms)) + +remaining_ms() { + echo $((deadline_ms - 10#${EPOCHREALTIME//[!0-9]/} / 1000)) +} + +# Clamping to what is left as well as to the call's own limit is what lets the +# loop below stay a plain "while there is time" without predicting step costs. +lock_ipc() { + local limit=$1 remaining seconds + shift + + remaining=$(remaining_ms) + (( remaining > 0 )) || return 1 + (( limit < remaining )) || limit=$remaining + printf -v seconds '%d.%03d' $((limit / 1000)) $((limit % 1000)) + + OMARCHY_SHELL_IPC_TIMEOUT="$seconds" \ + timeout --kill-after=0.1s "$seconds" omarchy-shell "$@" +} + +# The shell answers refusals on stdout with a zero exit, so the reply is the +# only way to spot a lock it can never perform. Nothing else needs inspecting: +# the status poll is what confirms success, and re-requesting is idempotent, so +# a request that may not have landed costs nothing to repeat. +request_lock() { + case $(lock_ipc "$lock_timeout_ms" lock lock 2>/dev/null) in + missing-pam) report_unsecured "no lock screen is configured" ;; + esac +} + +# secure: done. locking: the shell has the request and is working on it, so +# leave it alone. Anything else, unreadable replies included, means ask again. +lock_state() { + jq -r 'if .secure == true then "secure" + elif .requested == true then "locking" + else "idle" end' \ + <<<"$(lock_ipc "$status_timeout_ms" lock status 2>/dev/null)" 2>/dev/null +} + +sync_clamshell() { + # Lid transitions can temporarily stall Hyprland IPC. This is best-effort: + # the lid binding and monitor watcher also reconcile clamshell state. + timeout --kill-after=0.1s 0.4s \ + omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true +} + +# logind suspends whether or not this wait succeeded, so a failure here means +# the machine slept with the session exposed. The notification is the only way +# anyone finds out, and it lands on the screen they unlock into. +report_unsecured() { + printf 'omarchy-system-sleep-lock: suspending without a secure lock (%s)\n' \ + "$1" >&2 + + omarchy-notification-send -u critical -g 󰌾 \ + "Screen did not lock before suspend" \ + "The session was left unlocked ($1)." >/dev/null 2>&1 || true + + exit 1 +} + +# Request the lock before touching monitor state, so a stuck Hyprland IPC call +# cannot consume the window before Quickshell has begun securing the session. +request_lock sync_clamshell -omarchy-shell lock lock >/dev/null 2>&1 || exit 1 -for (( attempt = 0; attempt < wait_attempts; attempt++ )); do - (( attempt % 5 == 0 )) && sync_clamshell +# The trailing sleep can overshoot the deadline by one interval, which is well +# inside the reserve derive_budget_ms already held back for logind. +while (( $(remaining_ms) > 0 )); do + case $(lock_state) in + secure) exit 0 ;; + locking) ;; + *) request_lock ;; + esac - status=$(omarchy-shell lock status 2>/dev/null || true) - - if jq -e '.secure == true' <<<"$status" >/dev/null 2>&1; then - exit 0 - fi - - sleep 0.05 + sleep "$poll_interval" done -exit 1 +report_unsecured "the shell did not secure the session within ${budget_ms}ms" diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 525bf673..a3b8a743 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -29,7 +29,7 @@ o.bind_toggle("SUPER + CTRL + I", "Toggle locking on idle", "idle") o.bind_toggle("SUPER + CTRL + N", "Toggle nightlight", "nightlight") o.bind("SUPER + CTRL + Delete", "Toggle laptop display", "omarchy-hyprland-monitor-internal toggle") o.bind("SUPER + CTRL + ALT + Delete", "Toggle laptop display mirroring", "omarchy-hyprland-monitor-internal-mirror toggle") -o.bind("switch:on:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) +o.bind("switch:on:Lid Switch", nil, "omarchy-system-lid-close", { locked = true }) o.bind("switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) o.bind("PRINT", "Screenshot", "omarchy-capture-screenshot") diff --git a/etc/systemd/logind.conf.d/20-inhibit-delay.conf b/etc/systemd/logind.conf.d/20-inhibit-delay.conf new file mode 100644 index 00000000..bebd95ee --- /dev/null +++ b/etc/systemd/logind.conf.d/20-inhibit-delay.conf @@ -0,0 +1,10 @@ +# omarchy-sleep-lock.service holds a delay inhibitor so the session is locked +# before the machine suspends. A delay inhibitor is a timer, not a promise: +# logind suspends anyway once the window expires, locked or not. Five seconds +# is not enough when closing the lid also reconfigures displays, because +# Quickshell waits for the screen set to settle before it can secure. +# +# This only costs anything when locking is broken. A healthy lock releases the +# inhibitor the moment the session reports secure, well under a second. +[Login] +InhibitDelayMaxSec=15 diff --git a/migrations/1784970000.sh b/migrations/1784970000.sh new file mode 100644 index 00000000..af5c3001 --- /dev/null +++ b/migrations/1784970000.sh @@ -0,0 +1,22 @@ +echo "Give the pre-suspend lock a window it can actually finish in" + +# logind's five second default expires while Quickshell is still securing the +# session on lid close, and it suspends regardless. The shipped drop-in raises +# InhibitDelayMaxSec, but logind only reads it on reload. +# +# Reload rather than restart: restarting systemd-logind tears down the session. +sudo systemctl reload systemd-logind >/dev/null 2>&1 || true + +# Check the property logind actually enforces, not the reload's exit status: a +# reload that returns success while the drop-in is missing or unparsed leaves +# the old five second window in place. omarchy-system-sleep-lock reads this same +# property at runtime, so it stays correct either way -- the reboot flag is only +# about getting the wider window to take effect. +dropin=/etc/systemd/logind.conf.d/20-inhibit-delay.conf +expected_s=$(sed -n 's/^InhibitDelayMaxSec=//p' "$dropin" 2>/dev/null) +effective_us=$(busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \ + org.freedesktop.login1.Manager InhibitDelayMaxUSec 2>/dev/null | awk '{print $2}') + +if [[ -z $expected_s || $effective_us != $((expected_s * 1000000)) ]]; then + omarchy-state set reboot-required +fi diff --git a/test/shell.d/lid-close-test.sh b/test/shell.d/lid-close-test.sh new file mode 100755 index 00000000..746dcfc5 --- /dev/null +++ b/test/shell.d/lid-close-test.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +lid_close="$ROOT/bin/omarchy-system-lid-close" +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +# closed/docked are the two facts logind uses to decide whether a lid close +# suspends, so each scenario pins them and records what the lid handler did. +setup_scenario() { + scenario_dir="$tmpdir/$1" + mock_bin="$scenario_dir/bin" + call_log="$scenario_dir/calls" + mkdir -p "$mock_bin" + : >"$call_log" + + local closed="$2" docked="$3" + + cat >"$mock_bin/omarchy-hw-laptop-closed" <"$mock_bin/omarchy-hw-external-monitors" <"$mock_bin/$command" <>"\$CALL_LOG" +SH + done + chmod +x "$mock_bin"/* +} + +run_lid_close() { + CALL_LOG="$call_log" PATH="$mock_bin:$PATH" "$lid_close" + mapfile -t calls <"$call_log" +} + +# An undocked lid close is about to suspend, and logind's inhibitor window is a +# timer rather than a promise, so the lock has to start now instead of waiting +# for PrepareForSleep. +setup_scenario undocked 0 1 +run_lid_close + +[[ ${calls[0]} == "omarchy-system-lock" ]] || + fail "undocked lid close locks before anything else" "calls: ${calls[*]}" +pass "undocked lid close locks before anything else" + +[[ ${calls[1]} == "omarchy-hyprland-monitor-clamshell" ]] || + fail "undocked lid close still reconciles displays" "calls: ${calls[*]}" +pass "undocked lid close still reconciles displays" + +# A docked lid close is clamshell mode: logind leaves the machine awake and the +# session stays in use on the external display, so locking it would be wrong. +setup_scenario docked 0 0 +run_lid_close + +[[ ${calls[*]} != *omarchy-system-lock* ]] || + fail "docked lid close does not lock the session" "calls: ${calls[*]}" +pass "docked lid close does not lock the session" + +[[ ${calls[0]} == "omarchy-hyprland-monitor-clamshell" ]] || + fail "docked lid close reconciles displays" "calls: ${calls[*]}" +pass "docked lid close reconciles displays" + +# Hyprland can replay a switch binding when the lid is already open, and an +# open lid must never lock the machine the user is sitting at. +setup_scenario open 1 1 +run_lid_close + +[[ ${calls[*]} != *omarchy-system-lock* ]] || + fail "an open lid never locks the session" "calls: ${calls[*]}" +pass "an open lid never locks the session" + +# The lid handler runs from a Hyprland binding, so a lock that hangs or fails +# must not stop the display reconciliation behind it. +setup_scenario failing_lock 0 1 +cat >"$mock_bin/omarchy-system-lock" <<'SH' +#!/bin/bash +echo omarchy-system-lock >>"$CALL_LOG" +exit 1 +SH +chmod +x "$mock_bin/omarchy-system-lock" +run_lid_close + +[[ ${calls[1]} == "omarchy-hyprland-monitor-clamshell" ]] || + fail "a failing lock still reconciles displays" "calls: ${calls[*]}" +pass "a failing lock still reconciles displays" diff --git a/test/shell.d/monitor-recovery-test.sh b/test/shell.d/monitor-recovery-test.sh index c455fe75..ec5c1ae5 100755 --- a/test/shell.d/monitor-recovery-test.sh +++ b/test/shell.d/monitor-recovery-test.sh @@ -9,11 +9,11 @@ monitor_internal="$ROOT/bin/omarchy-hyprland-monitor-internal" monitor_mirror="$ROOT/bin/omarchy-hyprland-monitor-internal-mirror" monitor_laptop="$ROOT/bin/omarchy-hyprland-monitor-laptop" monitor_external_active="$ROOT/bin/omarchy-hyprland-monitor-external-active" -sleep_lock="$ROOT/bin/omarchy-system-sleep-lock" system_wake="$ROOT/bin/omarchy-system-wake" clamshell="$ROOT/bin/omarchy-hyprland-monitor-clamshell" lock_service="$ROOT/shell/plugins/lock/Service.qml" hw_clamshell="$ROOT/bin/omarchy-hw-clamshell" +hw_laptop_closed="$ROOT/bin/omarchy-hw-laptop-closed" utilities="$ROOT/default/hypr/bindings/utilities.lua" grep -F 'sleep "$delay"' "$monitor_watch" >/dev/null @@ -36,8 +36,8 @@ grep -F 'sync_poll_state' "$monitor_watch" >/dev/null grep -F 'done < <(socat' "$monitor_watch" >/dev/null pass "clamshell poll only runs on a docked laptop, not desktops or undocked laptops" -grep -F '/proc/acpi/button/lid/*/state' "$hw_clamshell" >/dev/null -grep -F 'omarchy-hw-external-monitors' "$hw_clamshell" >/dev/null +grep -F 'omarchy-hw-laptop-closed && omarchy-hw-external-monitors' "$hw_clamshell" >/dev/null +grep -F '/proc/acpi/button/lid/*/state' "$hw_laptop_closed" >/dev/null pass "clamshell helper detects closed-lid external monitor state" grep -F 'hyprctl monitors -j' "$monitor_external_active" >/dev/null @@ -72,13 +72,9 @@ pass "internal monitor recovery only wakes displays when it re-enables one" grep -F 'omarchy-hyprland-monitor-external-active' "$monitor_mirror" >/dev/null pass "internal mirror helper recovers when no active external display remains" -grep -F 'switch:on:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell"' "$utilities" >/dev/null +grep -F 'switch:on:Lid Switch", nil, "omarchy-system-lid-close"' "$utilities" >/dev/null grep -F 'switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell"' "$utilities" >/dev/null -pass "lid switch bindings reconcile clamshell display state" - -grep -F 'omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true' "$sleep_lock" >/dev/null -grep -F '(( attempt % 5 == 0 )) && sync_clamshell' "$sleep_lock" >/dev/null -pass "sleep lock syncs clamshell display state while waiting for secure lock" +pass "lid switch bindings lock on close and reconcile clamshell display state" grep -F 'omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true' "$system_wake" >/dev/null pass "system wake resyncs clamshell display state" diff --git a/test/shell.d/sleep-lock-test.sh b/test/shell.d/sleep-lock-test.sh new file mode 100755 index 00000000..c9385946 --- /dev/null +++ b/test/shell.d/sleep-lock-test.sh @@ -0,0 +1,336 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +sleep_lock="$ROOT/bin/omarchy-system-sleep-lock" +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +# Each scenario gets its own mock PATH and call log, then runs the sleep lock +# with a short budget so a stalled shell cannot slow the suite down. +setup_scenario() { + scenario_dir="$tmpdir/$1" + mock_bin="$scenario_dir/bin" + call_log="$scenario_dir/calls" + state_dir="$scenario_dir/state" + notify_log="$scenario_dir/notifications" + journal_log="$scenario_dir/journal" + mkdir -p "$mock_bin" "$state_dir" + : >"$notify_log" + : >"$journal_log" + + # The budget is derived from logind, so pin the window rather than letting the + # host's own configuration decide what these scenarios are testing. + mock_logind_window 5000000 + + # Capture the desktop warning instead of firing a real one at whoever is + # running the suite. + cat >"$mock_bin/omarchy-notification-send" <>"$notify_log" +SH + chmod +x "$mock_bin/omarchy-notification-send" +} + +mock_logind_window() { + cat >"$mock_bin/busctl" <"$mock_bin/omarchy-hyprland-monitor-clamshell" <>"\$CALL_LOG" +sleep ${1:-0} +SH + chmod +x "$mock_bin/omarchy-hyprland-monitor-clamshell" +} + +# Called with no budget to exercise the value derived from logind's window. +run_sleep_lock() { + local args=() + [[ -n ${1:-} ]] && args=("$1") + + start_us=${EPOCHREALTIME//[!0-9]/} + set +e + CALL_LOG="$call_log" STATE_DIR="$state_dir" PATH="$mock_bin:$PATH" \ + "$sleep_lock" "${args[@]}" 2>"$journal_log" + exit_status=$? + set -e + elapsed_us=$((10#${EPOCHREALTIME//[!0-9]/} - 10#$start_us)) + + mapfile -t calls <"$call_log" +} + +# A responsive shell locks immediately, even when the clamshell sync stalls. +setup_scenario responsive +cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" +if [[ $* == "lock lock" ]]; then + printf 'ok\n' +elif [[ $* == "lock status" ]]; then + printf '{"secure":true}\n' +fi +SH +chmod +x "$mock_bin/omarchy-shell" +mock_clamshell 2 + +run_sleep_lock 4000 + +(( exit_status == 0 )) || + fail "sleep lock succeeds once the session reports secure" "exit: $exit_status" +pass "sleep lock succeeds once the session reports secure" + +[[ ${calls[0]} == "shell lock lock" ]] || + fail "sleep lock requests the session lock first" "first call: ${calls[0]}" +pass "sleep lock requests the session lock first" + +[[ ${calls[1]} == "clamshell" && ${calls[2]} == "shell lock status" ]] || + fail "sleep lock checks security after clamshell reconciliation" +pass "sleep lock checks security after clamshell reconciliation" + +(( elapsed_us < 1500000 )) || + fail "sleep lock bounds a stalled clamshell sync" "elapsed: ${elapsed_us}us" +pass "sleep lock bounds a stalled clamshell sync" + +# A shell that never secures the session must give up inside the budget rather +# than hold logind's delay inhibitor open. +setup_scenario never_secure +cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" +if [[ $* == "lock lock" ]]; then + printf 'ok\n' +elif [[ $* == "lock status" ]]; then + printf '{"secure":false}\n' +fi +SH +chmod +x "$mock_bin/omarchy-shell" +mock_clamshell + +run_sleep_lock 1500 + +(( exit_status != 0 )) || + fail "sleep lock reports failure when the session never secures" +pass "sleep lock reports failure when the session never secures" + +# The contract is the budget plus at most one poll interval, since the pause +# between polls is not itself clipped. Derived budgets hold back a full second +# for logind, so that overshoot is always well inside the reserve. +(( elapsed_us <= 1600000 )) || + fail "sleep lock gives up within its budget" "elapsed: ${elapsed_us}us" +pass "sleep lock gives up within its budget" + +polls=0 +for call in "${calls[@]}"; do + [[ $call == "shell lock status" ]] && (( ++polls )) +done +(( polls > 1 )) || + fail "sleep lock keeps polling until the deadline" "polls: $polls" +pass "sleep lock keeps polling until the deadline" + +# A lock request that times out may never have landed, so the wait retries it +# instead of suspending an unlocked session over one slow IPC call. +setup_scenario retry_lock +cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" + +if [[ $* == "lock lock" ]]; then + if [[ -f $STATE_DIR/requested ]]; then + touch "$STATE_DIR/locked" + printf 'ok\n' + exit 0 + fi + touch "$STATE_DIR/requested" + exit 1 +fi + +if [[ $* == "lock status" ]]; then + if [[ -f $STATE_DIR/locked ]]; then + printf '{"secure":true}\n' + else + printf '{"secure":false}\n' + fi +fi +SH +chmod +x "$mock_bin/omarchy-shell" +mock_clamshell + +run_sleep_lock 4000 + +(( exit_status == 0 )) || + fail "sleep lock retries a failed lock request" "exit: $exit_status" +pass "sleep lock retries a failed lock request" + +requests=0 +for call in "${calls[@]}"; do + [[ $call == "shell lock lock" ]] && (( ++requests )) +done +(( requests == 2 )) || + fail "sleep lock stops requesting once the lock lands" "requests: $requests" +pass "sleep lock stops requesting once the lock lands" + +# A request can land even when its IPC response times out. Pending status proves +# that Quickshell is already securing the session, so do not spend the remaining +# inhibitor budget sending the same request again. +setup_scenario pending_lock +cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" + +if [[ $* == "lock lock" ]]; then + exit 1 +fi + +if [[ $* == "lock status" ]]; then + if [[ -f $STATE_DIR/pending_seen ]]; then + printf '{"secure":true}\n' + else + touch "$STATE_DIR/pending_seen" + printf '{"secure":false,"requested":true,"pending":true,"sessionLocked":false}\n' + fi +fi +SH +chmod +x "$mock_bin/omarchy-shell" +mock_clamshell + +run_sleep_lock 4000 + +(( exit_status == 0 )) || + fail "sleep lock succeeds after observing a pending lock" "exit: $exit_status" +pass "sleep lock succeeds after observing a pending lock" + +requests=0 +for call in "${calls[@]}"; do + [[ $call == "shell lock lock" ]] && (( ++requests )) +done +(( requests == 1 )) || + fail "sleep lock does not retry an observed pending lock" "requests: $requests" +pass "sleep lock does not retry an observed pending lock" + +# The shell reports a refusal on stdout with a zero exit, so a lock it can never +# perform has to end the wait instead of burning the rest of the window on it. +setup_scenario missing_pam +cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" +if [[ $* == "lock lock" ]]; then + printf 'missing-pam\n' +fi +exit 0 +SH +chmod +x "$mock_bin/omarchy-shell" +mock_clamshell + +run_sleep_lock 4000 + +(( exit_status != 0 )) || + fail "sleep lock fails fast when the shell cannot lock at all" +(( elapsed_us < 500000 )) || + fail "sleep lock fails fast when the shell cannot lock at all" "elapsed: ${elapsed_us}us" +pass "sleep lock fails fast when the shell cannot lock at all" + +[[ ${calls[*]} != *"lock status"* ]] || + fail "sleep lock stops polling a shell that refused to lock" "calls: ${calls[*]}" +pass "sleep lock stops polling a shell that refused to lock" + +# logind suspends regardless of this exit status, so an unlocked suspend is +# otherwise invisible. The warning is the only trace the user ever sees, and the +# journal line is what makes it diagnosable after the fact. +grep -qF "did not lock before suspend" "$notify_log" || + fail "sleep lock warns that the session was left unlocked" \ + "notifications: $(< "$notify_log")" +pass "sleep lock warns that the session was left unlocked" + +grep -qF "suspending without a secure lock" "$journal_log" || + fail "sleep lock records the unlocked suspend in the journal" \ + "journal: $(< "$journal_log")" +pass "sleep lock records the unlocked suspend in the journal" + +# A never-securing shell is the scenario that runs out the whole budget, so it +# is also the one that shows which budget was derived. +never_secures() { + cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" +if [[ $* == "lock lock" ]]; then + printf 'ok\n' +elif [[ $* == "lock status" ]]; then + printf '{"secure":false,"requested":true,"pending":true,"sessionLocked":false}\n' +fi +SH + chmod +x "$mock_bin/omarchy-shell" + mock_clamshell +} + +# The drop-in only counts once logind has reloaded it, and a machine can carry +# its own override, so the budget follows whatever logind actually enforces. +setup_scenario derived_short_window +mock_logind_window 2000000 +never_secures + +run_sleep_lock + +(( elapsed_us <= 1300000 )) || + fail "sleep lock derives its budget from logind's window" "elapsed: ${elapsed_us}us" +pass "sleep lock derives its budget from logind's window" + +# Without a readable window there is no way to know what logind will tolerate, +# so fall back to the budget that was safe before the drop-in existed. +setup_scenario unreadable_window +cat >"$mock_bin/busctl" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$mock_bin/busctl" +never_secures + +run_sleep_lock + +(( elapsed_us > 1300000 && elapsed_us <= 4300000 )) || + fail "sleep lock falls back to a conservative budget" "elapsed: ${elapsed_us}us" +pass "sleep lock falls back to a conservative budget when logind cannot be read" + +# A hand-raised window must not strand a closed laptop awake in a bag. This +# scenario runs for the whole capped budget by design. +setup_scenario capped_window +mock_logind_window 600000000 +never_secures + +run_sleep_lock + +(( exit_status != 0 )) || + fail "sleep lock caps the budget a huge logind window would allow" +(( elapsed_us <= 12500000 )) || + fail "sleep lock caps the budget a huge logind window would allow" \ + "elapsed: ${elapsed_us}us" +pass "sleep lock caps the budget a huge logind window would allow" + +# The cap is only reachable because the shipped drop-in widens logind's window +# past it. Ship one without the other and the cap is dead weight. +inhibit_delay=$(sed -n 's/^InhibitDelayMaxSec=//p' "$ROOT/etc/systemd/logind.conf.d/20-inhibit-delay.conf") +budget_cap_ms=$(sed -n 's/^budget_cap_ms=//p' "$sleep_lock") + +[[ -n $inhibit_delay && -n $budget_cap_ms ]] || + fail "sleep lock cap and logind window are both declared" \ + "window: ${inhibit_delay:-unset} cap: ${budget_cap_ms:-unset}" +(( budget_cap_ms < inhibit_delay * 1000 )) || + fail "sleep lock cap leaves logind room to act" \ + "cap: ${budget_cap_ms}ms window: ${inhibit_delay}s" +pass "sleep lock cap stays inside the shipped logind inhibitor window"