fix(docking): make workspace and lid transitions atomic
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:
2026-07-13 13:56:54 +01:00
parent cffe432912
commit 2a34c7398b
12 changed files with 654 additions and 292 deletions

View File

@@ -1,19 +1,11 @@
# Automatic audio-output follow for docks / external monitors (#87 round
# 2). The WirePlumber priority rules (modules/nixos/dock-audio-rules.nix)
# only decide among sinks when there is NO stored user default — but
# WirePlumber persists every explicit pick (menu, pavucontrol, pactl) in
# its default-nodes state, and that stored default outranks priority. So
# on any machine where a device was ever picked, docking never moves the
# default — Bernardo's T14s dock test, 2026-07-12. This watcher makes the
# switch explicit: when a dock-class sink appears (same regexes as the
# wireplumber rules — imported from that file, single source of truth) it
# `pactl set-default-sink`s it and toasts. EasyEffects needs no handling:
# EE 8's useDefaultOutputDevice defaults to true, so its output follows
# the default device and effects stay in the chain (a user who pinned a
# device inside EE keeps their pin — that's an explicit choice). Unplug
# needs no handling either: the stored dock sink vanishes and WirePlumber
# falls back by priority to the built-in output. A manual pick made while
# docked sticks until the next plug event (a plug is treated as intent).
# Automatic audio-output follow for docks / external monitors (#100).
# WirePlumber's stored default outranks priority.session, and some HDMI
# codecs only expose their usable route after the display hotplug. The
# Hyprland monitor watcher therefore calls `reprobe monitoradded` for the
# unambiguous physical-plug event: restart the graph (the recovery used by
# the old working flake), wait for it to settle, then explicitly select an
# available dock-class sink. Ordinary sink changes never select anything,
# so a manual speaker choice sticks until a fresh monitor plug.
{ config, lib, pkgs, ... }:
let
@@ -23,83 +15,129 @@ let
(map (m: lib.removePrefix "~" m."node.name")
(lib.concatMap (r: r.matches) rules."monitor.alsa.rules"));
watcher = pkgs.writeShellScript "nomarchy-dock-audio" ''
set -euo pipefail
tool = pkgs.writeShellScriptBin "nomarchy-dock-audio" ''
set -u
PACTL=${pkgs.pulseaudio}/bin/pactl
JQ=${pkgs.jq}/bin/jq
SYSTEMCTL=${pkgs.systemd}/bin/systemctl
LOGGER=${pkgs.util-linux}/bin/logger
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
lock="$rt/nomarchy-dock-audio-reprobe.lock"
TAB=$(printf '\t')
# Self-gate: no PipeWire pulse socket (nomarchy.audio off, TTY-only
# session) means nothing to watch exit clean, stay dead.
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
for _ in $(seq 60); do [ -e "$rt/pulse/native" ] && break; sleep 1; done
[ -e "$rt/pulse/native" ] || exit 0
is_dock() {
printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -qE '^(${dockSinkRe})$'
}
switch_to() { # $1 = sink node name, $2 = description
[ "$($PACTL get-default-sink)" = "$1" ] && return 0
$PACTL set-default-sink "$1" 2>/dev/null || return 0
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Audio" \
"Output ''${2:-$1}" 2>/dev/null || true
}
# Return dock-class sinks whose current port is usable. PipeWire often
# creates the HDMI/DP node before the cable is plugged and later flips its
# card/port availability, so presence alone is not a hotplug signal.
dock_sinks() {
$PACTL --format=json list sinks \
| $JQ -r --arg re '^(${dockSinkRe})$' '
.[]
| select(.name | test($re))
| select(
((.ports // []) | length) == 0
or any(.ports[]?; (.availability // "unknown") != "not available")
)
| "\(.name)\t\(.description)"'
}
adopt_first_dock() {
dock_sinks | while IFS="$TAB" read -r name desc; do
[ -n "$name" ] || continue
switch_to "$name" "$desc"
break
log() { "$LOGGER" -t nomarchy-dock-audio -- "$*"; }
wait_for_pulse() {
tries=0
while [ "$tries" -lt 40 ]; do
"$PACTL" info >/dev/null 2>&1 && return 0
tries=$((tries+1)); sleep 0.25
done
return 1
}
# Startup sweep (login/relogin while already docked): adopt a dock
# sink only when the current default is NOT one a service restart
# must not override a manual in-dock pick.
if ! is_dock "$($PACTL get-default-sink)"; then
adopt_first_dock
fi
# Highest-priority dock-class sink 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() {
"$PACTL" --format=json list sinks 2>/dev/null \
| "$JQ" -r --arg re '^(${dockSinkRe})$' '
[ .[] as $sink
| $sink
| select(.name | test($re))
| select(
((.ports // []) | length) == 0
or .active_port == null
or ([.ports[]?
| select(.name == $sink.active_port)
| (.availability // "unknown")][0]
// "unknown") != "not available"
) ]
| sort_by(.priority // 0) | reverse[]
| "\(.name)\t\(.description // .name)"'
}
# Event loop: handle both a genuinely new sink and the common case where
# an existing HDMI/DP card changes profile/port availability. Deliberately
# ignore ordinary sink "change" events (volume/mute) and server-default
# changes, so a manual speaker pick while docked remains sticky.
LC_ALL=C $PACTL subscribe | while read -r _ ev _ kind id; do
case "$ev:$kind" in
"'new':sink"|"'new':card"|"'change':card") ;;
*) continue ;;
esac
sleep 0.75 # let PipeWire finish the profile/route transition
adopt_first_dock
done
select_first_dock() { # $1 = observable trigger
trigger="$1"
candidate=$(dock_sinks | ${pkgs.coreutils}/bin/head -n 1) || candidate=
if [ -z "$candidate" ]; then
log "trigger=$trigger result=no-available-dock-sink"
return 1
fi
name=''${candidate%%"$TAB"*}
desc=''${candidate#*"$TAB"}
if "$PACTL" set-default-sink "$name" 2>/dev/null; then
log "trigger=$trigger selected=$name description=$desc"
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Audio" \
"Output $desc" 2>/dev/null || true
return 0
fi
log "trigger=$trigger result=set-default-failed candidate=$name"
return 1
}
case "''${1:-watch}" in
candidates)
dock_sinks ;;
select)
wait_for_pulse || { log "trigger=''${2:-manual} result=pulse-unavailable"; exit 1; }
select_first_dock "''${2:-manual}" ;;
reprobe)
trigger="''${2:-monitoradded}"
# mkdir is the debounce/lock: simultaneous monitoradded events from
# an MST dock collapse into one graph restart. Runtime-only state,
# removed on every exit path.
if ! ${pkgs.coreutils}/bin/mkdir "$lock" 2>/dev/null; then
log "trigger=$trigger result=debounced"
exit 0
fi
trap '${pkgs.coreutils}/bin/rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
log "trigger=$trigger action=reprobe-start"
sleep 2
"$SYSTEMCTL" --user restart \
pipewire.service pipewire-pulse.service wireplumber.service \
>/dev/null 2>&1 || log "trigger=$trigger action=graph-restart-returned-error"
if wait_for_pulse; then
sleep 0.75
select_first_dock "$trigger" || true
else
log "trigger=$trigger result=pulse-did-not-return"
fi ;;
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`.
wait_for_pulse || true
# A graph restart closes `pactl subscribe`, sometimes with status 0.
# Always resubscribe; log one concise closure line for diagnosis.
while :; do
if LC_ALL=C "$PACTL" subscribe 2>/dev/null \
| while IFS= read -r line; do
case "$line" in
*"'new' on sink"*|*"'change' on card"*) : ;;
esac
done
then status=0; else status=$?; fi
log "subscription-closed status=$status; retrying"
sleep 1
wait_for_pulse || sleep 1
done ;;
*)
echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]]" >&2
exit 64 ;;
esac
'';
in
{
config = lib.mkIf cfg.dockAudio.enable {
home.packages = [ tool ];
systemd.user.services.nomarchy-dock-audio = {
Unit = {
Description = "Move the default audio sink to dock/monitor outputs on hotplug";
Description = "Reprobe and select dock/monitor audio on display hotplug";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${watcher}";
ExecStart = "${tool}/bin/nomarchy-dock-audio watch";
Restart = "on-failure";
RestartSec = 2;
};

View File

@@ -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);
}

View File

@@ -364,7 +364,7 @@ in
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; };
batteryNotify.enable = lib.mkEnableOption "low-battery notifications at the bar's thresholds (25% low, 10% critical that one stays up until dismissed); self-gating, a silent no-op on machines without a battery" // { default = true; };
dockAudio.enable = lib.mkEnableOption "automatic default-output switch to dock/monitor audio (HDMI/DisplayPort/USB sinks) on hotplug, with a toast; EasyEffects follows along, and unplugging falls back to the built-in output" // { default = true; };
dockAudio.enable = lib.mkEnableOption "settled PipeWire/WirePlumber reprobe and automatic default-output switch to an available dock/monitor sink (HDMI/DisplayPort/USB) on fresh display hotplug, with a toast and journal result; a later manual choice sticks until the next plug" // { default = true; };
firstBootWelcome.enable = lib.mkEnableOption "one dismissible \"you're set\" toast on the first session (menu/themes/keys + network pointer); marker is settings.firstBootShown in the flake checkout" // { default = true; };
idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; };
yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; };

View File

@@ -15,14 +15,6 @@ let
px = n: mkLiteral "${toString n}px";
# Dock-class audio sinks (HDMI/DP/USB) — the same regex data the
# WirePlumber priority rules and the dock-audio watcher consume
# (modules/nixos/dock-audio-rules.nix is the single source).
dockSinkRe = lib.concatStringsSep "|"
(map (m: lib.removePrefix "~" m."node.name")
(lib.concatMap (r: r.matches)
(import ../nixos/dock-audio-rules.nix)."monitor.alsa.rules"));
# Per-theme override probe (same convention as waybar.css).
rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
hasRasiOverride = builtins.pathExists rasiOverride;
@@ -507,24 +499,16 @@ ${themeRows}
if [ ! -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ]; then
notify-send "Audio" "No PipeWire/PulseAudio socket (nomarchy.audio off?)."; exit 0
fi
# Docked quick row: the first dock-class sink (HDMI/DP/USB
# dockSinkRe, single source with the wireplumber rules) that is
# not already the default. One pick sends output there; the
# dock-audio watcher normally does this on plug, so this row is
# the manual override / catch-up. pactl by store path it is
# not on PATH (PipeWire stack, wpctl only).
sinks=$(${pkgs.pulseaudio}/bin/pactl --format=json list sinks 2>/dev/null || true)
# The helper owns dock-class detection + availability (regexes come
# from dock-audio-rules.nix once). This row is the manual catch-up.
candidate=
command -v nomarchy-dock-audio >/dev/null 2>&1 \
&& candidate=$(nomarchy-dock-audio candidates 2>/dev/null \
| ${pkgs.coreutils}/bin/head -n 1)
dockname=''${candidate%%"$TAB"*}
dockdesc=''${candidate#*"$TAB"}
cursink=$(${pkgs.pulseaudio}/bin/pactl get-default-sink 2>/dev/null || true)
dockname=$(printf '%s' "$sinks" | jq -r --arg cur "$cursink" --arg re '${dockSinkRe}' \
'[.[]
| select((.name | test("^(" + $re + ")$")) and .name != $cur)
| select(
((.ports // []) | length) == 0
or any(.ports[]?; (.availability // "unknown") != "not available")
)][0].name // empty')
dockdesc=
[ -n "$dockname" ] && dockdesc=$(printf '%s' "$sinks" \
| jq -r --arg n "$dockname" '[.[] | select(.name == $n)][0].description // empty')
[ "$dockname" = "$cursink" ] && dockname=
choice=$( {
[ -n "$dockname" ] && row "Send output ''${dockdesc:-$dockname}" video-display
row "Output device" audio-volume-high
@@ -534,8 +518,7 @@ ${themeRows}
case "$choice" in
"$BACK") exec "$0" system ;;
"Send output"*)
${pkgs.pulseaudio}/bin/pactl set-default-sink "$dockname" \
&& notify-send "Audio" "Output ''${dockdesc:-$dockname}" ;;
nomarchy-dock-audio select menu ;;
*Output*) exec rofi-pulse-select sink ;;
*Input*) exec rofi-pulse-select source ;;
esac ;;
@@ -599,12 +582,11 @@ ${themeRows}
if [ -n "$profs" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then
name=$( {
[ -n "$profs" ] && printf 'Profiles \n'
# Dock mode deliberately keeps the internal panel logically
# active as the unplug fallback. Soft-disabling it can leave
# Hyprland with zero outputs and kill the whole session before a
# user-space rescue runs. Move its workspaces and focus instead.
# Dock mode performs an atomic workspace handoff and only then
# disables the internal panel. The always-on hotplug watcher
# holds logind's lid-switch inhibitor across the reverse path.
[ "$nActive" -gt 1 ] && [ -n "$internal" ] && [ -n "$external" ] \
&& printf 'Dock mode · everything %s (safe unplug)\n' "$external"
&& printf 'Dock mode · external only %s\n' "$external"
[ -n "$offmon" ] && printf 'Screen on · %s\n' "$offmon"
[ "$nActive" -gt 1 ] && printf 'Move workspace · next monitor\n'
[ "$nActive" -eq 2 ] && printf 'Swap workspaces · %s %s\n' \
@@ -617,20 +599,17 @@ ${themeRows}
[ "$name" = "Profiles " ] && exec "$0" display-profile
case "$name" in
"Dock mode"*)
moved=0
while read -r ws; do
[ -n "$ws" ] || continue
hyprctl dispatch moveworkspacetomonitor "$ws" "$external" >/dev/null 2>&1 \
&& moved=$((moved+1))
done < <(hyprctl workspaces -j | jq -r --arg m "$internal" \
'.[] | select(.monitor == $m and .id > 0) | .id')
hyprctl dispatch focusmonitor "$external" >/dev/null 2>&1
notify-send "Display" "Dock mode $moved workspace(s) moved to $external; laptop panel kept as the unplug fallback."
if nomarchy-display-transition dock "$internal" "$external" \
"$external, preferred, auto, 1"; then
notify-send "Display" "Dock mode every laptop workspace moved to $external; laptop panel off."
else
notify-send "Display" "Dock transition failed; the previous layout was kept where possible."
fi
exit 0 ;;
"Screen on"*)
# preferred/auto/auto is good enough live; the declared
# preferred/auto/1 is the known-good live fallback; the declared
# scale/position come back at the next relogin/switch.
if hyprctl keyword monitor "$offmon,preferred,auto,auto" >/dev/null 2>&1; then
if nomarchy-display-transition enable "$offmon"; then
notify-send "Display" "$offmon is back on."
else
notify-send "Display" "Could not enable $offmon."