fix(docking): don't lose the hotplug event; one prompt per keyboard; follow the jack
All checks were successful
Check / eval (push) Successful in 3m31s

Four failures from Bernardo's round-4 dock pass on the AMD dev box, plus a
headphone regression, tracked to three causes.

The display watcher ran socat with `-T 1`, which closes Hyprland's IPC
socket after one second of *inactivity*. A dock is normally plugged into an
idle desktop, so the socket was almost always mid-reconnect at that moment
and `monitoradded` was lost outright — and the wallpaper repaint, the
docking profile, and the dock-audio reprobe all hung off that single event.
The reconnect path recovered only the lid inhibitor, so the dock stayed
half-configured until something else poked the watcher.

Make the connection long-lived and take the periodic tick from a 1s `read`
timeout instead, then let the *output set* — not the event — decide: a new
`reconcile` diffs it and is called on both ticks and monitor events, so an
event now only makes the reaction instant rather than necessary. A newly
present external output is as unambiguous a physical plug as the event was,
so it may still override a manual sink choice. Wallpaper paints are retried
over the first few seconds because awww registers an output on its own
schedule, not the compositor's.

Hyprland calls every key-capable evdev node a "keyboard": the lid and power
buttons, a monitor's control channel, and each extra HID collection of a
real keyboard. One dock with one keyboard asked for a layout four times.
udev already draws the distinction properly, so take ID_INPUT_KEYBOARD from
it, map the names into Hyprland's spelling, and ask once per physical
keyboard — applying the answer to every node it brought with it.

Finally, a sink WirePlumber has been told to prefer is stored as the
configured default and outranks every priority rule. On UCM cards the
headphones are a sink of their own rather than a route, so once anything
pinned a sink (this feature on a dock plug, or the user in the Audio menu),
plugging headphones in could no longer take the audio back. The `watch`
loop already subscribed to exactly the right events with an empty handler;
finish it. Unplug needs no undo — the pin goes stale and WirePlumber falls
back by priority on its own, to the dock when docked and the speakers
otherwise.

Verified V2: checks.docking-ux and checks.dock-audio pass with new guards
against each regression; the keyboard filter and both sink selectors run
against the dev box's real devices and sink names. The hotplug paths need
hardware — queued as docking round 5.

Also queues #107, renaming theme.json now that it holds the system state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:24:48 +01:00
parent 9550d2be5e
commit e0da1ea40f
5 changed files with 263 additions and 87 deletions

View File

