#!/usr/bin/env bash # Power & Lock: one timeline, one idle card, and a power button that cannot # power the machine off on its own. # # The page was four cards that each said a true thing and together said nothing: # two idle cards holding the same three concepts under different headings, an # ordering warning that appeared as a fifth card, and two informational cards # about hardware -- a lid and a button -- neither of which could be changed. The # redesign turns the timings into one picture and makes the button adjustable. # # Three properties are worth a contract, in descending order of what it would # cost to get them wrong: # # 1. THE POWER BUTTON NEVER POWERS OFF DIRECTLY. logind is told to ignore the # key so a stray press is a question rather than an instant loss of # unsaved work; the whole point of making the action a preference is that # "Powers off" still means "opens the menu with Power Off armed, press # again". A `systemctl poweroff` anywhere in the bind path silently undoes # that, and the failure is invisible until somebody brushes the button. # 2. One idle card, not two. The AC and battery timings are the same three # concepts, and the old shape let them drift apart on screen -- a person # could read "suspend after 20 minutes" off one card while the other one # was in effect. Pinned structurally: the battery sliders live inside the # same card as their wall-power counterparts, whatever the card is called. # 3. Everything the page claims about this machine is read from something # that asked it. Hibernate is gated on logind's own CanHibernate rather # than on a guess, the inhibitor row is a live list with an empty state, # and the generated hypridle path is shown rather than described. # # Static apart from one thing: the inhibitors verb is run against a stubbed # `busctl`, so the row's filtering and ordering are exercised without asking # the live session what is holding it awake. Nothing here starts a shell, # presses a button, or reaches a real bus. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" shell_dir="$repo_dir/config/dot/quickshell" settings="$shell_dir/modules/settings" page="$settings/PowerPage.qml" timeline="$settings/IdleTimeline.qml" qmldir="$settings/qmldir" schema="$shell_dir/config/PreferenceSchema.qml" keybinds="$repo_dir/config/dot/hypr/keybinds.lua" shell_qml="$shell_dir/shell.qml" powermenu="$shell_dir/modules/powermenu/PowerMenu.qml" idle_lock="$shell_dir/services/IdleLock.qml" idle_helper="$shell_dir/scripts/panama-idle" findings=() note() { findings+=("$1"); } for file in "$page" "$timeline" "$schema" "$keybinds" "$shell_qml" "$powermenu" \ "$idle_lock" "$idle_helper" "$qmldir"; do [[ -r "$file" ]] || { printf 'power page contract: missing %s\n' "${file#"$repo_dir/"}" >&2; exit 1; } done # The page and the components only it uses. A card lifted into a component of # its own is a normal thing to do while building this, and every assertion # below about what the page shows would quietly stop meaning anything if it # only ever read PowerPage.qml. Shared rows (SliderRow, SettingsCard, and the # rest) are excluded by the same test that finds these: a component another # settings page also instantiates is not part of this page's own structure. mapfile -t page_files < <(python3 - "$page" "$settings" <<'PY' import os import re import sys page, settings = sys.argv[1], sys.argv[2] source = open(page, encoding="utf-8").read() others = [os.path.join(settings, name) for name in os.listdir(settings) if name.endswith(".qml") and os.path.join(settings, name) != page] other_text = "\n".join(open(path, encoding="utf-8").read() for path in others) files = [page] for name in sorted(set(re.findall(r"\b([A-Z][A-Za-z0-9]+) \{", source))): candidate = os.path.join(settings, name + ".qml") if not os.path.exists(candidate): continue if re.search(r"\b" + name + r" \{", other_text): continue files.append(candidate) print("\n".join(files)) PY ) # grep across the page and its own components, as one surface. page_has() { grep -Fq "$@" "${page_files[@]}"; } page_matches() { grep -Eq "$@" "${page_files[@]}"; } # ── 1. The power button ────────────────────────────────────────────────────── # # The preference's option values are read out of the schema rather than # restated here, so a fifth option cannot be added without the bind that has to # handle it. A contract that hardcoded the four would pass on the day a fifth # was added and did nothing. mapfile -t options < <(python3 - "$schema" <<'PY' import re import sys source = open(sys.argv[1], encoding="utf-8").read() match = re.search(r'\{\s*\n\s+key: "powerButtonAction".*?\n\s{8}\}', source, re.S) if not match: raise SystemExit(0) block = match.group(0) for value in re.findall(r'value: "([a-z]+)"', block): print(value) PY ) if (( ${#options[@]} == 0 )); then note 'the schema has no powerButtonAction entry with option values, so the power button is not adjustable at all' else expected=$(printf '%s\n' menu nothing poweroff suspend) actual=$(printf '%s\n' "${options[@]}" | sort) [[ "$actual" == "$expected" ]] \ || note "powerButtonAction offers [$(tr '\n' ' ' <<<"$actual")], expected menu / suspend / poweroff / nothing" fi python3 - "$schema" <<'PY' || note 'the powerButtonAction entry is not a power-group enum defaulting to the menu' import re import sys source = open(sys.argv[1], encoding="utf-8").read() match = re.search(r'\{\s*\n\s+key: "powerButtonAction".*?\n\s{8}\}', source, re.S) if not match: raise SystemExit(1) block = match.group(0) ok = 'group: "power"' in block and 'def: "menu"' in block and 'type: "enum"' in block raise SystemExit(0 if ok else 1) PY # No hypr block. This preference is read by keybinds.lua through prefs, the way # workspace rules are; a hypr block would send Hyprland a keyword that does not # exist and the write would fail on every commit. python3 - "$schema" <<'PY' && note 'powerButtonAction declares a hypr block, but there is no compositor keyword behind it -- the bind reads the preference' import re import sys source = open(sys.argv[1], encoding="utf-8").read() match = re.search(r'\{\s*\n\s+key: "powerButtonAction".*?\n\s{8}\}', source, re.S) raise SystemExit(0 if match and "hypr:" in match.group(0) else 1) PY # The bind reads it, and every value the schema offers is handled. # # "The bind path" is keybinds.lua plus anything the power-key bind hands the # decision to: whether the preference is read at config time through prefs or # at press time by a helper is A's call, and a contract that insisted on one # mechanism would have to be rewritten to allow the other. What must hold # either way is that the preference is read somewhere on the path, that every # option the schema offers is branched on there, and -- the one that matters -- # that nothing on the path can power the machine off. bind_path=("$keybinds") while IFS= read -r script; do [[ -n "$script" ]] || continue candidate="$shell_dir/scripts/$script" [[ -r "$candidate" ]] && bind_path+=("$candidate") done < <(grep -oE 'panama-[a-z-]+' "$keybinds" | sort -u) grep -lq 'powerButtonAction' "${bind_path[@]}" \ || note 'nothing the power-key bind reaches reads powerButtonAction, so the preference changes nothing' # The region of keybinds.lua that decides what the power key does: from # wherever the preference is first named through the end of the bind statement # itself, in either order. Where the branching lives in a helper instead, that # helper's whole text stands in for it. region="$(python3 - "$keybinds" <<'PY' import sys lines = open(sys.argv[1], encoding="utf-8").read().splitlines() marks = [i for i, line in enumerate(lines) if "powerButtonAction" in line or "XF86PowerOff" in line] if not marks: raise SystemExit(0) # The bind's option table can trail onto the following lines, and `locked` is # in it. Run on to the end of the statement. end = max(marks) while end + 1 < len(lines) and "})" not in lines[end]: end += 1 print("\n".join(lines[min(marks):end + 1])) PY )" decision="$region" for file in "${bind_path[@]:1}"; do grep -q 'powerButtonAction' "$file" && decision+=$'\n'"$(cat "$file")" done if [[ -z "$region" ]]; then note 'no XF86PowerOff bind and no powerButtonAction read: the power key is unbound' else # By the option's own name rather than by a quoting style: the branch may # be a Lua field, a shell case label, or a string, and which one it is is # A's business. for option in "${options[@]}"; do grep -qw "$option" <<<"$decision" \ || note "the power key bind has no branch for the \"$option\" option, so choosing it would fall through to whatever the last branch does" done grep -q 'powermenu' <<<"$decision" \ || note 'the power key bind never reaches the power menu, which is the default action' grep -q 'systemctl suspend' <<<"$decision" \ || note 'the power key bind cannot suspend, so the Suspends option does nothing' grep -q 'locked = true' <<<"$region" \ || note 'the power key bind is not locked, so it stops working on the lock screen -- the one place a power button press is most likely' # An unreadable, absent or unrecognised value opens the menu. This is the # difference between a corrupt settings file being harmless and it being a # power button that does something nobody chose. grep -qE '\*\)[^;]*\b(menu|powermenu)\b|else[^;]*\b(menu|powermenu)\b' <<<"$decision" \ || note 'an unrecognised powerButtonAction value does not fall back to the power menu, so a hand-edited settings file decides what the power key does' fi # THE assertion. Powering off is a two-press flow through the menu; a direct # poweroff would make the "Powers off" option lose unsaved work on one press. # Asserted over the whole bind path, so moving the branching into a helper # moves this check with it rather than out from under it. # # Comments are stripped first. This file explains at length that it deliberately # does NOT call systemctl poweroff, and a contract must not fail over prose that # agrees with it. for file in "${bind_path[@]}"; do sed -E 's/(^|[^:])--.*/\1/; s/^[[:space:]]*#.*//' "$file" \ | grep -qE 'systemctl[^|;&]*poweroff|loginctl[^|;&]*poweroff|\bshutdown -h\b' \ && note "${file##*/} can power the machine off directly -- the poweroff option must open the power menu with Power Off armed, so a second press is required" done # And the same thing said from the shell's side: the IPC the bind calls only # opens a menu. It must not run anything itself. python3 - "$shell_qml" <<'PY' || note "the powermenu IPC handler runs a command of its own, so a single power-key press could act without the menu's second press" import re import sys source = open(sys.argv[1], encoding="utf-8").read() match = re.search(r'IpcHandler \{\s*\n\s+target: "powermenu"(?P.*?)\n \}', source, re.S) if not match: raise SystemExit(1) body = match.group("body") if "execDetached" in body or ".run(" in body or "systemctl" in body: raise SystemExit(1) raise SystemExit(0) PY # The pre-selection argument exists, and the menu still arms rather than fires. python3 - "$shell_qml" <<'PY' || note 'the powermenu IPC takes no entry to pre-select, so the poweroff option cannot arm Power Off' import re import sys source = open(sys.argv[1], encoding="utf-8").read() match = re.search(r'IpcHandler \{\s*\n\s+target: "powermenu"(?P.*?)\n \}', source, re.S) if not match: raise SystemExit(1) raise SystemExit(0 if re.search(r'function \w+\(\s*\w+\s*:', match.group("body")) else 1) PY grep -q 'disarm' "$powermenu" \ || note 'the power menu no longer disarms its destructive entries, which is what makes powering off two presses' # ── 2. One idle card ───────────────────────────────────────────────────────── # # Structural rather than by title: whatever the card ends up called, the # battery timings must sit inside the same card as their wall-power # counterparts. Two cards is the shape this redesign exists to remove. python3 - "${page_files[@]}" <<'PY' || note 'the AC and battery idle timings are in different cards again -- one card was the point, so the two sets can never disagree on screen' import re import sys def cards_in(path): """Every SettingsCard block, sliced by brace depth from its opening line.""" lines = open(path, encoding="utf-8").read().splitlines() for index, line in enumerate(lines): if not re.match(r"\s*SettingsCard \{", line): continue depth = 0 body = [] for current in lines[index:]: body.append(current) depth += current.count("{") - current.count("}") if depth == 0: break yield "\n".join(body) cards = [card for path in sys.argv[1:] for card in cards_in(path)] source = "\n".join(open(path, encoding="utf-8").read() for path in sys.argv[1:]) pairs = [("lockMinutes", "lockMinutesBattery"), ("screenBlankMinutes", "screenBlankMinutesBattery"), ("suspendMinutes", "suspendMinutesBattery")] for ac, battery in pairs: ac_needle = f'setting: "{ac}"' battery_needle = f'setting: "{battery}"' if battery_needle not in source: continue together = any(ac_needle in card and battery_needle in card for card in cards) if not together: raise SystemExit(1) raise SystemExit(0) PY page_has 'title: "Idle behavior on battery"' \ && note 'the separate battery idle card is still here' # The timeline itself: a picture, fed the same numbers the sliders write. page_has 'IdleTimeline {' \ || note 'the Power page does not render the idle timeline' grep -q '^IdleTimeline 1.0 IdleTimeline.qml$' "$qmldir" \ || note 'IdleTimeline is not registered in the settings qmldir, so the page would fail to resolve it' python3 - "${page_files[@]}" <<'PY' || note 'the timeline is not fed from the stored idle values, so the picture and the sliders below it could disagree' import re import sys block = None for path in sys.argv[1:]: lines = open(path, encoding="utf-8").read().splitlines() for index, line in enumerate(lines): if not re.match(r"\s*IdleTimeline \{", line): continue depth = 0 body = [] for current in lines[index:]: body.append(current) depth += current.count("{") - current.count("}") if depth == 0: break block = "\n".join(body) break if block is not None: break if block is None: raise SystemExit(1) sourced = ("IdleLock." in block or "DesktopPreferences.get(" in block or "Settings." in block) raise SystemExit(0 if sourced else 1) PY # The ordering warning is drawn on the timeline now rather than being a card of # its own that appears and disappears above the controls. python3 - "${page_files[@]}" <<'PY' || note 'the lock-before-blank warning is still a card of its own rather than being rendered on the timeline' import re import sys for path in sys.argv[1:]: lines = open(path, encoding="utf-8").read().splitlines() for index, line in enumerate(lines): if not re.match(r"\s*SettingsCard \{", line): continue depth = 0 body = [] for current in lines[index:]: body.append(current) depth += current.count("{") - current.count("}") if depth == 0: break card = "\n".join(body) if "lockBeforeBlank" in card and "IdleTimeline" not in card: # A card whose entire visibility is the warning. if re.search(r"visible: IdleLock\.lockBeforeBlank\s*$", card, re.M): raise SystemExit(1) raise SystemExit(0) PY # The page and its components are presentation. Anything that shells out # belongs in a service, where one copy of it can be shared. for file in "${page_files[@]}"; do grep -qE '\bProcess\b' "$file" \ && note "${file#"$settings/"} shells out; the Power page's subprocesses belong in IdleLock and Battery" done # ── 3. What the page claims, it asked for ──────────────────────────────────── # Profile tiles: the daemon owns the value, so the page reads and writes it # rather than storing a copy, and it says when the hardware is holding back. for needle in 'PowerProfiles.profiles' 'PowerProfiles.active' 'PowerProfiles.set(' 'PowerProfiles.degraded'; do page_has "$needle" || note "the power profile tiles do not use $needle" done # The inhibitor row, and the sentence it shows when nothing is holding a lock. page_has 'IdleLock.inhibitors' \ || note 'nothing on the page lists what is keeping the machine awake' grep -q 'property var inhibitors\|property list inhibitors' "$idle_lock" \ || note 'IdleLock exposes no inhibitors, so the row would read undefined' grep -q 'inhibitors' "$idle_helper" \ || note 'panama-idle has no inhibitors verb, so there is nothing to read them from' # The verb itself, against a stubbed logind. Three properties, and the third is # the one that decides whether this row can be believed. if command -v jq >/dev/null 2>&1; then stub_dir="$(mktemp -d /tmp/panama-power-stub.XXXXXX)" trap 'rm -rf "$stub_dir"' EXIT # ListInhibitors returns a(ssssuu): what, who, why, mode, uid, pid. cat >"$stub_dir/busctl" <<'STUB' #!/usr/bin/env bash [[ -n "${PANAMA_STUB_BUSCTL_FAIL:-}" ]] && exit 1 cat <<'JSON' {"type":"a(ssssuu)","data":[[ ["sleep:idle","hypridle","Holding the screen awake for a video","delay",1000,4242], ["sleep","steam","Downloading Half-Life 3","block",1000,4243], ["shutdown","packagekit","Applying updates","block",0,4244], ["handle-lid-switch","panama-lid","An external display is connected","block",1000,4245] ]]} JSON STUB chmod +x "$stub_dir/busctl" listed="$(PATH="$stub_dir:$PATH" "$idle_helper" inhibitors 2>/dev/null)" jq -e . >/dev/null 2>&1 <<<"$listed" \ || note "the inhibitors verb did not emit valid JSON (got: $listed)" # A shutdown hold says nothing about whether the screen will blank, and # listing it would put an entry on the card that explains nothing. jq -e 'map(.who) | index("packagekit") == null' >/dev/null 2>&1 <<<"$listed" \ || note 'a shutdown inhibitor is listed as a reason the machine is awake, which it is not' for who in hypridle steam panama-lid; do jq -e --arg who "$who" 'map(.who) | index($who) != null' >/dev/null 2>&1 <<<"$listed" \ || note "the inhibitor list drops $who, which does bear on sleeping or idling" done # Panama's own hold is the one entry a person could act on, so hiding it # would be the least honest omission available. jq -e 'map(.who) | index("panama-lid") != null' >/dev/null 2>&1 <<<"$listed" \ || note "Panama's own lid inhibitor is filtered out of the list it belongs in" # A delay inhibitor holds sleep for a few seconds and then the machine # sleeps anyway. Ranking those above a block would put the things that do # NOT keep the machine awake at the top of a card about what does. jq -e '[.[] | .mode == "delay"] | (index(true) // length) >= (rindex(false) // -1)' \ >/dev/null 2>&1 <<<"$listed" \ || note 'delay inhibitors sort above blocking ones, so the entries that do not keep the machine awake lead the list' # And the failure that matters: not being able to ask must not read as # "nothing is holding it". if PATH="$stub_dir:$PATH" PANAMA_STUB_BUSCTL_FAIL=1 "$idle_helper" inhibitors 2>/dev/null \ | grep -q '^\[\]$'; then note 'a logind that could not be reached produces an empty list, which claims nothing holds the machine awake rather than admitting it could not look' fi PATH="$stub_dir:$PATH" PANAMA_STUB_BUSCTL_FAIL=1 "$idle_helper" inhibitors >/dev/null 2>&1 \ && note 'the inhibitors verb succeeds when logind cannot be reached, so the caller cannot tell an empty list from a failed one' rm -rf "$stub_dir" trap - EXIT fi page_matches -i 'holds a wake lock' \ || note 'the inhibitor row has no empty state, so a machine holding nothing shows an empty row rather than saying so' # Hibernate: gated on logind's answer, and honest about why the answer is no. page_has 'canHibernate' \ || note 'the hibernate row is not gated on whether this machine can hibernate' page_matches -i 'zram' \ || note 'the hibernate row does not say why this machine cannot hibernate, which is the only reason the row is there' # ...and the property it reads has to trace back to logind, not to a constant. probe="$(grep -rl 'property bool canHibernate' "$shell_dir/services" "$shell_dir/modules" 2>/dev/null)" if [[ -z "$probe" ]]; then note 'nothing declares canHibernate, so the gate is a guess' elif ! grep -lq 'CanHibernate' $probe; then note 'canHibernate is set without asking logind CanHibernate, so a machine that can hibernate would be told it cannot' fi # The lid card stays read-only by design (see LidPolicy's own argument). page_has 'LidPolicy.' || note 'the lid card reads no live lid policy' page_matches 'setting: "lid' \ && note 'the lid grew an override, which LidPolicy deliberately does not have -- a lid set to never suspend is a laptop that cooks in a bag' # The power button row is the schema key, not prose about a fixed behavior. page_has 'setting: "powerButtonAction"' \ || note 'the power button is still described rather than adjustable' # Management says where the generated file went, rather than describing it. page_has 'IdleLock.generatedPath' \ || note 'the Management card does not show the generated hypridle path' # The Power page changes settings. It is not a second power menu: nothing here # suspends, hibernates or powers the machine off. if page_matches 'systemctl", *"(suspend|hibernate|poweroff|reboot)|systemctl (suspend|hibernate|poweroff|reboot)'; then note 'the Power page runs a power command of its own -- the power menu owns those, with its two-press arming' fi if (( ${#findings[@]} > 0 )); then printf 'power page contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'power page contract: PASS\n'