diff --git a/README.md b/README.md index e4e4f2b..7b65b34 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -138 of them, under `tests/`. Run the lot, or a subset by pattern: +139 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/scripts/panama-lid b/config/dot/quickshell/scripts/panama-lid new file mode 100755 index 0000000..8d8ca23 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-lid @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +# What closing the lid should do. +# +# panama-lid status what this machine would do right now, as JSON +# panama-lid guard hold the inhibitor while clamshell use is possible +# +# The rule is 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 implementation is deliberately not a lid watcher. logind already handles +# the lid perfectly well; what it cannot do is notice that an external display +# makes a closed lid mean something different, because its own "docked" test +# looks for an ACPI docking station that modern hardware does not have. +# +# So Panama does not take the lid over. It holds a `handle-lid-switch` +# inhibitor while an external monitor is connected, and releases it when the +# last one goes away. logind does everything else, which has three properties +# worth having: +# +# * Nothing has to watch the lid, and no polling loop exists. +# * If this ever fails or is killed, the inhibitor is released and the +# machine goes back to logind's default. The failure mode is "a docked +# laptop suspends", not "the lid does nothing at all", which is the failure +# mode a logind drop-in would have. +# * Locking before sleep is already handled: hypridle's before_sleep_cmd +# runs `loginctl lock-session`, so a suspend from a closed lid is a locked +# suspend without anything here being involved. +# +# Started and stopped by services/LidPolicy.qml on display topology changes. +# +# NOT YET VERIFIED ON A LAPTOP. The logic and the inhibitor are testable on any +# machine and are covered by tests/setup/lid-contract, but the end-to-end +# behavior of closing a real lid on a docked machine needs a machine with a +# lid. Everything here fails toward logind's default, so the untested path is +# "keeps working while docked" rather than anything that could strand a session. + +set -uo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" +HW="$PANAMA_PATH/bin/panama-hw" + +answer() { "$@" && printf 'true' || printf 'false'; } + +cmd_status() { + local inhibited=false + systemd-inhibit --list 2>/dev/null | grep -q 'panama-lid' && inhibited=true + printf '{"laptop":%s,"lidClosed":%s,"externalMonitor":%s,"clamshell":%s,"inhibited":%s}\n' \ + "$(answer "$HW" laptop)" \ + "$(answer "$HW" lid-closed)" \ + "$(answer "$HW" external-monitor)" \ + "$(answer "$HW" clamshell)" \ + "$inhibited" +} + +# Holds the inhibitor for as long as it runs. Exits immediately, holding +# nothing, when this machine has no reason to want one -- a desktop has no lid +# to inhibit and an undocked laptop should suspend normally. +cmd_guard() { + "$HW" laptop || exit 0 + "$HW" external-monitor || exit 0 + + exec systemd-inhibit \ + --what=handle-lid-switch \ + --who=panama-lid \ + --why="An external display is connected, so a closed lid is a docked machine rather than one being put away" \ + --mode=block \ + sleep infinity +} + +case "${1:-status}" in + status) cmd_status ;; + guard) cmd_guard ;; + -h|--help) + cat <<'USAGE' +usage: panama-lid [status|guard] + + status what closing the lid would do right now, as JSON + guard hold a handle-lid-switch inhibitor while an external display is + connected; exits immediately on a machine that needs none +USAGE + ;; + *) printf 'panama-lid: unknown command: %s\n' "$1" >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/services/LidPolicy.qml b/config/dot/quickshell/services/LidPolicy.qml new file mode 100644 index 0000000..936fdc8 --- /dev/null +++ b/config/dot/quickshell/services/LidPolicy.qml @@ -0,0 +1,78 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// Closing the lid on a docked machine should not put it to sleep. +// +// logind handles the lid correctly except for one case it cannot see: an +// external display means a closed lid is a docked machine rather than one +// being put in a bag. Its own "docked" test looks for an ACPI docking station, +// which modern hardware does not have. +// +// So Panama does not take the lid over. It holds a `handle-lid-switch` +// inhibitor while an external monitor is connected and releases it when the +// last one disconnects; logind does the rest, including the suspend, and +// hypridle's before_sleep_cmd already locks the session on the way down. +// +// This service exists only to notice the topology changing. The decision +// itself is in bin/panama-lid, so a machine with no lid and no external +// display costs one process start that exits immediately. +// +// Failure is safe in the direction that matters. If the guard dies, or this +// service never starts, the inhibitor is released and logind's default returns: +// a docked laptop suspends when the lid closes, which is merely annoying. The +// alternative design -- a logind drop-in setting HandleLidSwitch=ignore plus a +// watcher of our own -- fails the other way, leaving a lid that does nothing at +// all on a machine being carried out of the building. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-lid" + + // Whether the inhibitor is currently held. Reported by the Power page and + // by panama-doctor; nothing depends on it to make a decision. + readonly property bool inhibited: guard.running + + // Same rule panama-hw uses: eDP, LVDS and DSI are the built-in panel and + // everything else arrived through a cable. Computed here rather than asked + // of the helper so a topology change is noticed without spawning anything. + readonly property bool externalConnected: { + const screens = Quickshell.screens ?? []; + for (const screen of screens) { + const name = String(screen?.name ?? ""); + if (name === "") + continue; + if (!/eDP|LVDS|DSI/i.test(name)) + return true; + } + return false; + } + + onExternalConnectedChanged: root.reconcile() + + function reconcile(): void { + // Restarting rather than toggling: the helper re-checks whether this + // machine is a laptop at all, and a process that is already holding + // the right inhibitor costs nothing to leave alone. + if (root.externalConnected) { + if (!guard.running) + guard.running = true; + } else if (guard.running) { + guard.running = false; + } + } + + // Long-lived by design: it holds the inhibitor for as long as it runs, and + // exits immediately on a machine that should not have one. + Process { + id: guard + command: [root.helperPath, "guard"] + } + + Component.onCompleted: root.reconcile() +} diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 124f759..84e23ad 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -318,6 +318,11 @@ ShellRoot { // even before any quick-settings device list has been opened. Connections { target: DeviceEvents } + // Same reason: the lid policy has to be watching display topology from the + // start, not from the first time somebody opens the Power page. On a + // machine with no lid it costs one process that exits immediately. + Connections { target: LidPolicy } + IpcHandler { target: "quicksettings" function toggle(): void { ShellState.toggle("quicksettings"); } diff --git a/tests/setup/lid-contract b/tests/setup/lid-contract new file mode 100755 index 0000000..ef4199d --- /dev/null +++ b/tests/setup/lid-contract @@ -0,0 +1,147 @@ +#!/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. +# +# Driven with stubbed predicates. 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 ─────────────────────────────────────── + +if compgen -G "$repo_dir/config/copy/etc/systemd/logind.conf.d/*" >/dev/null 2>&1; then + note 'a logind drop-in is shipped; 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" <"$fake/systemd-inhibit" <>"$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' + +# ── 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'