@@ -15,6 +15,12 @@ let
(map (m: lib.removePrefix "~" m."node.name")
(lib.concatMap (r: r.matches) rules."monitor.alsa.rules"));
# Cards driven by ALSA UCM expose speakers and headphones as separate
# sinks (HiFi__Headphones__sink), the headphone one existing only while
# the jack is occupied — so on those machines a jack plug is a sink
# appearing, not the route switch WirePlumber handles by itself.
headphoneSinkRe = "alsa_output\\..*[Hh]eadphone.*";
tool = pkgs.writeShellScriptBin "nomarchy-dock-audio" ''
set -u
PACTL=${pkgs.pulseaudio}/bin/pactl
@@ -35,12 +41,12 @@ let
return 1
}
# Highest-priority dock-class sink whose active port is not explicitly
# Highest-priority sink matching $1 whose active port is not explicitly
# unavailable. With pro-audio profiles a sink may have no ports; its
# existence is then the only availability signal PipeWire exposes.
dock_sinks() {
sinks_matching() {
"$PACTL" --format=json list sinks 2>/dev/null \
| "$JQ" -r --arg re '^(${dockSinkRe})$' '
| "$JQ" -r --arg re "^($1)$" '
[ .[] as $sink
| $sink
| select(.name | test($re))
@@ -55,12 +61,15 @@ let
| sort_by(.priority // 0) | reverse[]
| "\(.name)\t\(.description // .name)"'
}
dock_sinks() { sinks_matching '${dockSinkRe}'; }
headphone_sinks() { sinks_matching '${headphoneSinkRe}'; }
default_sink() { "$PACTL" get-default-sink 2>/dev/null; }
select_first_dock() { # $1 = observable trigger
trigger="$1"
candidate=$(dock_sinks | ${pkgs.coreutils}/bin/head -n 1) || candidate=
select_first() { # $1 = sink lister, $2 = observable trigger
lister="$1"; trigger="$2"
candidate=$("$lister" | ${pkgs.coreutils}/bin/head -n 1) || candidate=
if [ -z "$candidate" ]; then
log "trigger=$trigger result=no-available-dock-sink"
log "trigger=$trigger result=no-available-sink"
return 1
fi
name=''${candidate%%"$TAB"*}
@@ -74,6 +83,7 @@ let
log "trigger=$trigger result=set-default-failed candidate=$name"
return 1
}
select_first_dock() { select_first dock_sinks "$1"; }
case "''${1:-watch}" in
candidates)
@@ -102,10 +112,30 @@ let
else
log "trigger=$trigger result=pulse-did-not-return"
fi ;;
headphones)
# Follow the jack, but only ever *towards* the headphones. On unplug
# the sink disappears and any pin naming it goes stale, which makes
# WirePlumber fall back by priority on its own to the dock when
# docked, to the speakers otherwise so there is nothing to undo.
wait_for_pulse || exit 0
hp=$(headphone_sinks | ${pkgs.coreutils}/bin/head -n 1) || hp=
[ -n "$hp" ] || exit 0
name=''${hp%%"$TAB"*}
[ "$(default_sink)" = "$name" ] && exit 0
select_first headphone_sinks "''${2:-jack}" || true ;;
watch)
# Selection is deliberately NOT done at service startup: a restart or
# relogin while docked must not erase a manual speaker choice. Only
# the compositor's fresh monitoradded event calls `reprobe`.
# Dock selection is deliberately NOT done at service startup: a
# restart or relogin while docked must not erase a manual speaker
# choice. Only the compositor's fresh monitoradded event calls
# `reprobe`.
#
# The jack is different, and is why this loop exists. A sink that
# WirePlumber has been told to prefer (by us on a dock plug, or by
# the user in the Audio menu / a mixer) is stored as the configured
# default, and that outranks every priority rule so on UCM cards,
# where the headphones are a sink of their own rather than a route,
# plugging them in could no longer steal the audio back. Watch for
# that sink appearing and select it explicitly.
wait_for_pulse || true
# A graph restart closes `pactl subscribe`, sometimes with status 0.
# Always resubscribe; log one concise closure line for diagnosis.
@@ -113,7 +143,10 @@ let
if LC_ALL=C "$PACTL" subscribe 2>/dev/null \
| while IFS= read -r line; do
case "$line" in
*"'new' on sink"*|*"'change' on card"*) : ;;
*"'new' on sink"*|*"'change' on card"*)
# Re-entering the tool keeps the decision out of this
# read loop, which must never block on the graph.
"$0" headphones jack || true ;;
esac
done
then status=0; else status=$?; fi
@@ -122,7 +155,7 @@ let
wait_for_pulse || sleep 1
done ;;
*)
echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]]" >&2
echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]|headphones [trigger]]" >&2
exit 64 ;;
esac
'';

View File

