fix(docking): make clamshell hotplug safe and discoverable
All checks were successful
Check / eval (push) Successful in 3m32s
All checks were successful
Check / eval (push) Successful in 3m32s
Real dock QA exposed pointer-bound menus, an absent default rescue watcher, the unsafe zero-output panel-off path, missing keyboard/audio hotplug coverage, and unpainted new outputs. Route Rofi to the focused output; replace panel disable with safe workspace migration; always run and reconnect the display watcher and repaint hotplugged outputs; expose a default per-device keyboard picker; handle audio card availability transitions. Verified: V1 — nix flake check --no-build; checks.docking-ux real Home Manager artifact build; option-docs/template-sot; Nix parse, py_compile, and git diff --check. V2 unavailable: no readable KVM. V3 pending in agent/HARDWARE-QUEUE.md.
This commit is contained in:
@@ -215,13 +215,21 @@ ${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; }
|
||||
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
|
||||
}
|
||||
|
||||
# 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): menu "Laptop screen off" + sudden undock
|
||||
# left a dead session. On every monitorremoved, if nothing is
|
||||
# active, re-enable whatever is disabled (preferred/auto — the
|
||||
# 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.
|
||||
@@ -237,17 +245,23 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
# ~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
|
||||
while [ "$(active_count)" = 0 ] && [ "$tries" -lt 4 ]; do
|
||||
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
|
||||
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 1
|
||||
sleep 0.25
|
||||
done
|
||||
if [ "$tries" -gt 0 ] && [ "$(active_count)" != 0 ]; then
|
||||
if [ -n "$rescued" ] && [ "$(active_count)" != 0 ]; then
|
||||
notify-send "Display" "Screen re-enabled — the active output disappeared." 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
@@ -257,6 +271,7 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
[ "$cur" = "$1" ] && return
|
||||
[ -n "$cur" ] || return
|
||||
auto_on || return
|
||||
command -v nomarchy-display-profile >/dev/null 2>&1 || return
|
||||
|
||||
target=$(nomarchy-display-profile match $cur) || target="base"
|
||||
|
||||
@@ -271,20 +286,27 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
}
|
||||
|
||||
prev=$(outputs)
|
||||
|
||||
# Listen to Hyprland's IPC socket for instant hotplug reaction
|
||||
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do
|
||||
case "$line" in
|
||||
monitorremoved*)
|
||||
rescue_blackout
|
||||
res=$(check_monitors "$prev")
|
||||
[ -n "$res" ] && prev="$res"
|
||||
;;
|
||||
monitoradded*)
|
||||
res=$(check_monitors "$prev")
|
||||
[ -n "$res" ] && prev="$res"
|
||||
;;
|
||||
esac
|
||||
|
||||
# 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.
|
||||
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
|
||||
case "$line" in
|
||||
monitorremoved*)
|
||||
rescue_blackout
|
||||
res=$(check_monitors "$prev")
|
||||
[ -n "$res" ] && prev="$res"
|
||||
;;
|
||||
monitoradded*)
|
||||
paint_outputs &
|
||||
res=$(check_monitors "$prev")
|
||||
[ -n "$res" ] && prev="$res"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
sleep 1
|
||||
done
|
||||
'';
|
||||
|
||||
@@ -299,7 +321,67 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
(lib.splitString "," config.nomarchy.keyboard.layout)
|
||||
++ config.nomarchy.keyboard.layouts
|
||||
);
|
||||
kbAutoSwitch = config.nomarchy.keyboard.layouts != [ ];
|
||||
|
||||
# Per-device keyboard helper shared by the hotplug watcher and the manual
|
||||
# System › Keyboard menu. The configured candidates are listed first, then
|
||||
# every XKB layout known to the system, so the feature works on a default
|
||||
# install instead of silently disappearing until home.nix is edited.
|
||||
keyboardTool = pkgs.writeShellScriptBin "nomarchy-keyboard-layout" ''
|
||||
set -u
|
||||
sync=${sync}
|
||||
|
||||
layouts() {
|
||||
printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg pickerLayouts}
|
||||
${pkgs.gawk}/bin/awk '
|
||||
/^! layout/ { in_layouts=1; next }
|
||||
/^!/ && in_layouts { exit }
|
||||
in_layouts && NF { print $1 }
|
||||
' ${pkgs.xkeyboard_config}/share/X11/xkb/rules/base.lst
|
||||
}
|
||||
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
|
||||
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
|
||||
apply_runtime() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
|
||||
|
||||
case "''${1:-}" in
|
||||
layouts)
|
||||
layouts | ${pkgs.gawk}/bin/awk 'NF && !seen[$0]++' ;;
|
||||
devices)
|
||||
hyprctl devices -j 2>/dev/null \
|
||||
| jq -r '.keyboards[] | "\(.name)\t\(.active_keymap // \"unknown\")"' ;;
|
||||
saved)
|
||||
[ -n "''${2:-}" ] || exit 64
|
||||
saved_for "$2" ;;
|
||||
restore)
|
||||
[ -n "''${2:-}" ] || exit 64
|
||||
layout=$(saved_for "$2")
|
||||
[ -n "$layout" ] && apply_runtime "$2" "$layout" ;;
|
||||
apply)
|
||||
[ -n "''${2:-}" ] && [ -n "''${3:-}" ] || exit 64
|
||||
layouts | ${pkgs.gnugrep}/bin/grep -Fxq "$3" \
|
||||
|| { notify-send "Keyboard" "Unknown XKB layout: $3"; exit 64; }
|
||||
if apply_runtime "$2" "$3"; then
|
||||
map=$(saved_map | jq -c --arg k "$2" --arg v "$3" '. + {($k): $v}') || exit 1
|
||||
if "$sync" --quiet set settings.keyboard.devices "$map" --no-switch; then
|
||||
notify-send "Keyboard" "$2 → $3"
|
||||
else
|
||||
notify-send "Keyboard" "$2 → $3 for this session, but the choice could not be saved."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
notify-send "Keyboard" "Could not apply $3 to $2."
|
||||
exit 1
|
||||
fi ;;
|
||||
forget)
|
||||
[ -n "''${2:-}" ] || exit 64
|
||||
map=$(saved_map | jq -c --arg k "$2" 'del(.[$k])') || exit 1
|
||||
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch || exit 1
|
||||
apply_runtime "$2" ${lib.escapeShellArg config.nomarchy.keyboard.layout} || true
|
||||
notify-send "Keyboard" "$2 now follows the session layout." ;;
|
||||
*)
|
||||
echo "usage: nomarchy-keyboard-layout [layouts|devices|saved <device>|restore <device>|apply <device> <layout>|forget <device>]" >&2
|
||||
exit 64 ;;
|
||||
esac
|
||||
'';
|
||||
|
||||
# On a keyboard that connects after login (not declared in
|
||||
# keyboard.devices, not already remembered), ask for a layout and persist it
|
||||
@@ -320,34 +402,18 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
# test (hotplug isn't verifiable in CI).
|
||||
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
|
||||
set -u
|
||||
layouts="${lib.concatStringsSep " " pickerLayouts}"
|
||||
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
|
||||
sync=${sync}
|
||||
|
||||
apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
|
||||
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; }
|
||||
|
||||
# The remembered map (device-name -> layout) from the LIVE in-flake state;
|
||||
# an absent key (sparse state) reads as an empty object.
|
||||
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
|
||||
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
|
||||
|
||||
# Persist a pick INSTANTLY: merge it into the map and write the whole object
|
||||
# back at the dot-free parent path, so a device name containing a dot can't
|
||||
# corrupt the dotted set-path. --no-switch = write only, no rebuild.
|
||||
remember() {
|
||||
map=$(saved_map | jq -c --arg k "$1" --arg v "$2" '. + {($k): $v}') || return
|
||||
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch
|
||||
}
|
||||
|
||||
# Startup: re-apply remembered layouts, never prompt — so the built-in
|
||||
# keyboard just stays on the session default.
|
||||
prev=" "
|
||||
for kb in $(keyboards); do
|
||||
prev="$prev$kb "
|
||||
is_declared "$kb" && continue
|
||||
s=$(saved_for "$kb"); [ -n "$s" ] && apply "$kb" "$s"
|
||||
"$tool" restore "$kb" || true
|
||||
done
|
||||
|
||||
# Watch for keyboards that connect later.
|
||||
@@ -358,12 +424,13 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
cur="$cur$kb "
|
||||
case "$prev" in *" $kb "*) continue ;; esac
|
||||
is_declared "$kb" && continue
|
||||
s=$(saved_for "$kb")
|
||||
s=$("$tool" saved "$kb")
|
||||
if [ -n "$s" ]; then
|
||||
apply "$kb" "$s"
|
||||
"$tool" restore "$kb" || true
|
||||
else
|
||||
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb")
|
||||
[ -n "$choice" ] && { remember "$kb" "$choice"; apply "$kb" "$choice"; }
|
||||
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
|
||||
done
|
||||
prev="$cur"
|
||||
@@ -420,8 +487,10 @@ in
|
||||
# the clean pkill a theme switch now does — respawns the bar instead
|
||||
# of orphaning the session bar-less.
|
||||
] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar"
|
||||
++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch"
|
||||
++ lib.optional (profiles != { }) "${displayProfileWatch}/bin/nomarchy-display-profile-watch";
|
||||
++ [
|
||||
"${keyboardWatch}/bin/nomarchy-keyboard-watch"
|
||||
"${displayProfileWatch}/bin/nomarchy-display-profile-watch"
|
||||
];
|
||||
|
||||
# ── Theme-driven look ──────────────────────────────────────────
|
||||
# These flow from theme-state.json and stay at NORMAL priority:
|
||||
@@ -636,11 +705,10 @@ in
|
||||
home.packages =
|
||||
lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
|
||||
[ pkgs.nwg-displays ]
|
||||
# The new-keyboard watcher on PATH (when enabled) so it's discoverable and
|
||||
# runnable by name for debugging — Hyprland still exec-once's it by store path.
|
||||
++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch
|
||||
# The display-profile switcher (the menu's Profiles row gates on it)
|
||||
# + its hotplug watcher, on PATH for debugging like the keyboard one.
|
||||
++ lib.optionals (config.nomarchy.hyprland.enable && profiles != { })
|
||||
[ displayProfileTool displayProfileWatch ];
|
||||
# Runtime hardware watchers/helpers stay on PATH for manual recovery and
|
||||
# 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 ]
|
||||
++ lib.optional (profiles != { }) displayProfileTool);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user