From 50a99a5ad0a9a5369c58e85a465c329a66b7cb4d Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 21 Aug 2026 22:36:36 -0400 Subject: [PATCH] Closing the lid at a desk is not closing it in a bag logind handles the lid correctly except for the one case it cannot see: an external display means a closed lid is a docked machine, not one being put away. Its own docked test looks for an ACPI docking station that modern hardware does not have. Panama does not take the lid over to fix that. It holds a logind handle-lid-switch inhibitor while an external display is connected and releases it when the last one goes, which needs no lid watcher, no polling, and no drop-in. The direction it fails in is the point: if the guard dies, logind's default comes back and a docked laptop suspends, which is annoying. A 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 a building. Locking on the way down needed no work: hypridle's before_sleep_cmd already runs loginctl lock-session, so a lid-close suspend is a locked suspend. The contract fails anything that duplicates it. Not yet verified against a real lid, which is stated in the helper's header rather than implied by silence. The decision logic, the inhibitor's shape, and every machine that should hold none of it are covered. --- README.md | 2 +- config/dot/quickshell/scripts/panama-lid | 84 +++++++++++ config/dot/quickshell/services/LidPolicy.qml | 78 ++++++++++ config/dot/quickshell/shell.qml | 5 + tests/setup/lid-contract | 147 +++++++++++++++++++ 5 files changed, 315 insertions(+), 1 deletion(-) create mode 100755 config/dot/quickshell/scripts/panama-lid create mode 100644 config/dot/quickshell/services/LidPolicy.qml create mode 100755 tests/setup/lid-contract 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'