@@ -325,11 +325,6 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
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
@@ -429,12 +424,17 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
}
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
in_list() { printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -Fxq "$2"; }
paint_outputs() {
# awww does not inherit the current image when Hyprland adds an output.
# Let the new output settle, then re-apply the current wallpaper to all
# outputs. Backgrounded so profile matching never waits on animation.
sleep 0.5
${sync} --quiet wallpaper >/dev/null 2>&1 || true
# awww does not inherit the current image when Hyprland adds an output,
# and it only paints outputs its daemon has already registered which
# lags the compositor's own monitoradded by an unpredictable margin.
# Re-apply a few times over the first few seconds rather than betting
# on one delay. Backgrounded so profile matching never waits on paint.
for delay in 0.5 1.5 3; do
sleep "$delay"
${sync} --quiet wallpaper >/dev/null 2>&1 || true
done
}
check_monitors() {
@@ -454,68 +454,109 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
fi
}
# 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. `-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
while IFS= read -r line; do
case "$line" in
"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>>"*)
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 &
check_monitors
;;
esac
done < <(${pkgs.socat}/bin/socat -T 1 -U - UNIX-CONNECT:"$socket" 2>/dev/null)
connected_external=$(external_outputs)
# The set of connected outputs not the IPC event stream is the source
# of truth. Hyprland's socket can miss or drop events (see the loop
# below), and a lost monitoradded used to mean no docking profile, no
# wallpaper on the new screen and no audio follow until something else
# poked the watcher. `reconcile` diffs the output set against the last
# one it acted on, so an event only makes the reaction *instant* never
# necessary. Everything here is idempotent and safe to call on a tick.
#
# $1 (optional) = an output Hyprland just announced as removed. It may
# still be listed in `monitors all` while teardown settles, so exclude it
# and restore the internal panel before that lands.
reconcile() {
hint="''${1:-}"
cur=$(outputs)
[ -n "$cur" ] || return
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).
ext=$(external_outputs)
if [ -n "$hint" ]; then
cur=$(printf '%s\n' "$cur" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true)
ext=$(printf '%s\n' "$ext" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true)
fi
if [ "$cur" != "$known_outputs" ]; then
added_ext=
for o in $ext; do
in_list "$known_outputs" "$o" || added_ext="$added_ext $o"
done
gone=
for o in $known_outputs; do
in_list "$cur" "$o" || gone="$gone $o"
done
known_outputs=$cur
if [ -n "$added_ext" ]; then
[ -z "$internal" ] || acquire_inhibitor
# An external output that was not there a moment ago is an
# unambiguous physical plug whether or not we saw the IPC event,
# so it is allowed to override a manual sink choice.
command -v nomarchy-dock-audio >/dev/null 2>&1 \
&& nomarchy-dock-audio reprobe monitoradded &
paint_outputs &
log "outputs-changed added=$added_ext audio-reprobe=scheduled"
fi
# awaiting_lid_open marks an undock already performed for this
# departure the guard is what keeps event + tick from doubling up.
if [ -n "$gone" ] && [ -z "$ext" ] && [ -n "$internal" ] \
&& [ -z "$awaiting_lid_open" ]; then
"$TRANSITION" undock "$internal" || true
awaiting_lid_open=1
log "outputs-changed removed=$gone transition=undock"
fi
check_monitors
fi
# Safety invariants, re-checked regardless of whether the set moved.
if [ -n "$internal" ] && [ -n "$ext" ]; then
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
if [ -n "$awaiting_lid_open" ] && [ -z "$ext" ] && lid_open; then
release_inhibitor
awaiting_lid_open=
fi
}
# Login/relogin while already docked still needs protection. Do not
# change the layout here: the baked config/manual profile remains SoT,
# so seed known_outputs with what is already connected.
internal=$(internal_output)
[ -n "$internal" ] && [ -n "$(external_outputs)" ] && acquire_inhibitor
known_outputs=$(outputs)
# Listen to Hyprland's IPC socket for instant hotplug reaction, and
# reconnect after a compositor reload/socket hiccup. The connection is
# deliberately long-lived: `socat -T 1` used to close it after one second
# of desktop idle, and a dock plugged while idle the normal case
# landed in the reconnect gap and was lost outright. A 1s `read` timeout
# supplies the same periodic tick without ever dropping the socket.
# 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
exec 3< <(${pkgs.socat}/bin/socat -U - UNIX-CONNECT:"$socket" 2>/dev/null)
while :; do
hint=
if IFS= read -r -t 1 line <&3; then
case "$line" in
"monitorremoved>>"*) hint=''${line#monitorremoved>>} ;;
"monitoradded>>"*) : ;;
# socket2 is chatty (focus, workspaces, ); only monitor events
# are worth a reconcile, the idle tick covers everything else.
*) continue ;;
esac
else
# bash returns >128 for the read timeout (our tick); anything else
# is EOF or error, i.e. the socket died and needs a reconnect.
rc=$?
[ "$rc" -gt 128 ] || break
fi
reconcile "$hint"
done
exec 3<&-
sleep 0.25
done
'';
@@ -616,6 +657,30 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
tool=${keyboardTool}/bin/nomarchy-keyboard-layout
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
in_list() { printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -Fxq "$2"; }
# Hyprland calls every evdev node that can emit a key a "keyboard": the
# lid and power buttons, a monitor's control channel, and each extra HID
# collection a real keyboard exposes. One dock with one keyboard on it
# therefore used to ask which layout to use four times, for devices that
# cannot type. udev already draws this distinction properly it sets
# ID_INPUT_KEYBOARD only for full typing keyboards so take the answer
# from there and translate the names into Hyprland's own spelling:
# spaces to '-', then lowercase (its deviceNameToInternalString).
typing_keyboards() {
for dev in /sys/class/input/event*; do
[ -r "$dev/device/name" ] || continue
${pkgs.systemd}/bin/udevadm info -q property -p "$dev" 2>/dev/null \
| ${pkgs.gnugrep}/bin/grep -q '^ID_INPUT_KEYBOARD=1' || continue
IFS= read -r name < "$dev/device/name" || continue
printf '%s\n' "$name" \
| ${pkgs.coreutils}/bin/tr ' ' '-' | ${pkgs.coreutils}/bin/tr 'A-Z' 'a-z'
done | ${pkgs.coreutils}/bin/sort -u
}
# Hyprland disambiguates same-named devices with a -1, -2, suffix, so a
# device's physical keyboard is its name minus that suffix.
base_name() { printf '%s\n' "$1" | ${pkgs.gnused}/bin/sed -E 's/-[0-9]+$//'; }
# Startup: re-apply remembered layouts, never prompt so the built-in
# keyboard just stays on the session default.
@@ -630,20 +695,40 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
while :; do
sleep 3
cur=" "
new=
for kb in $(keyboards); do
cur="$cur$kb "
case "$prev" in *" $kb "*) continue ;; esac
is_declared "$kb" && continue
s=$("$tool" saved "$kb")
if [ -n "$s" ]; then
"$tool" restore "$kb" || true
else
choice=$("$tool" layouts \
| rofi -monitor -1 -dmenu -i -p "Keyboard layout · $kb (e.g. us, gb, pt, br)")
[ -n "$choice" ] && "$tool" apply "$kb" "$choice"
fi
new="$new $kb"
done
prev="$cur"
# Nothing arrived: stay off udev, this is the every-3s common path.
[ -n "$new" ] || continue
typing=$(typing_keyboards)
handled=" "
for kb in $new; do
b=$(base_name "$kb")
in_list "$typing" "$b" || continue
case "$handled" in *" $b "*) continue ;; esac
handled="$handled$b "
# Ask once for the physical keyboard, then answer for every node it
# brought with it they are one keyboard and share one layout.
group=
for k in $new; do
[ "$(base_name "$k")" = "$b" ] && group="$group $k"
done
s=$("$tool" saved "$b")
if [ -n "$s" ]; then
for k in $group; do "$tool" restore "$k" || true; done
else
choice=$("$tool" layouts \
| rofi -monitor -1 -dmenu -i -p "Keyboard layout · $b (e.g. us, gb, pt, br)")
[ -n "$choice" ] || continue
for k in $group; do "$tool" apply "$k" "$choice"; done
fi
done
done
'';
in