#!/usr/bin/env bash # Generates hypridle's configuration from Panama's shared settings. # # Why this exists rather than editing hypridle.conf directly: ~/.config/hypr is # a symlink into the Panama repository, so writing hypridle.conf at runtime # would dirty a tracked file with machine state. The generated config therefore # lives under XDG_STATE_HOME, and a systemd drop-in points hypridle at it with # `-c`. The repository's hypridle.conf remains the shipped default and is what # runs if this has never been set up. # # panama-idle apply regenerate and restart hypridle # panama-idle status report as JSON what is in effect # panama-idle inhibitors what is currently holding sleep or idle off # panama-idle install write the systemd drop-in (idempotent) # panama-idle remove remove the drop-in and fall back to the shipped config # # All values are read from the settings store and clamped here as well as in the # schema, because this script is also reachable from a shell. set -euo pipefail settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama" generated="$state_dir/hypridle.conf" dropin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/hypridle.service.d" dropin="$dropin_dir/panama.conf" # Set by generate() to the mktemp path it is currently writing, so concurrent # invocations (e.g. rapid settings changes each spawning `apply`) never share # a tmp file and interleave writes into a corrupt hypridle.conf. Cleared once # the atomic mv below lands, so this is a no-op on a normal exit. generated_tmp="" cleanup() { rm -f "$generated_tmp" 2>/dev/null || true; } trap cleanup EXIT read_setting() { local key="$1" fallback="$2" [[ -r "$settings" ]] || { printf '%s' "$fallback"; return; } jq -r --arg k "$key" --arg d "$fallback" \ 'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \ "$settings" 2>/dev/null || printf '%s' "$fallback" } clamp_int() { local value="$1" low="$2" high="$3" fallback="$4" [[ "$value" =~ ^-?[0-9]+$ ]] || { printf '%s' "$fallback"; return; } (( value < low )) && value="$low" (( value > high )) && value="$high" printf '%s' "$value" } # hypridle has no concept of a power source: one config, one set of timeouts. # So rather than maintaining two configs and swapping them, the single config # is regenerated whenever the machine moves between wall power and battery, and # this decides which set of keys it is built from. IdleLock watches # Battery.acChanged and calls `apply` for exactly this reason. # # A machine with no battery never consults the battery keys at all, which is # what keeps a desktop's generated config byte-for-byte what it was before any # of this existed. on_battery() { local hw="${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hw" [[ -x "$hw" ]] || return 1 "$hw" battery || return 1 ! "$hw" ac } load() { local suffix="" if on_battery; then suffix="Battery" fi # An unwritten key falls back to ITS OWN schema default, never to the # other power source's value. The battery keys once fell back to their AC # counterparts, which sounded protective and produced a lie instead: the # Power page shows the schema default (suspend at 20) for an unwritten # battery key, while this generator quietly used the AC value (never), so # a fresh laptop displayed one behavior and shipped another -- and # discharged to zero in a bag. Whatever the sliders show is what must be # generated; these fallbacks are pinned to the schema by # tests/hypr/idle-defaults-contract. if [[ "$suffix" == "Battery" ]]; then blank_min="$(clamp_int "$(read_setting screenBlankMinutesBattery 2)" 0 120 2)" lock_min="$(clamp_int "$(read_setting lockMinutesBattery 5)" 0 240 5)" suspend_min="$(clamp_int "$(read_setting suspendMinutesBattery 20)" 0 480 20)" else blank_min="$(clamp_int "$(read_setting screenBlankMinutes 5)" 0 120 5)" lock_min="$(clamp_int "$(read_setting lockMinutes 10)" 0 240 10)" suspend_min="$(clamp_int "$(read_setting suspendMinutes 0)" 0 480 0)" fi lock_on_sleep="$(read_setting lockOnSleep true)" [[ "$lock_on_sleep" == "true" || "$lock_on_sleep" == "false" ]] || lock_on_sleep=true } generate() { load mkdir -p "$state_dir" # Unique per invocation, in the same directory as the destination so the # final mv is an atomic same-filesystem rename rather than a copy. generated_tmp="$(mktemp "$generated.XXXXXX")" { printf '# Generated by panama-idle from %s\n' "$settings" printf '# Power source at generation: %s\n' \ "$(on_battery && echo battery || echo 'wall power')" printf '# Do not edit: it is rewritten whenever the idle settings change.\n' printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n' printf 'general {\n' printf ' lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run\n' if [[ "$lock_on_sleep" == "true" ]]; then printf ' before_sleep_cmd = loginctl lock-session\n' fi printf " after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = \"on\" })'\n" printf ' inhibit_sleep = 2\n' printf '}\n' # On battery, dim before blanking -- GNOME's single largest idle # battery saver, and the gentle warning that the screen is about to # go. Two-thirds of the way to blank, floor of 30 seconds; -s saves # the level and -r restores exactly it, so a person's brightness # choice survives the round trip. Only where a real backlight exists: # a desktop's DDC monitor is not worth waking over this. if on_battery && (( blank_min > 0 )) \ && command -v brightnessctl >/dev/null 2>&1 \ && [[ -n "$(ls /sys/class/backlight 2>/dev/null)" ]]; then dim_at=$(( blank_min * 60 * 2 / 3 )) (( dim_at < 30 )) && dim_at=30 printf '\n# %s seconds -> dim, undone on activity.\n' "$dim_at" printf 'listener {\n' printf ' timeout = %s\n' "$dim_at" printf ' on-timeout = brightnessctl -c backlight -s set 30%%\n' printf ' on-resume = brightnessctl -c backlight -r\n' printf '}\n' fi if (( blank_min > 0 )); then printf '\n# %s minutes -> screen off.\n' "$blank_min" printf 'listener {\n' printf ' timeout = %s\n' "$(( blank_min * 60 ))" printf " on-timeout = hyprctl dispatch 'hl.dsp.dpms({ action = \"off\" })'\n" printf " on-resume = hyprctl dispatch 'hl.dsp.dpms({ action = \"on\" })'\n" printf '}\n' fi if (( lock_min > 0 )); then printf '\n# %s minutes -> lock.\n' "$lock_min" printf 'listener {\n' printf ' timeout = %s\n' "$(( lock_min * 60 ))" printf ' on-timeout = loginctl lock-session\n' printf '}\n' fi if (( suspend_min > 0 )); then printf '\n# %s minutes -> suspend.\n' "$suspend_min" printf 'listener {\n' printf ' timeout = %s\n' "$(( suspend_min * 60 ))" printf ' on-timeout = systemctl suspend\n' printf '}\n' fi } >"$generated_tmp" mv "$generated_tmp" "$generated" generated_tmp="" } # What is currently holding sleep or idle off, as a JSON array of # { who, why, what, mode }. # # The Power page can promise a timeline all it likes; if something in the # session holds a wake lock, the timeline is not what will happen. This is the # honest substitute for the conditional suspend rules hypridle cannot express: # rather than inventing rules about when not to sleep, say who is already # saying it. # # Read from logind over D-Bus rather than by parsing `systemd-inhibit --list`. # That table is a padded, human-facing layout whose `why` column contains # spaces and whose `who` column is a free string the inhibiting program picks, # so there is no column count that reliably splits it -- and systemd documents # it as display output, not an interface. ListInhibitors returns the same rows # as typed data. `--json=short` plus jq is the whole parser. # # `mode` is carried through because it is the difference between a program that # stops the machine sleeping and one that merely asks for a moment on the way # down. A `delay` inhibitor holds sleep for at most InhibitDelayMaxSec and then # the machine sleeps anyway; NetworkManager, UPower and hypridle all hold one # permanently, and listing those as reasons the machine is awake would be a # lie the size of the card they appear on. Only `block` keeps a machine up. # # Panama's own lid inhibitor is included rather than filtered out. It IS one of # the reasons a docked machine stays awake, and a list that quietly omits the # desktop's own hold would be the one entry a person could not act on. # # Prints NOTHING and exits non-zero when logind could not be asked, so the # caller can tell "found nothing" from "could not look". An empty array on # failure would claim that nothing holds the machine awake, which is the wrong # way to be wrong, and it would claim it in a shape the reader cannot question. cmd_inhibitors() { local raw raw="$(busctl --json=short call org.freedesktop.login1 /org/freedesktop/login1 \ org.freedesktop.login1.Manager ListInhibitors 2>/dev/null)" || return 1 # ListInhibitors returns a(ssssuu): what, who, why, mode, uid, pid. Only # the holds that bear on sleeping or idling are kept -- a shutdown or # power-key inhibitor says nothing about whether the screen will blank. # handle-lid-switch earns its place for the same reason Panama's own hold # does: on a docked laptop it is exactly why the machine is still up. jq -c ' [ .data[0][] | { what: .[0], who: .[1], why: .[2], mode: .[3] } | select(.what | split(":") | any(. == "sleep" or . == "idle" or . == "handle-lid-switch")) | { who, why, what, mode } ] | sort_by(.mode == "delay", .who) ' <<<"$raw" 2>/dev/null || return 1 } install_dropin() { mkdir -p "$dropin_dir" cat >"$dropin" </dev/null || printf unknown)" \ "$(on_battery && printf battery || printf ac)" \ "$blank_min" "$lock_min" "$suspend_min" "$lock_on_sleep" "$generated" ;; inhibitors) cmd_inhibitors ;; *) printf 'usage: panama-idle [apply|install|remove|status|inhibitors]\n' >&2 exit 2 ;; esac