Draw idle as one timeline, and let the power button answer to its owner
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -15,6 +15,12 @@
|
||||
# 3. The charge-limit control appears only where the firmware has one.
|
||||
# 4. The threshold write goes through panama-sudo with a reason, never bare
|
||||
# sudo, and is read back rather than assumed.
|
||||
# 5. Health -- design-capacity percentage and charge cycles -- is reported
|
||||
# where sysfs reports it and is SILENT where it does not. Most of these
|
||||
# files are optional and plenty of firmware omits them, so the tempting
|
||||
# failure is a tile reading "100% of design capacity, 0 cycles" on a
|
||||
# three-year-old battery that simply never said. That is worse than no
|
||||
# tile: it is a confident wrong answer about whether hardware is dying.
|
||||
#
|
||||
# The helper is driven against fixture sysfs trees; the QML side is pinned
|
||||
# statically, since a battery cannot be simulated into the running shell.
|
||||
@@ -122,17 +128,110 @@ PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold 10
|
||||
PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold abc >/dev/null 2>&1 \
|
||||
&& note 'a non-numeric threshold was accepted'
|
||||
|
||||
# ── 5. The QML side hides itself ─────────────────────────────────────────────
|
||||
# ── 5. Health, where the firmware reports it ─────────────────────────────────
|
||||
#
|
||||
# Two sysfs spellings for the same fact, depending on whether the driver
|
||||
# reports energy or charge. Both have to work, or half the laptops in the world
|
||||
# get a blank tile.
|
||||
|
||||
with_health() {
|
||||
local name="$1" prefix="$2" full="$3" design="$4" cycles="${5:-}"
|
||||
local root
|
||||
root="$(fixture "$name" 72 1)"
|
||||
printf '%s\n' "$full" >"$root/sys/class/power_supply/BAT0/${prefix}_full"
|
||||
printf '%s\n' "$design" >"$root/sys/class/power_supply/BAT0/${prefix}_full_design"
|
||||
[[ -n "$cycles" ]] && printf '%s\n' "$cycles" >"$root/sys/class/power_supply/BAT0/cycle_count"
|
||||
printf '%s\n' "$root"
|
||||
}
|
||||
|
||||
# A pack that has lost a tenth of its design capacity, in energy units.
|
||||
energy="$(with_health energy energy 45000000 50000000 312)"
|
||||
status="$(ask "$energy" status)"
|
||||
[[ "$(field "$status" .healthPercent)" == "90" ]] \
|
||||
|| note "health is not computed from energy_full against energy_full_design (got $(field "$status" .healthPercent))"
|
||||
[[ "$(field "$status" .cycleCount)" == "312" ]] \
|
||||
|| note "the charge cycle count is not reported (got $(field "$status" .cycleCount))"
|
||||
|
||||
# The same pack, on a driver that reports charge rather than energy.
|
||||
charge="$(with_health charge charge 45000000 50000000 312)"
|
||||
status="$(ask "$charge" status)"
|
||||
[[ "$(field "$status" .healthPercent)" == "90" ]] \
|
||||
|| note 'health is not read from the charge_full spelling, so a driver that reports charge shows no health at all'
|
||||
|
||||
# A pack that reports neither. Absent, null, or zero -- anything but a number
|
||||
# that looks like an answer.
|
||||
plain="$(ask "$laptop" status)"
|
||||
for key in healthPercent cycleCount; do
|
||||
value="$(field "$plain" ".$key")"
|
||||
case "$value" in
|
||||
""|null|0) ;;
|
||||
*) note "a battery whose firmware reports no $key was given one anyway ($value), which is a confident wrong answer about whether the hardware is dying" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# And a machine with no battery at all invents nothing.
|
||||
desktop_status="$(ask "$desktop" status)"
|
||||
for key in healthPercent cycleCount; do
|
||||
value="$(field "$desktop_status" ".$key")"
|
||||
case "$value" in
|
||||
""|null|0) ;;
|
||||
*) note "a machine with no battery reported a $key of $value" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# A design capacity of zero would divide by it. Firmware does report this.
|
||||
zeroed="$(with_health zeroed energy 45000000 0)"
|
||||
status="$(ask "$zeroed" status)"
|
||||
value="$(field "$status" .healthPercent)"
|
||||
case "$value" in
|
||||
""|null|0) ;;
|
||||
*) note "a zero design capacity produced a health percentage of $value" ;;
|
||||
esac
|
||||
|
||||
# The status JSON stays parseable in every one of those cases -- an empty
|
||||
# substitution would have made it valid-looking but wrong above, and invalid
|
||||
# here.
|
||||
for candidate in "$energy" "$charge" "$laptop" "$desktop" "$zeroed"; do
|
||||
jq -e . >/dev/null 2>&1 <<<"$(ask "$candidate" status)" \
|
||||
|| note 'status stopped emitting valid JSON once the health fields were added'
|
||||
done
|
||||
|
||||
# The service carries them through, and null-safely: `null > 0` is false in
|
||||
# QML, which is what makes an absent reading hide rather than render as an
|
||||
# empty tile.
|
||||
for property in healthPercent cycleCount; do
|
||||
grep -q "property .*$property" "$service" \
|
||||
|| note "the battery service does not expose $property, so the tile would read undefined"
|
||||
done
|
||||
|
||||
# ── 6. The QML side hides itself ─────────────────────────────────────────────
|
||||
|
||||
grep -q 'property bool available' "$service" \
|
||||
|| note 'the battery service has no availability flag'
|
||||
grep -q 'Settings.showBattery && Battery.available' "$cluster" \
|
||||
|| note 'the bar indicator does not gate on both the preference and the hardware'
|
||||
grep -q 'visible: Battery.available' "$page" \
|
||||
# The Power page and the components it is built from. A card lifted into a
|
||||
# component of its own is a normal thing to do, and every assertion below would
|
||||
# quietly stop meaning anything if it only ever read PowerPage.qml.
|
||||
power_surface=("$page")
|
||||
for candidate in "$(dirname "$page")"/{Power,Battery,Idle}*.qml; do
|
||||
[[ -r "$candidate" && "$candidate" != "$page" ]] && power_surface+=("$candidate")
|
||||
done
|
||||
|
||||
grep -q 'visible: Battery.available' "${power_surface[@]}" \
|
||||
|| note 'the Power page battery card does not hide on a machine without one'
|
||||
grep -q 'visible: Battery.chargeLimitSupported' "$page" \
|
||||
grep -q 'visible: Battery.chargeLimitSupported' "${power_surface[@]}" \
|
||||
|| note 'the charge limit control does not hide where the firmware has none'
|
||||
|
||||
# The health tiles follow the same rule as everything else here: a reading the
|
||||
# firmware did not give is a tile that is not drawn.
|
||||
for property in healthPercent cycleCount; do
|
||||
grep -q "Battery.$property" "${power_surface[@]}" \
|
||||
|| note "the Power page never shows $property, so the battery health the helper reads goes nowhere"
|
||||
grep -qE "visible: .*Battery\.$property" "${power_surface[@]}" \
|
||||
|| note "the $property tile is drawn unconditionally, so a battery that reports none shows an empty one"
|
||||
done
|
||||
|
||||
# The alias layer has to carry the key, or the binding silently reads undefined
|
||||
# and the indicator never appears. This exact mistake was made writing it.
|
||||
for key in showBattery batteryLowPercent batteryCriticalPercent; do
|
||||
|
||||
Executable
+496
@@ -0,0 +1,496 @@
|
||||
#!/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'
|
||||
+134
-1
@@ -21,8 +21,17 @@
|
||||
# 3. A docked laptop holds one, and it is the right kind.
|
||||
# 4. Locking on the way down is not this code's job -- hypridle's
|
||||
# before_sleep_cmd already does it -- and must not be quietly duplicated.
|
||||
# 5. Opening the lid restores the panel as it was CONFIGURED, not as a
|
||||
# four-key approximation of it. `open` used to emit a rule carrying
|
||||
# output/mode/position/scale and nothing else, and a Hyprland monitor rule
|
||||
# replaces the previous rule for that output whole -- so a docked laptop
|
||||
# whose internal panel had been set to 10-bit, wide gamut, rotated, or
|
||||
# placed at a particular position lost every one of those the first time
|
||||
# the lid was closed and reopened. Silently, and only on a machine with a
|
||||
# lid, which is the combination that keeps a bug alive.
|
||||
#
|
||||
# Driven with stubbed predicates. The end-to-end behavior of a real lid needs a
|
||||
# Driven with stubbed predicates, and for (5) a stubbed `hyprctl` that records
|
||||
# the rule instead of applying it. The end-to-end behavior of a real lid needs a
|
||||
# machine with a lid; see the header of the helper.
|
||||
|
||||
set -uo pipefail
|
||||
@@ -124,6 +133,130 @@ grep -q 'before_sleep_cmd' "$hypridle" \
|
||||
uncommented "$helper" | grep -q 'loginctl lock-session\|hyprlock' \
|
||||
&& note 'the lid helper locks the session itself, duplicating what hypridle already does on every sleep'
|
||||
|
||||
# ── 5. Opening the lid restores the whole configured record ──────────────────
|
||||
#
|
||||
# Everything below runs against a stubbed `hyprctl`, so nothing here reaches the
|
||||
# compositor: the stub answers the monitor query from a fixture and writes the
|
||||
# rule it was asked to apply into a file. `eval` is what the helper uses (a
|
||||
# runtime rule, gone at the next reload) rather than `keyword`, which would
|
||||
# persist -- so even a real run of this would be recoverable; it still does not
|
||||
# happen.
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
emitted="$work/emitted"
|
||||
config_home="$work/config"
|
||||
mkdir -p "$config_home/panama"
|
||||
|
||||
cat >"$fake/hyprctl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
# The monitor query: one internal panel and one external display.
|
||||
if [[ "\$1" == "-j" ]]; then
|
||||
printf '%s\n' '[{"name":"eDP-1"},{"name":"DP-2"}]'
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "\$*" >>"$emitted"
|
||||
STUB
|
||||
chmod +x "$fake/hyprctl"
|
||||
|
||||
# `open` with whatever this fixture stores for the internal panel.
|
||||
open_with() {
|
||||
: >"$emitted"
|
||||
printf '%s' "$1" >"$config_home/panama/settings.json"
|
||||
PATH="$fake:$PATH" PANAMA_PATH="$fake" XDG_CONFIG_HOME="$config_home" \
|
||||
"$helper" open >/dev/null 2>&1
|
||||
cat "$emitted" 2>/dev/null
|
||||
}
|
||||
|
||||
carries() {
|
||||
grep -Fq "$2" <<<"$1" \
|
||||
|| note "opening the lid emitted no $3 (rule was: $(tr -d '\n' <<<"$1"))"
|
||||
}
|
||||
|
||||
# A fully described panel. Every one of these fields is something the
|
||||
# Displays page can write and the old four-key rule threw away.
|
||||
full='{"displays":{"eDP-1":{"mode":"2880x1800@120","scale":2,"transform":1,
|
||||
"x":1920,"y":0,"primary":false,"vrrMode":1,"colorProfile":"wide",
|
||||
"bitdepth":10,"sdrBrightness":1.2,"sdrSaturation":0.9}}}'
|
||||
rule="$(open_with "$full")"
|
||||
|
||||
[[ -n "$rule" ]] || note 'opening the lid on a laptop emitted no monitor rule at all'
|
||||
carries "$rule" 'hl.monitor' 'monitor rule'
|
||||
carries "$rule" 'eDP-1' 'output name'
|
||||
carries "$rule" '2880x1800@120' 'stored mode'
|
||||
# Position comes from the stored coordinates. "auto" here would move the
|
||||
# panel out from under the arrangement the user dragged.
|
||||
carries "$rule" '1920x0' 'position from the stored x and y'
|
||||
carries "$rule" 'scale = 2' 'stored scale'
|
||||
carries "$rule" 'transform = 1' 'stored rotation'
|
||||
carries "$rule" 'vrr = 1' 'stored variable refresh rate'
|
||||
carries "$rule" 'bitdepth = 10' 'stored bit depth'
|
||||
carries "$rule" 'cm = "wide"' 'stored colour profile'
|
||||
carries "$rule" 'sdrbrightness = 1.2' 'stored SDR brightness'
|
||||
carries "$rule" 'sdrsaturation = 0.9' 'stored SDR saturation'
|
||||
|
||||
# jq prints an absent key as the string "null", which reaches a rule as a
|
||||
# value the compositor will reject or, worse, accept.
|
||||
grep -q 'null' <<<"$rule" \
|
||||
&& note 'the emitted rule contains a null, so an unset field was written out rather than left off'
|
||||
|
||||
# ── Invalid values drop one at a time, and geometry survives ─────────────
|
||||
#
|
||||
# The two failure directions monitors.lua chose, and the reason they
|
||||
# differ: an unreadable colour costs a shade, so it drops on its own and
|
||||
# the arrangement stands. Geometry is the opposite -- guessing half of it
|
||||
# can strand an output where no cursor reaches -- so a bad one refuses the
|
||||
# whole record and the panel comes back on its preferred mode.
|
||||
broken='{"displays":{"eDP-1":{"mode":"2880x1800@120","scale":2,"transform":1,
|
||||
"x":1920,"y":0,"primary":false,"vrrMode":7,"colorProfile":"chartreuse",
|
||||
"bitdepth":12,"sdrBrightness":9,"sdrSaturation":"a lot"}}}'
|
||||
rule="$(open_with "$broken")"
|
||||
|
||||
carries "$rule" '2880x1800@120' 'mode, which is valid and must survive a bad colour profile'
|
||||
carries "$rule" '1920x0' 'position, which is valid and must survive a bad colour profile'
|
||||
carries "$rule" 'scale = 2' 'scale, which is valid and must survive a bad colour profile'
|
||||
carries "$rule" 'transform = 1' 'rotation, which is valid and must survive a bad colour profile'
|
||||
for bad in 'vrr = 7' 'chartreuse' 'bitdepth = 12' 'sdrbrightness = 9' 'a lot'; do
|
||||
grep -Fq "$bad" <<<"$rule" \
|
||||
&& note "an out-of-range value reached the compositor: $bad"
|
||||
done
|
||||
|
||||
# Bad geometry takes the record down with it, back to the panel's own
|
||||
# preferred mode -- never a partially honoured rule.
|
||||
for field in '"transform":9' '"scale":7' '"mode":"enormous"'; do
|
||||
rule="$(open_with "{\"displays\":{\"eDP-1\":{\"mode\":\"2880x1800@120\",
|
||||
\"scale\":2,\"transform\":1,${field}}}}")"
|
||||
carries "$rule" 'mode = "preferred"' "a fallback to the preferred mode for a record with $field"
|
||||
grep -Fq '2880x1800@120' <<<"$rule" \
|
||||
&& note "a record with $field was half-honoured: its mode was applied anyway"
|
||||
done
|
||||
|
||||
# ── No stored position means automatic placement ─────────────────────────
|
||||
legacy='{"displays":{"eDP-1":{"mode":"2880x1800@120","scale":2,"transform":0}}}'
|
||||
rule="$(open_with "$legacy")"
|
||||
carries "$rule" 'position = "auto"' 'automatic position for a record with no stored coordinates'
|
||||
carries "$rule" '2880x1800@120' 'mode from a record predating the layout fields'
|
||||
grep -Fq 'x0' <<<"$rule" \
|
||||
&& note 'a record with no stored coordinates produced a position anyway'
|
||||
|
||||
# A half-written position is refused the way monitors.lua refuses it:
|
||||
# guessing the other half can strand an output where nothing can reach it.
|
||||
half='{"displays":{"eDP-1":{"mode":"2880x1800@120","scale":2,"transform":0,"x":1920}}}'
|
||||
rule="$(open_with "$half")"
|
||||
carries "$rule" 'position = "auto"' 'automatic position for a half-written record'
|
||||
|
||||
# ── Nothing stored at all falls back to the panel's own preference ───────
|
||||
rule="$(open_with '{}')"
|
||||
carries "$rule" 'mode = "preferred"' 'preferred mode when nothing is stored'
|
||||
carries "$rule" 'position = "auto"' 'automatic position when nothing is stored'
|
||||
carries "$rule" 'scale = "auto"' 'automatic scale when nothing is stored'
|
||||
|
||||
# ── It stays a runtime rule ──────────────────────────────────────────────
|
||||
# `hyprctl keyword` would write the approximation into the compositor's
|
||||
# live configuration, where a reload would not undo it.
|
||||
uncommented "$helper" | grep -q 'hyprctl keyword' \
|
||||
&& note 'the lid helper applies monitor rules with keyword rather than eval, so a wrong rule would outlive a reload'
|
||||
fi
|
||||
|
||||
# ── The service that drives it ───────────────────────────────────────────────
|
||||
|
||||
[[ -r "$service" ]] || note 'LidPolicy.qml is missing, so nothing notices a display being connected'
|
||||
|
||||
Reference in New Issue
Block a user