497 lines
22 KiB
Bash
Executable File
497 lines
22 KiB
Bash
Executable File
#!/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<body>.*?)\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<body>.*?)\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<var> 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'
|