fix(docking): make workspace and lid transitions atomic
All checks were successful
Check / eval (push) Successful in 3m19s
All checks were successful
Check / eval (push) Successful in 3m19s
Restore the old flake's external-only workspace handoff, protect closed-lid cable removal with a verified low-level logind inhibitor, and use monitor-added as the settled audio reprobe boundary. Verified: V2 — nix flake check --no-build; docking-ux, option-docs, and template-sot checks; KVM tools/monitor-fallback.nix lifecycle test. V3 pending: closed-lid BenQ round 4 in agent/HARDWARE-QUEUE.md.
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user