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.
110 lines
4.4 KiB
Nix
110 lines
4.4 KiB
Nix
# 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).
|
|
{ config, lib, pkgs, ... }:
|
|
|
|
let
|
|
cfg = config.nomarchy;
|
|
rules = import ../nixos/dock-audio-rules.nix;
|
|
dockSinkRe = lib.concatStringsSep "|"
|
|
(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
|
|
PACTL=${pkgs.pulseaudio}/bin/pactl
|
|
JQ=${pkgs.jq}/bin/jq
|
|
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
|
|
done
|
|
}
|
|
|
|
# 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
|
|
|
|
# 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
|
|
'';
|
|
in
|
|
{
|
|
config = lib.mkIf cfg.dockAudio.enable {
|
|
systemd.user.services.nomarchy-dock-audio = {
|
|
Unit = {
|
|
Description = "Move the default audio sink to dock/monitor outputs on hotplug";
|
|
After = [ "graphical-session.target" ];
|
|
PartOf = [ "graphical-session.target" ];
|
|
};
|
|
Service = {
|
|
ExecStart = "${watcher}";
|
|
Restart = "on-failure";
|
|
RestartSec = 2;
|
|
};
|
|
Install.WantedBy = [ "graphical-session.target" ];
|
|
};
|
|
};
|
|
}
|