Notice the battery, and the machine it is or is not in
Panama had no idea whether it was running on a laptop. No upower, no battery, no lid, no AC: hypridle.conf says "This is a desktop" in its own header, and that was true of the code as well as the machine. panama-hw answers hardware questions one at a time, exits 0 or 1, and prints nothing, so scripts, services and contracts all ask the same way. The definition the rest of the laptop work hangs on is one line: clamshell is lid-closed AND an external monitor. A machine with no mains supply at all reports as being on wall power, because a desktop cannot run out of it. The battery service follows Vitals: sysfs through FileView, an availability flag, and no subprocess on the timer. Globbing is the one thing QML cannot do -- a battery is BAT0 or BAT1 or CMB0, mains is AC or ADP1 or ACAD -- so panama-battery resolves the names once and the shell reads the files directly after. Nothing falls back to a plausible zero: a desktop shows no indicator, no card, and no charge limit control where the firmware has no ceiling. Also repairs two contracts that were already failing and had not been noticed, because only the full suite runs them. The dependency scanner treated line-initial variable assignments, case labels, comments and heredoc bodies as commands, and `count`, `host`, `cancel` and `import` are all real binaries on Fedora, so `command -v` could not filter them out. It now drops comments and heredoc bodies and requires a command to be followed by whitespace. Verified it still catches a genuinely undeclared dependency rather than passing quietly. The launcher command contract had not been told about the fourteen commands added earlier today.
This commit is contained in:
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The battery, and the machines that do not have one.
|
||||
#
|
||||
# This is the first thing Panama has shipped that only exists on some hardware,
|
||||
# and the failure that matters is not a wrong percentage -- it is a desktop
|
||||
# growing battery chrome, or a laptop showing a confident 0% because a file
|
||||
# could not be read. So the properties pinned here are mostly about absence:
|
||||
#
|
||||
# 1. No battery means `available` is false and every surface hides. Not 0%,
|
||||
# not "Unknown" in the bar, not an empty card on the Power page.
|
||||
# 2. A machine with no mains supply at all is on wall power. A desktop must
|
||||
# never be treated as running on battery, or every battery-specific idle
|
||||
# timing would apply to it.
|
||||
# 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.
|
||||
#
|
||||
# The helper is driven against fixture sysfs trees; the QML side is pinned
|
||||
# statically, since a battery cannot be simulated into the running shell.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-battery"
|
||||
service="$repo_dir/config/dot/quickshell/services/Battery.qml"
|
||||
cluster="$repo_dir/config/dot/quickshell/modules/bar/StatusCluster.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/PowerPage.qml"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
aliases="$repo_dir/config/dot/quickshell/config/Settings.qml"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -x "$helper" ]] || { printf 'battery contract: %s is not executable\n' "$helper" >&2; exit 1; }
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# A fake machine. `battery <pct>` and `mains <0|1>` are both optional, and
|
||||
# leaving one out means the machine genuinely does not have it.
|
||||
fixture() {
|
||||
local name="$1" battery="${2:-}" mains="${3:-}" threshold="${4:-}"
|
||||
local root="$work/$name"
|
||||
mkdir -p "$root/sys/class/power_supply"
|
||||
if [[ -n "$battery" ]]; then
|
||||
mkdir -p "$root/sys/class/power_supply/BAT0"
|
||||
printf 'Battery\n' >"$root/sys/class/power_supply/BAT0/type"
|
||||
printf '%s\n' "$battery" >"$root/sys/class/power_supply/BAT0/capacity"
|
||||
printf 'Discharging\n' >"$root/sys/class/power_supply/BAT0/status"
|
||||
[[ -n "$threshold" ]] && printf '%s\n' "$threshold" \
|
||||
>"$root/sys/class/power_supply/BAT0/charge_control_end_threshold"
|
||||
fi
|
||||
if [[ -n "$mains" ]]; then
|
||||
mkdir -p "$root/sys/class/power_supply/AC0"
|
||||
printf 'Mains\n' >"$root/sys/class/power_supply/AC0/type"
|
||||
printf '%s\n' "$mains" >"$root/sys/class/power_supply/AC0/online"
|
||||
fi
|
||||
printf '%s\n' "$root"
|
||||
}
|
||||
|
||||
ask() {
|
||||
local root="$1"; shift
|
||||
PANAMA_HW_SYS="$root/sys" PANAMA_PATH="$repo_dir" "$helper" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
field() { jq -r "$2" <<<"$1" 2>/dev/null; }
|
||||
|
||||
# ── 1. A desktop ─────────────────────────────────────────────────────────────
|
||||
|
||||
desktop="$(fixture desktop)"
|
||||
status="$(ask "$desktop" status)"
|
||||
[[ "$(field "$status" .available)" == "false" ]] \
|
||||
|| note 'a machine with no battery reports one as available'
|
||||
[[ "$(field "$status" .acOnline)" == "true" ]] \
|
||||
|| note 'a machine with no mains supply is reported as running on battery'
|
||||
|
||||
paths="$(ask "$desktop" paths)"
|
||||
[[ "$(field "$paths" .battery)" == "" ]] \
|
||||
|| note 'a machine with no battery resolves a battery path anyway'
|
||||
|
||||
# ── 2. A laptop ──────────────────────────────────────────────────────────────
|
||||
|
||||
laptop="$(fixture laptop 64 1)"
|
||||
status="$(ask "$laptop" status)"
|
||||
[[ "$(field "$status" .available)" == "true" ]] || note 'a battery was not detected'
|
||||
[[ "$(field "$status" .percent)" == "64" ]] \
|
||||
|| note "the charge level is wrong (got $(field "$status" .percent))"
|
||||
[[ "$(field "$status" .acOnline)" == "true" ]] || note 'a plugged-in laptop reads as unplugged'
|
||||
|
||||
unplugged="$(fixture unplugged 41 0)"
|
||||
status="$(ask "$unplugged" status)"
|
||||
[[ "$(field "$status" .acOnline)" == "false" ]] \
|
||||
|| note 'a laptop with mains offline still reads as on wall power'
|
||||
|
||||
# ── 3. The charge limit appears only where it exists ─────────────────────────
|
||||
|
||||
paths="$(ask "$laptop" paths)"
|
||||
[[ "$(field "$paths" .threshold)" == "" ]] \
|
||||
|| note 'a machine without a charge threshold resolves one anyway, so the control would appear and do nothing'
|
||||
|
||||
limited="$(fixture limited 80 1 80)"
|
||||
paths="$(ask "$limited" paths)"
|
||||
[[ "$(field "$paths" .threshold)" != "" ]] \
|
||||
|| note 'a machine with a charge threshold does not expose it'
|
||||
[[ "$(field "$(ask "$limited" status)" .chargeLimit)" == "80" ]] \
|
||||
|| note 'the charge limit is not reported'
|
||||
|
||||
# ── 4. The write is privileged, named, and verified ──────────────────────────
|
||||
|
||||
grep -q 'panama-sudo' "$helper" \
|
||||
|| note 'the threshold write does not go through panama-sudo'
|
||||
if grep -nE '(^|[^-[:alnum:]])sudo ' "$helper" | grep -q -v 'panama-sudo'; then
|
||||
note 'the helper calls bare sudo somewhere, so the prompt would not name the change'
|
||||
fi
|
||||
grep -q -- '--reason' "$helper" \
|
||||
|| note 'the privileged write does not state a reason, so the prompt would not say what it changes'
|
||||
|
||||
# Out-of-range values are refused before any password is asked for.
|
||||
PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold 10 >/dev/null 2>&1 \
|
||||
&& note 'a threshold below the supported range was accepted'
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
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" \
|
||||
|| note 'the Power page battery card does not hide on a machine without one'
|
||||
grep -q 'visible: Battery.chargeLimitSupported' "$page" \
|
||||
|| note 'the charge limit control does not hide where the firmware has none'
|
||||
|
||||
# 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
|
||||
grep -q "property .*$key" "$aliases" \
|
||||
|| note "Settings.qml does not alias $key, so the binding reads undefined"
|
||||
done
|
||||
|
||||
for key in showBattery batteryLowPercent batteryCriticalPercent batteryChargeLimit; do
|
||||
grep -q "key: \"$key\"" "$schema" || note "the schema has no $key entry"
|
||||
done
|
||||
|
||||
# No subprocess on the polling path: the whole point of resolving paths once.
|
||||
if grep -A4 'Timer {' "$service" | grep -q 'running: true' && grep -q 'Process' "$service"; then
|
||||
grep -q 'onTriggered: root.refresh()' "$service" \
|
||||
|| note 'the poll timer does something other than re-read files'
|
||||
fi
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
printf 'battery contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'battery contract: PASS\n'
|
||||
@@ -72,6 +72,9 @@ package_for() {
|
||||
wl-copy|wl-paste) printf 'wl-clipboard' ;;
|
||||
ssh-keygen) printf 'openssh' ;;
|
||||
ssh|ssh-add) printf 'openssh-clients' ;;
|
||||
# The Wayland build is the one that can inject into this session; the
|
||||
# x11 one cannot. desktop-packages declares it under that name.
|
||||
espanso) printf 'espanso-wayland' ;;
|
||||
rg) printf 'ripgrep' ;;
|
||||
xdg-mime|xdg-settings|xdg-open) printf 'xdg-utils' ;;
|
||||
update-desktop-database|desktop-file-validate) printf 'desktop-file-utils' ;;
|
||||
@@ -114,13 +117,50 @@ while read -r script; do
|
||||
|
||||
missing+=("$cmd (from $(basename "$script"), package: $pkg)")
|
||||
done < <({
|
||||
# Heredoc bodies are not shell. A python or sql block embedded in a
|
||||
# script is scanned as though every line began a command, and its
|
||||
# keywords collide with real binaries often enough to matter: `import`
|
||||
# is ImageMagick, `time` is a package, `select` is shell syntax. The
|
||||
# scanner cannot parse those languages and should not try, so the
|
||||
# bodies are dropped before anything else looks at them.
|
||||
scanned="$(awk '
|
||||
# Whole-line comments. These files explain themselves at length,
|
||||
# and prose containing "; cancel it" or "| list" reads as a
|
||||
# statement to a line-based scanner. Dropped first so a comment
|
||||
# mentioning <<EOF cannot open a heredoc either.
|
||||
!inbody && /^[[:space:]]*#/ { next }
|
||||
|
||||
# <<MARKER, <<-MARKER, <<"MARKER", <<'"'"'MARKER'"'"' -- with or
|
||||
# without a command in front of it.
|
||||
!inbody && match($0, /<<-?[[:space:]]*["'"'"']?[A-Za-z_][A-Za-z0-9_]*["'"'"']?/) {
|
||||
marker = substr($0, RSTART, RLENGTH)
|
||||
gsub(/^<<-?[[:space:]]*["'"'"']?|["'"'"']?$/, "", marker)
|
||||
inbody = 1
|
||||
print
|
||||
next
|
||||
}
|
||||
inbody {
|
||||
line = $0
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
|
||||
if (line == marker) inbody = 0
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$script")"
|
||||
|
||||
# Statement-initial or after a pipe.
|
||||
grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+' "$script" \
|
||||
| grep -oE '[a-z][a-z0-9_-]+$'
|
||||
#
|
||||
# A word followed by `=` is a variable assignment and a word followed
|
||||
# by `)` is a case label; neither is a command, and both collide with
|
||||
# real binaries -- `count`, `host` and `cancel` are all installed on a
|
||||
# normal Fedora machine, so `command -v` below cannot filter them out.
|
||||
# Requiring whitespace or end-of-line after the word excludes both.
|
||||
grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+([[:space:]]|$)' <<<"$scanned" \
|
||||
| grep -oE '[a-z][a-z0-9_-]+'
|
||||
|
||||
# Behind a wrapper. ddcutil is always invoked as `timeout 10 ddcutil`,
|
||||
# so it never appears statement-initial and was missed entirely.
|
||||
grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
|
||||
grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' <<<"$scanned" \
|
||||
| grep -oE '[a-z][a-z0-9_-]+$'
|
||||
|
||||
# `command -v X` is how these helpers probe for a tool before using it,
|
||||
|
||||
@@ -65,7 +65,20 @@ done < <(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\([a-z-]*\)"/\1/')
|
||||
#
|
||||
# They are still commands, so everything else below applies to them: a title,
|
||||
# a description, search vocabulary, the Panama icon, and closing quietly.
|
||||
declare -a standalone=(search-web save-project open-project)
|
||||
#
|
||||
# The OS-parity commands are standalone for the same reason. A power menu that
|
||||
# needs the shell running is a power menu you cannot reach when the shell is
|
||||
# what broke; the pick-lists render through `vicinae dmenu` and act through
|
||||
# hyprctl; reminders are systemd timers. None of them has anything to ask the
|
||||
# shell for, and routing them through panama-action would only add a way for
|
||||
# them to stop working.
|
||||
declare -a standalone=(
|
||||
search-web save-project open-project
|
||||
lock-screen suspend-system log-out reboot-system power-off
|
||||
remind-me list-reminders pick-color
|
||||
switch-window force-quit-window kill-process ssh-hosts recent-files
|
||||
copy-password
|
||||
)
|
||||
|
||||
# Generated commands must match their source. A stale command dispatches to a
|
||||
# page that has been renamed or removed, and the launcher reports nothing wrong.
|
||||
|
||||
Reference in New Issue
Block a user