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
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:
@@ -95,6 +95,22 @@ pre-existing ISO-pinning gap.
|
|||||||
|
|
||||||
## NEXT
|
## NEXT
|
||||||
|
|
||||||
|
### 107. Rename `theme.json` to reflect that it is the system state
|
||||||
|
|
||||||
|
The state file long ago stopped being about themes: it carries night-light,
|
||||||
|
keyboard memory, display resolution and profiles, auto-commit, services and
|
||||||
|
more, so `theme.json` now misnames its own contents and misleads anyone
|
||||||
|
reading the flake. Rename it to `state.json` (Bernardo, 2026-07-14 —
|
||||||
|
`system.json` is the alternative; pick one and use it everywhere), and carry
|
||||||
|
the `nomarchy-theme-sync` tool name along with it.
|
||||||
|
|
||||||
|
Standalone task — do not fold it into a feature. Ships with a compatibility
|
||||||
|
shim that keeps reading an existing `theme.json` so downstream checkouts do
|
||||||
|
not break on a pull, plus a migration note. Pass = a fresh install and an
|
||||||
|
existing downstream checkout both work, every in-repo reference (modules,
|
||||||
|
tools, template, docs) uses the new name, and the shim is documented with the
|
||||||
|
release it can be dropped in.
|
||||||
|
|
||||||
### 103. Live ISO baseline desktop applications
|
### 103. Live ISO baseline desktop applications
|
||||||
|
|
||||||
Make Chromium and Firefox, `libreoffice-fresh`, GNOME Text Editor, Amberol, and
|
Make Chromium and Firefox, `libreoffice-fresh`, GNOME Text Editor, Amberol, and
|
||||||
|
|||||||
@@ -32,6 +32,28 @@ the **T14s** (webcam case).
|
|||||||
nomarchy-battery-charge-limit.service` stays false, `Result=success`,
|
nomarchy-battery-charge-limit.service` stays false, `Result=success`,
|
||||||
BAT0's `charge_control_end_threshold` still matches the limit, and the
|
BAT0's `charge_control_end_threshold` still matches the limit, and the
|
||||||
journal has no new `start-limit-hit`/`Failed with result` lines.
|
journal has no new `start-limit-hit`/`Failed with result` lines.
|
||||||
|
- [ ] **Docking recovery round 5 (dock/keyboard/audio hotplug, 2026-07-14)**
|
||||||
|
— round 4 was run on the AMD dev box and **failed on four counts**
|
||||||
|
(Bernardo): no wallpaper on the external monitor, no automatic dock
|
||||||
|
mode (System › Display had to be used by hand), audio staying on the
|
||||||
|
laptop speakers while docked, and one dock + one external keyboard
|
||||||
|
asking for a layout **four times**. Separately, plugging headphones had
|
||||||
|
stopped moving audio to them. Causes and fixes in the commit carrying
|
||||||
|
this entry: the display watcher's `socat -T 1` closed Hyprland's IPC
|
||||||
|
socket after a second of idle, so a dock plugged into an idle desktop
|
||||||
|
lost `monitoradded` in the reconnect gap (wallpaper + dock mode + audio
|
||||||
|
all hang off that one event); Hyprland lists every key-capable evdev
|
||||||
|
node as a "keyboard"; and a pinned `default.configured.audio.sink`
|
||||||
|
outranks the priority rules, which kills jack-follow on UCM cards where
|
||||||
|
the headphones are their own sink. **Re-run round 4 below in full**,
|
||||||
|
plus: (a) leave the desktop untouched for ~30s, then plug the dock —
|
||||||
|
wallpaper, dock mode, and audio must all follow with no manual step;
|
||||||
|
(b) the external keyboard must prompt **exactly once**, and never for
|
||||||
|
the monitor or the lid/power buttons; (c) docked and undocked, plug and
|
||||||
|
unplug headphones — audio must move to them and back each time.
|
||||||
|
`journalctl --user -t nomarchy-display-watch` should show
|
||||||
|
`outputs-changed added=…`, and `-t nomarchy-dock-audio`
|
||||||
|
`trigger=jack selected=…` on a headphone plug.
|
||||||
- [ ] **Docking recovery round 4 (#100, closed-lid BenQ sequence)** — after
|
- [ ] **Docking recovery round 4 (#100, closed-lid BenQ sequence)** — after
|
||||||
updating to the commit carrying this entry, run `nomarchy-home` and
|
updating to the commit carrying this entry, run `nomarchy-home` and
|
||||||
relogin. With the lid open, put visible windows on at least workspaces
|
relogin. With the lid open, put visible windows on at least workspaces
|
||||||
|
|||||||
26
flake.nix
26
flake.nix
@@ -894,13 +894,15 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1)
|
menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1)
|
||||||
keyboard=$(find ${gen}/home-path/bin -name nomarchy-keyboard-layout | head -1)
|
keyboard=$(find ${gen}/home-path/bin -name nomarchy-keyboard-layout | head -1)
|
||||||
|
kbwatch=$(find ${gen}/home-path/bin -name nomarchy-keyboard-watch | head -1)
|
||||||
watcher=$(find ${gen}/home-path/bin -name nomarchy-display-profile-watch | head -1)
|
watcher=$(find ${gen}/home-path/bin -name nomarchy-display-profile-watch | head -1)
|
||||||
transition=$(find ${gen}/home-path/bin -name nomarchy-display-transition | head -1)
|
transition=$(find ${gen}/home-path/bin -name nomarchy-display-transition | head -1)
|
||||||
audio=$(find ${gen}/home-path/bin -name nomarchy-dock-audio | head -1)
|
audio=$(find ${gen}/home-path/bin -name nomarchy-dock-audio | head -1)
|
||||||
test -n "$menu" -a -n "$keyboard" -a -n "$watcher" \
|
test -n "$menu" -a -n "$keyboard" -a -n "$watcher" \
|
||||||
-a -n "$transition" -a -n "$audio"
|
-a -n "$transition" -a -n "$audio" -a -n "$kbwatch"
|
||||||
bash -n "$menu"
|
bash -n "$menu"
|
||||||
bash -n "$keyboard"
|
bash -n "$keyboard"
|
||||||
|
bash -n "$kbwatch"
|
||||||
bash -n "$watcher"
|
bash -n "$watcher"
|
||||||
bash -n "$transition"
|
bash -n "$transition"
|
||||||
bash -n "$audio"
|
bash -n "$audio"
|
||||||
@@ -923,9 +925,22 @@
|
|||||||
grep -q 'nomarchy-dock-lid-inhibitor' "$watcher"
|
grep -q 'nomarchy-dock-lid-inhibitor' "$watcher"
|
||||||
! grep -q 'exec -a nomarchy-dock-lid-inhibitor' "$watcher"
|
! grep -q 'exec -a nomarchy-dock-lid-inhibitor' "$watcher"
|
||||||
grep -Fq 'kill -TERM -"$pid"' "$watcher"
|
grep -Fq 'kill -TERM -"$pid"' "$watcher"
|
||||||
grep -q 'done < <(' "$watcher"
|
grep -q 'exec 3< <(' "$watcher"
|
||||||
grep -q '"$TRANSITION" undock' "$watcher"
|
grep -q '"$TRANSITION" undock' "$watcher"
|
||||||
grep -q 'nomarchy-dock-audio reprobe monitoradded' "$watcher"
|
grep -q 'nomarchy-dock-audio reprobe monitoradded' "$watcher"
|
||||||
|
# A dock plugged while the desktop sits idle must still be
|
||||||
|
# seen: `socat -T` closed the IPC socket after a second of
|
||||||
|
# quiet and lost the event in the reconnect gap, so the
|
||||||
|
# output set — not the event — decides, on a read timeout.
|
||||||
|
! grep -q 'socat -T' "$watcher"
|
||||||
|
grep -Fq 'read -r -t 1 line <&3' "$watcher"
|
||||||
|
grep -q 'reconcile "$hint"' "$watcher"
|
||||||
|
grep -q 'outputs-changed added=' "$watcher"
|
||||||
|
# One physical keyboard exposes several key-capable evdev
|
||||||
|
# nodes; only udev's own classification may raise a prompt.
|
||||||
|
grep -q 'ID_INPUT_KEYBOARD=1' "$kbwatch"
|
||||||
|
grep -q 'typing_keyboards' "$kbwatch"
|
||||||
|
grep -q 'base_name' "$kbwatch"
|
||||||
grep -q 'hyprctl --batch' "$transition"
|
grep -q 'hyprctl --batch' "$transition"
|
||||||
grep -q 'keyword monitor.*disable' "$transition"
|
grep -q 'keyword monitor.*disable' "$transition"
|
||||||
grep -q 'result=no-lid-inhibitor' "$transition"
|
grep -q 'result=no-lid-inhibitor' "$transition"
|
||||||
@@ -936,7 +951,12 @@
|
|||||||
grep -q 'restart.*pipewire\|pipewire.service' "$audio"
|
grep -q 'restart.*pipewire\|pipewire.service' "$audio"
|
||||||
grep -q 'set-default-sink' "$audio"
|
grep -q 'set-default-sink' "$audio"
|
||||||
grep -q 'subscription-closed' "$audio"
|
grep -q 'subscription-closed' "$audio"
|
||||||
grep -q 'result=no-available-dock-sink' "$audio"
|
grep -q 'result=no-available-sink' "$audio"
|
||||||
|
# A pinned default sink outranks every priority rule, so on
|
||||||
|
# UCM cards (headphones = their own sink) the jack has to be
|
||||||
|
# followed explicitly or plugging in stops switching output.
|
||||||
|
grep -q 'headphone_sinks' "$audio"
|
||||||
|
grep -Fq '"$0" headphones jack' "$audio"
|
||||||
! grep -q 'power_save' "$audio"
|
! grep -q 'power_save' "$audio"
|
||||||
echo "docking-ux: atomic display, lid inhibitor, and monitor-triggered audio reprobe present"
|
echo "docking-ux: atomic display, lid inhibitor, and monitor-triggered audio reprobe present"
|
||||||
touch $out
|
touch $out
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ let
|
|||||||
(map (m: lib.removePrefix "~" m."node.name")
|
(map (m: lib.removePrefix "~" m."node.name")
|
||||||
(lib.concatMap (r: r.matches) rules."monitor.alsa.rules"));
|
(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" ''
|
tool = pkgs.writeShellScriptBin "nomarchy-dock-audio" ''
|
||||||
set -u
|
set -u
|
||||||
PACTL=${pkgs.pulseaudio}/bin/pactl
|
PACTL=${pkgs.pulseaudio}/bin/pactl
|
||||||
@@ -35,12 +41,12 @@ let
|
|||||||
return 1
|
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
|
# unavailable. With pro-audio profiles a sink may have no ports; its
|
||||||
# existence is then the only availability signal PipeWire exposes.
|
# existence is then the only availability signal PipeWire exposes.
|
||||||
dock_sinks() {
|
sinks_matching() {
|
||||||
"$PACTL" --format=json list sinks 2>/dev/null \
|
"$PACTL" --format=json list sinks 2>/dev/null \
|
||||||
| "$JQ" -r --arg re '^(${dockSinkRe})$' '
|
| "$JQ" -r --arg re "^($1)$" '
|
||||||
[ .[] as $sink
|
[ .[] as $sink
|
||||||
| $sink
|
| $sink
|
||||||
| select(.name | test($re))
|
| select(.name | test($re))
|
||||||
@@ -55,12 +61,15 @@ let
|
|||||||
| sort_by(.priority // 0) | reverse[]
|
| sort_by(.priority // 0) | reverse[]
|
||||||
| "\(.name)\t\(.description // .name)"'
|
| "\(.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
|
select_first() { # $1 = sink lister, $2 = observable trigger
|
||||||
trigger="$1"
|
lister="$1"; trigger="$2"
|
||||||
candidate=$(dock_sinks | ${pkgs.coreutils}/bin/head -n 1) || candidate=
|
candidate=$("$lister" | ${pkgs.coreutils}/bin/head -n 1) || candidate=
|
||||||
if [ -z "$candidate" ]; then
|
if [ -z "$candidate" ]; then
|
||||||
log "trigger=$trigger result=no-available-dock-sink"
|
log "trigger=$trigger result=no-available-sink"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
name=''${candidate%%"$TAB"*}
|
name=''${candidate%%"$TAB"*}
|
||||||
@@ -74,6 +83,7 @@ let
|
|||||||
log "trigger=$trigger result=set-default-failed candidate=$name"
|
log "trigger=$trigger result=set-default-failed candidate=$name"
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
select_first_dock() { select_first dock_sinks "$1"; }
|
||||||
|
|
||||||
case "''${1:-watch}" in
|
case "''${1:-watch}" in
|
||||||
candidates)
|
candidates)
|
||||||
@@ -102,10 +112,30 @@ let
|
|||||||
else
|
else
|
||||||
log "trigger=$trigger result=pulse-did-not-return"
|
log "trigger=$trigger result=pulse-did-not-return"
|
||||||
fi ;;
|
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)
|
watch)
|
||||||
# Selection is deliberately NOT done at service startup: a restart or
|
# Dock selection is deliberately NOT done at service startup: a
|
||||||
# relogin while docked must not erase a manual speaker choice. Only
|
# restart or relogin while docked must not erase a manual speaker
|
||||||
# the compositor's fresh monitoradded event calls `reprobe`.
|
# 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
|
wait_for_pulse || true
|
||||||
# A graph restart closes `pactl subscribe`, sometimes with status 0.
|
# A graph restart closes `pactl subscribe`, sometimes with status 0.
|
||||||
# Always resubscribe; log one concise closure line for diagnosis.
|
# Always resubscribe; log one concise closure line for diagnosis.
|
||||||
@@ -113,7 +143,10 @@ let
|
|||||||
if LC_ALL=C "$PACTL" subscribe 2>/dev/null \
|
if LC_ALL=C "$PACTL" subscribe 2>/dev/null \
|
||||||
| while IFS= read -r line; do
|
| while IFS= read -r line; do
|
||||||
case "$line" in
|
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
|
esac
|
||||||
done
|
done
|
||||||
then status=0; else status=$?; fi
|
then status=0; else status=$?; fi
|
||||||
@@ -122,7 +155,7 @@ let
|
|||||||
wait_for_pulse || sleep 1
|
wait_for_pulse || sleep 1
|
||||||
done ;;
|
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 ;;
|
exit 64 ;;
|
||||||
esac
|
esac
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -325,11 +325,6 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
inhibitor_file="$rt/nomarchy-dock-lid-inhibitor.pid"
|
inhibitor_file="$rt/nomarchy-dock-lid-inhibitor.pid"
|
||||||
awaiting_lid_open=
|
awaiting_lid_open=
|
||||||
log() { "$LOGGER" -t nomarchy-display-watch -- "$*"; }
|
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() {
|
internal_output() {
|
||||||
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
|
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
|
||||||
printf '%s\n' "$NOMARCHY_INTERNAL_OUTPUT"; return
|
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
|
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
||||||
}
|
}
|
||||||
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
|
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() {
|
paint_outputs() {
|
||||||
# awww does not inherit the current image when Hyprland adds an output.
|
# 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
|
# and it only paints outputs its daemon has already registered — which
|
||||||
# outputs. Backgrounded so profile matching never waits on animation.
|
# lags the compositor's own monitoradded by an unpredictable margin.
|
||||||
sleep 0.5
|
# Re-apply a few times over the first few seconds rather than betting
|
||||||
${sync} --quiet wallpaper >/dev/null 2>&1 || true
|
# 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() {
|
check_monitors() {
|
||||||
@@ -454,68 +454,109 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Login/relogin while already docked still needs protection. Do not
|
# The set of connected outputs — not the IPC event stream — is the source
|
||||||
# change the layout here: the baked config/manual profile remains SoT.
|
# of truth. Hyprland's socket can miss or drop events (see the loop
|
||||||
internal=$(internal_output)
|
# below), and a lost monitoradded used to mean no docking profile, no
|
||||||
[ -n "$internal" ] && [ -n "$(external_outputs)" ] && acquire_inhibitor
|
# wallpaper on the new screen and no audio follow until something else
|
||||||
|
# poked the watcher. `reconcile` diffs the output set against the last
|
||||||
# Listen to Hyprland's IPC socket for instant hotplug reaction. Reconnect
|
# one it acted on, so an event only makes the reaction *instant* — never
|
||||||
# after a compositor reload/socket hiccup. `-T 1` also gives the parent
|
# necessary. Everything here is idempotent and safe to call on a tick.
|
||||||
# shell a periodic chance to observe lid-open and release the inhibitor.
|
#
|
||||||
# Process substitution is deliberate: a pipeline `... | while read`
|
# $1 (optional) = an output Hyprland just announced as removed. It may
|
||||||
# would run the loop in a subshell and lose inhibitor lifecycle state.
|
# still be listed in `monitors all` while teardown settles, so exclude it
|
||||||
socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
|
# and restore the internal panel before that lands.
|
||||||
while :; do
|
reconcile() {
|
||||||
while IFS= read -r line; do
|
hint="''${1:-}"
|
||||||
case "$line" in
|
cur=$(outputs)
|
||||||
"monitorremoved>>"*)
|
[ -n "$cur" ] || return
|
||||||
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)
|
|
||||||
internal=$(internal_output)
|
internal=$(internal_output)
|
||||||
if [ -n "$internal" ] && [ -n "$connected_external" ]; then
|
ext=$(external_outputs)
|
||||||
# Socket reconnect may have hidden the add event. Recover the safety
|
if [ -n "$hint" ]; then
|
||||||
# invariant (but do not call audio: only an observed fresh event is
|
cur=$(printf '%s\n' "$cur" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true)
|
||||||
# allowed to override a manual sink choice).
|
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
|
if [ -z "$inhibitor_pid" ] || ! kill -0 "$inhibitor_pid" 2>/dev/null; then
|
||||||
acquire_inhibitor || true
|
acquire_inhibitor || true
|
||||||
fi
|
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
|
fi
|
||||||
if [ -n "$awaiting_lid_open" ] && [ -z "$(external_outputs)" ] && lid_open; then
|
if [ -n "$awaiting_lid_open" ] && [ -z "$ext" ] && lid_open; then
|
||||||
release_inhibitor
|
release_inhibitor
|
||||||
awaiting_lid_open=
|
awaiting_lid_open=
|
||||||
fi
|
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
|
sleep 0.25
|
||||||
done
|
done
|
||||||
'';
|
'';
|
||||||
@@ -616,6 +657,30 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
tool=${keyboardTool}/bin/nomarchy-keyboard-layout
|
tool=${keyboardTool}/bin/nomarchy-keyboard-layout
|
||||||
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
|
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
|
||||||
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
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
|
# Startup: re-apply remembered layouts, never prompt — so the built-in
|
||||||
# keyboard just stays on the session default.
|
# keyboard just stays on the session default.
|
||||||
@@ -630,20 +695,40 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
while :; do
|
while :; do
|
||||||
sleep 3
|
sleep 3
|
||||||
cur=" "
|
cur=" "
|
||||||
|
new=
|
||||||
for kb in $(keyboards); do
|
for kb in $(keyboards); do
|
||||||
cur="$cur$kb "
|
cur="$cur$kb "
|
||||||
case "$prev" in *" $kb "*) continue ;; esac
|
case "$prev" in *" $kb "*) continue ;; esac
|
||||||
is_declared "$kb" && continue
|
is_declared "$kb" && continue
|
||||||
s=$("$tool" saved "$kb")
|
new="$new $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
|
|
||||||
done
|
done
|
||||||
prev="$cur"
|
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
|
done
|
||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
|
|||||||
Reference in New Issue
Block a user