fix(docking): make workspace and lid transitions atomic
All checks were successful
Check / eval (push) Successful in 3m19s
All checks were successful
Check / eval (push) Successful in 3m19s
Restore the old flake's external-only workspace handoff, protect closed-lid cable removal with a verified low-level logind inhibitor, and use monitor-added as the settled audio reprobe boundary. Verified: V2 — nix flake check --no-build; docking-ux, option-docs, and template-sot checks; KVM tools/monitor-fallback.nix lifecycle test. V3 pending: closed-lid BenQ round 4 in agent/HARDWARE-QUEUE.md.
This commit is contained in:
@@ -124,6 +124,90 @@ let
|
||||
profileWorkspaceRules =
|
||||
lib.mapAttrsToList monitorLib.workspaceRule activeProfile.workspaces;
|
||||
|
||||
# One transition primitive for the interactive Dock mode, abrupt undock,
|
||||
# and named profiles that disable the internal panel. Dock is one Hyprland
|
||||
# batch: enable the target, move every internal workspace, focus it, then
|
||||
# disable the panel. Undock deliberately enables the panel FIRST, waits
|
||||
# for it to become an active target, then restores every workspace.
|
||||
displayTransitionTool = pkgs.writeShellScriptBin "nomarchy-display-transition" ''
|
||||
set -u
|
||||
LOGGER=${pkgs.util-linux}/bin/logger
|
||||
log() { "$LOGGER" -t nomarchy-display-transition -- "$*"; }
|
||||
valid_output() {
|
||||
case "$1" in *[!A-Za-z0-9_.:-]*|"") return 1 ;; *) return 0 ;; esac
|
||||
}
|
||||
workspaces_on() {
|
||||
hyprctl workspaces -j 2>/dev/null \
|
||||
| jq -r --arg m "$1" '.[] | select(.monitor == $m and .id > 0) | .id'
|
||||
}
|
||||
all_workspaces() {
|
||||
hyprctl workspaces -j 2>/dev/null | jq -r '.[] | select(.id > 0) | .id'
|
||||
}
|
||||
lid_inhibitor_held() {
|
||||
${pkgs.systemd}/bin/systemd-inhibit --list --json=short 2>/dev/null \
|
||||
| ${pkgs.jq}/bin/jq -e 'any(.[]; .what == "handle-lid-switch" and .why == "Safe dock/undock display transition" and .mode == "block")' \
|
||||
>/dev/null
|
||||
}
|
||||
batch_moves() { # stdin workspace ids, $1 target
|
||||
target="$1"; commands=
|
||||
while IFS= read -r ws; do
|
||||
case "$ws" in *[!0-9]*|"") continue ;; esac
|
||||
commands="$commands dispatch moveworkspacetomonitor $ws $target;"
|
||||
done
|
||||
printf '%s' "$commands"
|
||||
}
|
||||
|
||||
case "''${1:-}" in
|
||||
dock)
|
||||
internal="''${2:-}"; external="''${3:-}"
|
||||
rule="''${4:-$external, preferred, auto, 1}"
|
||||
valid_output "$internal" && valid_output "$external" \
|
||||
|| { echo "invalid monitor name" >&2; exit 64; }
|
||||
# Fail closed: disabling the panel without this lock recreates the
|
||||
# exact cable-removal → logind suspend race #100 is fixing.
|
||||
lid_inhibitor_held \
|
||||
|| { log "transition=dock internal=$internal external=$external result=no-lid-inhibitor"; exit 1; }
|
||||
moves=$(workspaces_on "$internal" | batch_moves "$external")
|
||||
if hyprctl --batch \
|
||||
"keyword monitor $rule; $moves dispatch focusmonitor $external; keyword monitor $internal, disable" \
|
||||
>/dev/null 2>&1; then
|
||||
log "transition=dock internal=$internal external=$external result=ok"
|
||||
else
|
||||
log "transition=dock internal=$internal external=$external result=failed"
|
||||
exit 1
|
||||
fi ;;
|
||||
undock)
|
||||
internal="''${2:-}"
|
||||
valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; }
|
||||
# Safety-critical ordering: never remove/move away from an external
|
||||
# before the internal panel is active and can receive workspaces.
|
||||
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 \
|
||||
|| { log "transition=undock internal=$internal result=enable-failed"; exit 1; }
|
||||
ready=
|
||||
tries=0
|
||||
while [ "$tries" -lt 20 ]; do
|
||||
if hyprctl monitors -j 2>/dev/null \
|
||||
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null; then
|
||||
ready=1; break
|
||||
fi
|
||||
tries=$((tries+1)); sleep 0.1
|
||||
done
|
||||
[ -n "$ready" ] \
|
||||
|| { log "transition=undock internal=$internal result=enable-timeout"; exit 1; }
|
||||
moves=$(all_workspaces | batch_moves "$internal")
|
||||
hyprctl --batch "$moves dispatch focusmonitor $internal" >/dev/null 2>&1 \
|
||||
|| { log "transition=undock internal=$internal result=move-failed"; exit 1; }
|
||||
log "transition=undock internal=$internal result=ok" ;;
|
||||
enable)
|
||||
internal="''${2:-}"
|
||||
valid_output "$internal" || exit 64
|
||||
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 ;;
|
||||
*)
|
||||
echo "usage: nomarchy-display-transition [dock <internal> <external> [external-rule]|undock <internal>|enable <internal>]" >&2
|
||||
exit 64 ;;
|
||||
esac
|
||||
'';
|
||||
|
||||
# Profile applier — on PATH only when profiles are declared (the menu
|
||||
# row self-gates on it). apply: the profile's rules live via hyprctl +
|
||||
# the in-flake state write; base: clear the state, then hyprctl reload
|
||||
@@ -139,10 +223,38 @@ ${lib.concatMapStringsSep "\n" (n: " echo ${lib.escapeShellArg n}") (lib.
|
||||
case "''${2:-}" in
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
(name: p:
|
||||
let
|
||||
isInternal = m: builtins.match "^(eDP|LVDS|DSI).*" m.name != null;
|
||||
enabled = lib.filter (m: m.resolution != "disable") p.monitors;
|
||||
disabled = lib.filter (m: m.resolution == "disable") p.monitors;
|
||||
internalOff = lib.findFirst isInternal null disabled;
|
||||
dockTarget = lib.findFirst (m: ! isInternal m) null enabled;
|
||||
safeDock = internalOff != null && dockTarget != null;
|
||||
ordinaryEnabled = if safeDock
|
||||
then lib.filter (m: m.name != dockTarget.name) enabled
|
||||
else enabled;
|
||||
ordinaryDisabled = if safeDock
|
||||
then lib.filter (m: m.name != internalOff.name) disabled
|
||||
else disabled;
|
||||
in
|
||||
" ${lib.escapeShellArg name})\n"
|
||||
# Outputs that will remain active always come first. If this is a
|
||||
# clamshell profile, the helper performs target-enable + workspace
|
||||
# handoff + internal-disable as one batch, avoiding the zero-output and
|
||||
# dangling-workspace states the old per-rule loop could create.
|
||||
+ lib.concatMapStringsSep "\n"
|
||||
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
|
||||
p.monitors
|
||||
ordinaryEnabled
|
||||
+ lib.optionalString safeDock
|
||||
("\n ${displayTransitionTool}/bin/nomarchy-display-transition dock "
|
||||
+ lib.escapeShellArg internalOff.name + " "
|
||||
+ lib.escapeShellArg dockTarget.name + " "
|
||||
+ lib.escapeShellArg (monitorRule dockTarget)
|
||||
+ " >/dev/null 2>&1 || { notify-send \"Display profile\" \"Could not safely disable the laptop panel.\"; exit 1; }")
|
||||
+ lib.optionalString (ordinaryDisabled != [ ]) "\n"
|
||||
+ lib.concatMapStringsSep "\n"
|
||||
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
|
||||
ordinaryDisabled
|
||||
# Workspace pins: the keyword sets the session rule, the dispatch
|
||||
# moves an already-open workspace over. Pins from a previously
|
||||
# applied profile linger until reload/rebuild (hyprctl can only
|
||||
@@ -198,18 +310,120 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
esac
|
||||
'';
|
||||
|
||||
# Hotplug auto-switch (opt-in via the menu's Auto-switch row →
|
||||
# settings.displayProfileAuto, read LIVE on every output change so the
|
||||
# toggle is instant, no rebuild). Polls like the keyboard watcher —
|
||||
# exec-once, not a systemd unit (graphical-session-bound units raced
|
||||
# Hyprland's IPC on relogin; see the waybar note below). Only reacts to
|
||||
# CHANGES after session start: the baked config already encodes the
|
||||
# last choice, and login must not fight a deliberate manual pick.
|
||||
# `match` picks deterministically (exact set, else unambiguous largest
|
||||
# subset); applying via the tool persists the concrete profile, so the
|
||||
# next rebuild bakes what auto chose.
|
||||
# Hyprland hotplug watcher: owns the safety-critical dock lifecycle plus
|
||||
# optional profile auto-switching. A low-level logind lid-switch inhibitor
|
||||
# is acquired as soon as an external appears. On final external removal,
|
||||
# the internal panel is enabled and workspaces restored before the watcher
|
||||
# waits for a physically open lid and releases the inhibitor. The normal
|
||||
# undocked lid-close path is therefore unchanged after release.
|
||||
displayProfileWatch = pkgs.writeShellScriptBin "nomarchy-display-profile-watch" ''
|
||||
set -u
|
||||
LOGGER=${pkgs.util-linux}/bin/logger
|
||||
TRANSITION=${displayTransitionTool}/bin/nomarchy-display-transition
|
||||
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
inhibitor_pid=
|
||||
inhibitor_file="$rt/nomarchy-dock-lid-inhibitor.pid"
|
||||
awaiting_lid_open=
|
||||
log() { "$LOGGER" -t nomarchy-display-watch -- "$*"; }
|
||||
is_internal() {
|
||||
[ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ] && [ "$1" = "$NOMARCHY_INTERNAL_OUTPUT" ] \
|
||||
&& return 0
|
||||
printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -qE '^(eDP|LVDS|DSI)'
|
||||
}
|
||||
internal_output() {
|
||||
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
|
||||
printf '%s\n' "$NOMARCHY_INTERNAL_OUTPUT"; return
|
||||
fi
|
||||
hyprctl monitors all -j 2>/dev/null \
|
||||
| jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)"))][0] // empty'
|
||||
}
|
||||
external_outputs() {
|
||||
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
|
||||
hyprctl monitors all -j 2>/dev/null \
|
||||
| jq -r --arg m "$NOMARCHY_INTERNAL_OUTPUT" '.[].name | select(. != $m)'
|
||||
return
|
||||
fi
|
||||
hyprctl monitors all -j 2>/dev/null \
|
||||
| jq -r '.[].name | select(test("^(eDP|LVDS|DSI)") | not)'
|
||||
}
|
||||
lid_open() {
|
||||
saw=
|
||||
if [ -n "''${NOMARCHY_LID_STATE_FILE:-}" ]; then
|
||||
files="$NOMARCHY_LID_STATE_FILE"
|
||||
else
|
||||
files=/proc/acpi/button/lid/*/state
|
||||
fi
|
||||
for state in $files; do
|
||||
[ -r "$state" ] || continue
|
||||
saw=1
|
||||
${pkgs.gnugrep}/bin/grep -qi open "$state" && return 0
|
||||
done
|
||||
# Desktops/VMs without a lid are safe to treat as open.
|
||||
[ -z "$saw" ]
|
||||
}
|
||||
owned_inhibitor_pid() {
|
||||
pid="''${1:-}"
|
||||
case "$pid" in *[!0-9]*|"") return 1 ;; esac
|
||||
[ -r "/proc/$pid/cmdline" ] || return 1
|
||||
${pkgs.coreutils}/bin/tr '\0' ' ' < "/proc/$pid/cmdline" \
|
||||
| ${pkgs.gnugrep}/bin/grep -qE \
|
||||
'nomarchy-dock-lid-inhibitor|Safe dock/undock display transition'
|
||||
}
|
||||
stop_inhibitor_group() {
|
||||
pid="''${1:-}"
|
||||
owned_inhibitor_pid "$pid" || return 0
|
||||
# The inhibitor and its sleep command share a private process group,
|
||||
# so both lose the inhibitor fd even after an unclean watcher death.
|
||||
kill -TERM -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
|
||||
}
|
||||
clear_stale_inhibitor() {
|
||||
[ -r "$inhibitor_file" ] || return
|
||||
IFS= read -r old < "$inhibitor_file" || old=
|
||||
if owned_inhibitor_pid "$old"; then
|
||||
stop_inhibitor_group "$old"
|
||||
log "lid-inhibitor=stale-cleaned pid=$old"
|
||||
fi
|
||||
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
|
||||
}
|
||||
acquire_inhibitor() {
|
||||
if [ -n "$inhibitor_pid" ] && kill -0 "$inhibitor_pid" 2>/dev/null; then return; fi
|
||||
${pkgs.util-linux}/bin/setsid ${pkgs.systemd}/bin/systemd-inhibit \
|
||||
--what=handle-lid-switch --mode=block --who=Nomarchy \
|
||||
--why="Safe dock/undock display transition" \
|
||||
${pkgs.bash}/bin/bash -c \
|
||||
'while :; do ${pkgs.coreutils}/bin/sleep 86400; done' \
|
||||
nomarchy-dock-lid-inhibitor &
|
||||
inhibitor_pid=$!
|
||||
printf '%s\n' "$inhibitor_pid" > "$inhibitor_file"
|
||||
tries=0
|
||||
while [ "$tries" -lt 20 ]; do
|
||||
if ! kill -0 "$inhibitor_pid" 2>/dev/null; then break; fi
|
||||
if ${pkgs.systemd}/bin/systemd-inhibit --list --json=short 2>/dev/null \
|
||||
| ${pkgs.jq}/bin/jq -e 'any(.[]; .what == "handle-lid-switch" and .why == "Safe dock/undock display transition" and .mode == "block")' \
|
||||
>/dev/null; then
|
||||
log "lid-inhibitor=acquired pid=$inhibitor_pid"
|
||||
return 0
|
||||
fi
|
||||
tries=$((tries+1)); sleep 0.1
|
||||
done
|
||||
log "lid-inhibitor=acquire-failed"
|
||||
stop_inhibitor_group "$inhibitor_pid"
|
||||
inhibitor_pid=
|
||||
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
|
||||
return 1
|
||||
}
|
||||
release_inhibitor() {
|
||||
[ -n "$inhibitor_pid" ] || return
|
||||
stop_inhibitor_group "$inhibitor_pid"
|
||||
wait "$inhibitor_pid" 2>/dev/null || true
|
||||
log "lid-inhibitor=released"
|
||||
inhibitor_pid=
|
||||
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
|
||||
}
|
||||
cleanup() { release_inhibitor; }
|
||||
trap cleanup EXIT INT TERM
|
||||
clear_stale_inhibitor
|
||||
|
||||
auto_on() {
|
||||
v=$(${sync} get settings.displayProfileAuto 2>/dev/null) || v=false
|
||||
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
||||
@@ -223,52 +437,8 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
${sync} --quiet wallpaper >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Blackout rescue — the session must never end up with ZERO active
|
||||
# outputs. Hyprland does NOT re-enable a soft-disabled monitor when
|
||||
# the last active one disconnects (VM-verified, tools/
|
||||
# monitor-fallback.nix): the former panel-off menu action (and still-valid
|
||||
# named profiles that disable eDP) + sudden undock left a dead session.
|
||||
# On every monitorremoved, if nothing is active, re-enable whatever is
|
||||
# disabled (preferred/auto — the
|
||||
# declared rule returns at the next reload/relogin). Independent of
|
||||
# the profile auto-switch and its settings gate: this is a safety
|
||||
# invariant, not a layout preference.
|
||||
active_count() {
|
||||
# NB: `grep -c .` exits 1 when the count is 0 — the case this whole
|
||||
# rescue exists for — so never `||`-guard on the pipeline status.
|
||||
hyprctl monitors -j 2>/dev/null | jq -r '.[].name' | grep -c . || true
|
||||
}
|
||||
rescue_blackout() {
|
||||
# Act IMMEDIATELY and retry: the zero-output state is not just a
|
||||
# black screen, it can kill the compositor outright — in the softGL
|
||||
# VM harness aquamarine ABRT'd (CBackend::dispatchIdle) within
|
||||
# ~0.5s of the last output going while it persisted, so shrinking
|
||||
# the window is the priority; the keyword is idempotent and the
|
||||
# retries cover a first attempt racing the removal teardown.
|
||||
tries=0 rescued=
|
||||
# monitorremoved can arrive just before the departing output disappears
|
||||
# from `hyprctl monitors`; keep a short bounded watch instead of deciding
|
||||
# from that racy first snapshot. The first check is still immediate.
|
||||
while [ "$tries" -lt 8 ]; do
|
||||
if [ "$(active_count)" = 0 ]; then
|
||||
rescued=1
|
||||
hyprctl monitors all -j 2>/dev/null | jq -r '.[] | select(.disabled) | .name' \
|
||||
| while read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
hyprctl keyword monitor "$m,preferred,auto,auto" >/dev/null 2>&1
|
||||
done
|
||||
fi
|
||||
tries=$((tries+1))
|
||||
sleep 0.25
|
||||
done
|
||||
if [ -n "$rescued" ] && [ "$(active_count)" != 0 ]; then
|
||||
notify-send "Display" "Screen re-enabled — the active output disappeared." 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
check_monitors() {
|
||||
cur=$(outputs)
|
||||
[ "$cur" = "$1" ] && return
|
||||
[ -n "$cur" ] || return
|
||||
auto_on || return
|
||||
command -v nomarchy-display-profile >/dev/null 2>&1 || return
|
||||
@@ -282,31 +452,71 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
[ "$target" = "$(nomarchy-display-profile active)" ] && return
|
||||
nomarchy-display-profile apply "$target"
|
||||
fi
|
||||
echo "$cur"
|
||||
}
|
||||
|
||||
prev=$(outputs)
|
||||
# Login/relogin while already docked still needs protection. Do not
|
||||
# change the layout here: the baked config/manual profile remains SoT.
|
||||
internal=$(internal_output)
|
||||
[ -n "$internal" ] && [ -n "$(external_outputs)" ] && acquire_inhibitor
|
||||
|
||||
# Listen to Hyprland's IPC socket for instant hotplug reaction. Reconnect
|
||||
# after a compositor reload/socket hiccup instead of silently losing the
|
||||
# safety listener for the rest of the session.
|
||||
# after a compositor reload/socket hiccup. `-T 1` also gives the parent
|
||||
# shell a periodic chance to observe lid-open and release the inhibitor.
|
||||
# Process substitution is deliberate: a pipeline `... | while read`
|
||||
# would run the loop in a subshell and lose inhibitor lifecycle state.
|
||||
socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
|
||||
while :; do
|
||||
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:"$socket" | while read -r line; do
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
monitorremoved*)
|
||||
rescue_blackout
|
||||
res=$(check_monitors "$prev")
|
||||
[ -n "$res" ] && prev="$res"
|
||||
"monitorremoved>>"*)
|
||||
removed=''${line#monitorremoved>>}
|
||||
internal=$(internal_output)
|
||||
# monitorremoved is emitted while the departing output may still
|
||||
# be in `monitors all`. Exclude its event name to decide whether
|
||||
# this was the final external; restore BEFORE teardown settles.
|
||||
others=$(external_outputs | ${pkgs.gnugrep}/bin/grep -Fxv "$removed" || true)
|
||||
if ! is_internal "$removed" && [ -z "$others" ] && [ -n "$internal" ]; then
|
||||
"$TRANSITION" undock "$internal" || true
|
||||
awaiting_lid_open=1
|
||||
log "event=monitorremoved output=$removed transition=undock"
|
||||
fi
|
||||
check_monitors
|
||||
;;
|
||||
monitoradded*)
|
||||
"monitoradded>>"*)
|
||||
added=''${line#monitoradded>>}
|
||||
if ! is_internal "$added"; then
|
||||
internal=$(internal_output)
|
||||
[ -z "$internal" ] || acquire_inhibitor
|
||||
command -v nomarchy-dock-audio >/dev/null 2>&1 \
|
||||
&& nomarchy-dock-audio reprobe monitoradded &
|
||||
log "event=monitoradded output=$added audio-reprobe=scheduled"
|
||||
fi
|
||||
paint_outputs &
|
||||
res=$(check_monitors "$prev")
|
||||
[ -n "$res" ] && prev="$res"
|
||||
check_monitors
|
||||
;;
|
||||
esac
|
||||
done
|
||||
sleep 1
|
||||
done < <(${pkgs.socat}/bin/socat -T 1 -U - UNIX-CONNECT:"$socket" 2>/dev/null)
|
||||
connected_external=$(external_outputs)
|
||||
internal=$(internal_output)
|
||||
if [ -n "$internal" ] && [ -n "$connected_external" ]; then
|
||||
# Socket reconnect may have hidden the add event. Recover the safety
|
||||
# invariant (but do not call audio: only an observed fresh event is
|
||||
# allowed to override a manual sink choice).
|
||||
if [ -z "$inhibitor_pid" ] || ! kill -0 "$inhibitor_pid" 2>/dev/null; then
|
||||
acquire_inhibitor || true
|
||||
fi
|
||||
elif [ -n "$inhibitor_pid" ] && [ -z "$awaiting_lid_open" ]; then
|
||||
# Likewise, reconcile a removal missed during an IPC reconnect.
|
||||
internal=$(internal_output)
|
||||
[ -n "$internal" ] && "$TRANSITION" undock "$internal" || true
|
||||
awaiting_lid_open=1
|
||||
log "event=reconcile transition=undock"
|
||||
fi
|
||||
if [ -n "$awaiting_lid_open" ] && [ -z "$(external_outputs)" ] && lid_open; then
|
||||
release_inhibitor
|
||||
awaiting_lid_open=
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
'';
|
||||
|
||||
@@ -709,6 +919,6 @@ in
|
||||
# debugging. The display-profile switcher itself remains gated so the
|
||||
# menu does not advertise an empty Profiles submenu.
|
||||
++ lib.optionals config.nomarchy.hyprland.enable
|
||||
([ keyboardTool keyboardWatch displayProfileWatch ]
|
||||
([ keyboardTool keyboardWatch displayTransitionTool displayProfileWatch ]
|
||||
++ lib.optional (profiles != { }) displayProfileTool);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user