285 lines
14 KiB
Bash
Executable File
285 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# What closing the lid does.
|
|
#
|
|
# One sentence: closing the lid should suspend, unless there is an external
|
|
# monitor, in which case the machine is docked and should keep working.
|
|
#
|
|
# The mechanism is a logind `handle-lid-switch` inhibitor held while an
|
|
# external display is connected, rather than a lid watcher of Panama's own.
|
|
# That choice is the thing most worth pinning, because the obvious alternative
|
|
# fails in the dangerous direction: a drop-in setting HandleLidSwitch=ignore
|
|
# plus a watcher that dies leaves a laptop whose lid does nothing at all, being
|
|
# carried out of a building. An inhibitor that dies gives back logind's
|
|
# default, which is merely an annoying suspend at a desk.
|
|
#
|
|
# So this asserts, in order of how much it would cost to get wrong:
|
|
#
|
|
# 1. No logind drop-in is shipped. Panama never takes the lid over.
|
|
# 2. A machine with no reason for an inhibitor holds none: a desktop, and an
|
|
# undocked laptop that should suspend normally.
|
|
# 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, 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
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-lid"
|
|
service="$repo_dir/config/dot/quickshell/services/LidPolicy.qml"
|
|
shell_qml="$repo_dir/config/dot/quickshell/shell.qml"
|
|
hypridle="$repo_dir/config/dot/hypr/hypridle.conf"
|
|
|
|
findings=()
|
|
note() { findings+=("$1"); }
|
|
|
|
[[ -x "$helper" ]] || { printf 'lid contract: %s is not executable\n' "$helper" >&2; exit 1; }
|
|
|
|
work="$(mktemp -d)"
|
|
trap 'rm -rf "$work"' EXIT
|
|
calls="$work/calls"
|
|
fake="$work/fake"
|
|
mkdir -p "$fake/bin"
|
|
|
|
# ── 1. Panama never takes the lid over ───────────────────────────────────────
|
|
|
|
# Specifically the LID. Other keys may be configured there -- the power-key
|
|
# drop-in is deliberate policy with a compositor bind behind it -- but a
|
|
# HandleLidSwitch line would replace the inhibitor design this file protects.
|
|
if compgen -G "$repo_dir/config/copy/etc/systemd/logind.conf.d/*" >/dev/null 2>&1 \
|
|
&& sed 's/#.*//' "$repo_dir"/config/copy/etc/systemd/logind.conf.d/* | grep -q 'HandleLidSwitch'; then
|
|
note 'a logind drop-in sets HandleLidSwitch; the inhibitor approach exists so the lid is never left doing nothing'
|
|
fi
|
|
# Comments are stripped first: these files explain at length what they
|
|
# deliberately do NOT do, and prose naming HandleLidSwitch or lock-session is
|
|
# documentation rather than behavior.
|
|
uncommented() { grep -rhv '^[[:space:]]*#' "$@" 2>/dev/null; }
|
|
|
|
uncommented "$repo_dir/config" | grep -q 'HandleLidSwitch' \
|
|
&& note 'something sets HandleLidSwitch, which takes the lid away from logind'
|
|
|
|
# ── The stubs ────────────────────────────────────────────────────────────────
|
|
|
|
# panama-hw answers whatever this fixture says. $1 laptop, $2 external monitor,
|
|
# as exit codes: 0 yes, 1 no.
|
|
stub_hw() {
|
|
cat >"$fake/bin/panama-hw" <<STUB
|
|
#!/usr/bin/env bash
|
|
case "\$1" in
|
|
laptop) exit $1 ;;
|
|
external-monitor) exit $2 ;;
|
|
lid-closed) exit 1 ;;
|
|
clamshell) exit 1 ;;
|
|
*) exit 1 ;;
|
|
esac
|
|
STUB
|
|
chmod +x "$fake/bin/panama-hw"
|
|
}
|
|
|
|
# systemd-inhibit records how it was called instead of holding anything.
|
|
cat >"$fake/systemd-inhibit" <<STUB
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "\$*" >>"$calls"
|
|
STUB
|
|
chmod +x "$fake/systemd-inhibit"
|
|
|
|
guard() {
|
|
: >"$calls"
|
|
PATH="$fake:$PATH" PANAMA_PATH="$fake" "$helper" guard >/dev/null 2>&1
|
|
}
|
|
|
|
# ── 2. Machines that should hold nothing ─────────────────────────────────────
|
|
|
|
stub_hw 1 0 # a desktop: not a laptop, external monitor present
|
|
guard || note 'the guard failed on a desktop instead of exiting cleanly'
|
|
[[ -s "$calls" ]] && note 'a desktop held a lid inhibitor, which it has no lid to inhibit'
|
|
|
|
stub_hw 0 1 # a laptop with no external display: must suspend normally
|
|
guard || note 'the guard failed on an undocked laptop instead of exiting cleanly'
|
|
[[ -s "$calls" ]] \
|
|
&& note 'an undocked laptop held a lid inhibitor, so closing it in a bag would not suspend'
|
|
|
|
# ── 3. A docked laptop holds the right one ───────────────────────────────────
|
|
|
|
stub_hw 0 0 # a laptop with an external display
|
|
guard
|
|
[[ -s "$calls" ]] || note 'a docked laptop held no inhibitor, so closing the lid would suspend mid-work'
|
|
recorded="$(cat "$calls" 2>/dev/null)"
|
|
grep -q -- '--what=handle-lid-switch' <<<"$recorded" \
|
|
|| note 'the inhibitor is not a handle-lid-switch inhibitor, so logind would still act on the lid'
|
|
grep -q -- '--mode=block' <<<"$recorded" \
|
|
|| note 'the inhibitor is a delay rather than a block, so logind would suspend anyway after its timeout'
|
|
grep -q -- '--who=panama-lid' <<<"$recorded" \
|
|
|| note 'the inhibitor does not identify itself, so systemd-inhibit --list cannot explain who is holding it'
|
|
grep -q -- '--why=' <<<"$recorded" \
|
|
|| note 'the inhibitor states no reason'
|
|
|
|
# ── 4. Locking on the way down stays hypridle's job ──────────────────────────
|
|
|
|
grep -q 'before_sleep_cmd' "$hypridle" \
|
|
|| note 'the shipped hypridle config no longer locks before sleep, which is what makes a lid-close suspend a locked one'
|
|
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'
|
|
grep -q 'eDP|LVDS|DSI' "$service" \
|
|
|| note 'the service does not distinguish the built-in panel from an external display'
|
|
grep -q 'Connections { target: LidPolicy }' "$shell_qml" \
|
|
|| note 'nothing keeps LidPolicy alive, so it would only start when some page happened to reference it'
|
|
|
|
# Status is machine-readable, for the Power page and panama-doctor.
|
|
stub_hw 0 0
|
|
status="$(PATH="$fake:$PATH" PANAMA_PATH="$fake" "$helper" status 2>/dev/null)"
|
|
if command -v jq >/dev/null 2>&1; then
|
|
jq -e . >/dev/null 2>&1 <<<"$status" || note 'status does not emit valid JSON'
|
|
for key in laptop lidClosed externalMonitor clamshell inhibited; do
|
|
jq -e "has(\"$key\")" >/dev/null 2>&1 <<<"$status" || note "status omits $key"
|
|
done
|
|
fi
|
|
|
|
if (( ${#findings[@]} > 0 )); then
|
|
printf 'lid contract: %d finding(s)\n' "${#findings[@]}" >&2
|
|
printf ' - %s\n' "${findings[@]}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'lid contract: PASS\n'
|