Files
blomarchy/bin/blob-system-sleep

125 lines
4.6 KiB
Bash
Executable File

#!/bin/bash
# blob:summary=Lock before suspend and wait for the session lock to become secure
# blob:group=system
# blob:hidden=true
# 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
# 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##* }
# 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))
BLOB_SHELL_IPC_TIMEOUT="$seconds" \
timeout --kill-after=0.1s "$seconds" blob-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 \
blob-hypr-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 'blob-system-sleep: suspending without a secure lock (%s)\n' \
"$1" >&2
blob-notify-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
# 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
sleep "$poll_interval"
done
report_unsecured "the shell did not secure the session within ${budget_ms}ms"