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.
This commit is contained in:
Gabriel Brown
2026-08-21 22:36:36 -04:00
parent bc6d63b70f
commit 50a99a5ad0
5 changed files with 315 additions and 1 deletions
+84
View File
@@ -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
@@ -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()
}
+5
View File
@@ -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"); }