feat(audio,menu): dock audio auto-follow watcher + docked Display/Audio menu rows
Some checks failed
Check / eval (push) Failing after 1m41s

Bernardo's dock test (the #87 V3): audio stayed on EasyEffects→speakers.
Root cause, established live: WirePlumber's stored default
(default-nodes state, written by any past explicit pick) outranks the
priority.session rules, so #87 alone can never move the default on a
real machine; EasyEffects 8 follows the default device (kcfg default
true, verified by watching its output links). Fix: nomarchy-dock-audio
(modules/home/dock-audio.nix, nomarchy.dockAudio.enable, default on) —
a pactl-subscribe watcher that set-default-sinks new dock-class sinks
(regex data imported from modules/nixos/dock-audio-rules.nix, single
source) with a toast, plus a startup sweep for login-while-docked that
never overrides a manual in-dock pick. Unplug falls back by priority.

Menus: Display gains docked rows — "Laptop screen off · everything →
<ext>" (live-only disable; a persisted one could black-screen an
undocked boot), "Screen on · <name>", "Move workspace · → next
monitor", "Swap workspaces" (exactly-2 gate); Audio gains a self-gated
"Send output → <dock sink>" quick row. All self-gate on real outputs.

Verified: V2-equivalent on live hardware (no dock available) — watcher
followed a fake dock-named null sink and fell back on unload, control
run without the watcher stayed pinned (proving the stored-default
diagnosis), EE output links moved mid-playback; generated menu bash -n
green, dock-row jq exercised on real pactl JSON, dispatchers probed on
live Hyprland; flake check + option-docs + template-sot green.
V3 pending: real-dock retest ×2 in HARDWARE-QUEUE (watcher keys on
new-sink events; pre-existing nodes that only flip route availability
would need the queued diagnosis data).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:22:49 +01:00
parent 9e37e11915
commit 6439105d38
9 changed files with 243 additions and 8 deletions

View File

@@ -245,6 +245,7 @@ two tables below are split along exactly that line.
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
| `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery |
| `nomarchy.dockAudio.enable` | `true` | Default audio output follows dock/monitor sinks (HDMI/DP/USB) on hotplug, with a toast; EasyEffects follows along; unplug falls back to built-in |
| `nomarchy.firstBootWelcome.enable` | `true` | One dismissible “you're set” toast on the first session (SUPER+M / SUPER+T / SUPER+? + network pointer); marker is `settings.firstBootShown` in the flake checkout |
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 15 min — battery-only) |
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |

View File

@@ -19,13 +19,35 @@ the **T14s** (webcam case).
— should suspend as usual. Optional: with a docked display-profile
(`eDP-1` disable), confirm clamshell layout still looks right after
lid close. Pass = no surprise suspend when docked.
- [ ] **#87 dock audio sink follow** — V2 asserts WirePlumber
`90-nomarchy-dock-audio` rules (HDMI/USB priority). On a laptop
with HDMI or USB-dock audio: play sound on speakers, plug external
audio → default sink should move to HDMI/dock (`wpctl status` shows
`*` on the external sink); unplug → `*` returns to built-in.
Manual `wpctl set-default` still works; Bluetooth may outrank dock
(by design). Pass = auto switch without menu intervention.
- [ ] **#87 round 2: dock audio auto-follow (watcher)** — round 1 result
(Newton, 2026-07-12): audio stayed on speakers when docking; the
running generation had no `90-nomarchy-dock-audio` rules yet, and
live testing showed the priority rules alone can never win anyway —
WirePlumber's stored default (`default-nodes` state, written by any
past explicit pick) outranks `priority.session`, and this machine
(like most) has one. Fix shipped: the `nomarchy-dock-audio` user
service `pactl set-default-sink`s dock-class sinks on hotplug;
EasyEffects follows the default (verified live: EE's output links
moved to a fake dock-named null sink mid-playback). Retest after
pull+rebuild+home on ≥ this rev: play audio, plug the dock →
toast "Audio → <monitor/dock>" and sound moves with EE effects
still active; unplug → sound returns to speakers; while docked,
picking speakers in the Audio menu sticks until the next replug.
If it does NOT switch, capture for diagnosis: `pactl subscribe`
output during the plug, `pactl --format=json list sinks` while
docked (sink names may not match the dock regexes, or the sink
node may pre-exist and only flip route availability — the watcher
keys on new-sink events).
- [ ] **Docked display/audio menu rows** (2026-07-12) — with a real
external monitor: Display menu shows "Laptop screen off ·
everything → <ext>" (only with >1 active output), "Screen on ·
<name>" after a disable, "Move workspace · → next monitor", "Swap
workspaces" (exactly 2 outputs). Laptop-off must move its
workspaces to the external; Screen on restores it (preferred/auto —
declared scale/pos return at the next relogin/switch). Audio menu
shows "Send output → <dock sink>" when one exists and isn't
default; picking it moves output + toasts. Pass = rows appear/act
as described, and none of them show on a single-display session.
- [ ] **#55 fingerprint enroll on real reader** — with
`nomarchy.hardware.fingerprint.enable` and a physical reader: System
Fingerprint Enroll a finger; List shows it; Verify succeeds; optional

View File

@@ -19,6 +19,30 @@ Template:
---
## 2026-07-12 — dock audio auto-follow + docked menu rows (interactive)
- **Task:** Bernardo's dock test (= #87's V3): audio stayed on speakers,
had to hand-tweak EasyEffects; wants docked Display/Audio menu options.
- **Did:** Root-caused live on Newton: WirePlumber's stored default
(default-nodes) outranks the #87 priority rules (control test: new
sink, no watcher → default pinned), and the running gen lacked the
rules anyway; EE 8 follows the default fine (kcfg default true +
observed links move). Shipped `modules/home/dock-audio.nix`
(`nomarchy.dockAudio.enable`, on): pactl-subscribe watcher
set-default-sinks new dock-class sinks (regexes imported from
dock-audio-rules.nix) + toast + startup sweep. Menu: Display gains
laptop-off/screen-on/move-workspace/swap rows (live-only keywords);
Audio gains self-gated "Send output → <dock sink>" row.
- **Verified:** V2-equivalent live on Newton — watcher: fake dock-named
null sink → default followed, unplug → fell back, EE links moved
mid-playback (pw-link); menu: generated script bash -n, dock-row jq
exercised against real pactl JSON, dispatchers probed on live
Hyprland. flake check green; option-docs green. Beware: kill the
subscribe pipe by PID — pkill -f self-matches the test shell.
- **Pending:** real-dock V3 ×2 in HARDWARE-QUEUE (watcher may miss
sinks that pre-exist and only flip route availability — capture pactl
subscribe + sink JSON if it fails again).
- **Next suggestion:** #89 slice 3 (Signal pixmap) or dock V3 feedback.
## 2026-07-12 — auto-commit sweep before pull/rebuild/home (interactive)
- **Task:** Bernardo's request (no BACKLOG item): with autoCommit on,
hand edits to `~/.nomarchy` (his had 4) stayed forever-dirty —

View File

@@ -402,6 +402,28 @@ Design/decision records and a running log of shipped work (items marked
built-in. Rules in `modules/nixos/dock-audio-rules.nix`;
`checks.dock-audio` pure + conf-file VM. Real hotplug V3 in
HARDWARE-QUEUE.
✓ **Dock audio round 2 — auto-follow watcher (2026-07-12):** the T14s
dock test showed priority rules alone can't deliver "just works":
WirePlumber's *stored* default (default-nodes state, written by any
past explicit device pick — every real machine has one) outranks
`priority.session`, so the default never moves on plug. Fix:
`nomarchy-dock-audio` (modules/home/dock-audio.nix,
`nomarchy.dockAudio.enable`, default on) — a `pactl subscribe` watcher
that `set-default-sink`s new dock-class sinks (regex data imported
from dock-audio-rules.nix, single source) + toasts; startup sweep for
login-while-docked (only when the default isn't already dock-class, so
restarts don't override a manual pick). EasyEffects needs nothing: EE 8
`useDefaultOutputDevice` defaults true and verified live — with a
stream playing, EE's output links moved to a fake dock-named null sink
and back on unload. Unplug = WirePlumber priority fallback to built-in.
Docked menu rows shipped alongside: Display gains "Laptop screen off ·
everything → <ext>" (live-only `keyword monitor <eDP>,disable` — a
persisted disable could black-screen an undocked boot), "Screen on ·
<name>", "Move workspace · → next monitor"
(`movecurrentworkspacetomonitor +1`), "Swap workspaces"
(`swapactiveworkspaces`, exactly-2 gate); Audio gains a self-gated
"Send output → <dock sink>" quick row (the watcher's manual override).
Real-dock V3 for both in HARDWARE-QUEUE.
✓ **neon-glass finish (#88, `VISION § D`):** rewrote whole-swap
`rofi.rasi` to rofi-native `@color` glass panel (cyan rim, soft
selection wash — same hex-alpha glass pattern as Boreal); btop title/

View File

@@ -28,6 +28,7 @@
./mime.nix # default applications (mimeapps.list), degrades with the suite
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
./battery-notify.nix # low-battery toasts at the bar's 25/10% thresholds
./dock-audio.nix # default sink follows dock/monitor audio on hotplug (#87)
./first-boot.nix # one-shot "you're set" toast on first session (#81)
./satty.nix # satty screenshot annotation tool, themed
];

View File

@@ -0,0 +1,90 @@
# 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
}
# 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
$PACTL --format=json list sinks \
| $JQ -r '.[] | "\(.name)\t\(.description)"' \
| while IFS="$TAB" read -r name desc; do
if is_dock "$name"; then switch_to "$name" "$desc"; break; fi
done
fi
# Event loop: `pactl subscribe` prints "Event 'new' on sink #NN".
LC_ALL=C $PACTL subscribe | while read -r _ ev _ kind id; do
[ "$ev" = "'new'" ] && [ "$kind" = "sink" ] || continue
id=''${id#\#}
case "$id" in *[!0-9]*|"") continue ;; esac
sleep 0.5 # let the node's description/routes settle
line=$($PACTL --format=json list sinks | $JQ -r --argjson i "$id" \
'.[] | select(.index == $i) | "\(.name)\t\(.description)"')
[ -n "$line" ] || continue
name=''${line%%"$TAB"*}; desc=''${line#*"$TAB"}
if is_dock "$name"; then switch_to "$name" "$desc"; fi
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" ];
};
};
}

View File

@@ -364,6 +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; };
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,6 +15,14 @@ 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;
@@ -498,13 +506,30 @@ ${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)
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)][0].name // empty')
dockdesc=
[ -n "$dockname" ] && dockdesc=$(printf '%s' "$sinks" \
| jq -r --arg n "$dockname" '[.[] | select(.name == $n)][0].description // empty')
choice=$( {
[ -n "$dockname" ] && row "Send output ''${dockdesc:-$dockname}" video-display
row "Output device" audio-volume-high
row "Input device" audio-input-microphone
back
} | rofi_menu -show-icons -p Audio) || exit 0
case "$choice" in
"$BACK") exec "$0" system ;;
"Send output"*)
${pkgs.pulseaudio}/bin/pactl set-default-sink "$dockname" \
&& notify-send "Audio" "Output ''${dockdesc:-$dockname}" ;;
*Output*) exec rofi-pulse-select sink ;;
*Input*) exec rofi-pulse-select source ;;
esac ;;
@@ -522,6 +547,14 @@ ${themeRows}
|| { notify-send "Display" "Hyprland is not running."; exit 0; }
mons=$(hyprctl monitors -j)
# Docked context: which outputs are active, which is the built-in
# panel, is anything currently soft-disabled (monitors all shows
# disabled outputs; plain monitors does not).
nActive=$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)
internal=$(printf '%s' "$mons" | jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)"))][0] // empty')
external=$(printf '%s' "$mons" | jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)") | not)][0] // empty')
offmon=$(printf '%s' "$(hyprctl monitors all -j)" | jq -r '[.[] | select(.disabled)][0].name // empty')
# Choose the output (skip the chooser when only one is connected and
# there are no profiles the Profiles row needs the chooser to live in).
# Where the mode picker's Back returns to: the output chooser when it
@@ -530,14 +563,54 @@ ${themeRows}
# `display` would just re-open this same mode picker (item 40).
modeBack=system
profs=; command -v nomarchy-display-profile >/dev/null 2>&1 && profs=1
if [ -n "$profs" ] || [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then
if [ -n "$profs" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then
name=$( {
[ -n "$profs" ] && printf 'Profiles \n'
# Docked rows self-gated: never offer to disable the only
# active display; the re-enable row appears whenever an
# output is soft-off (so an undocked laptop can recover too).
[ "$nActive" -gt 1 ] && [ -n "$internal" ] && [ -n "$external" ] \
&& printf 'Laptop screen off · everything %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' \
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
"$(printf '%s' "$mons" | jq -r '.[1].name')"
printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"'
printf '%s\n' "$BACK"
} | rofi_menu -p Display ) || exit 0
[ "$name" = "$BACK" ] && exec "$0" system
[ "$name" = "Profiles " ] && exec "$0" display-profile
case "$name" in
"Laptop screen off"*)
# Live-only on purpose: Hyprland moves the panel's
# workspaces to the remaining output on disable; persisting
# a disable could black-screen an undocked boot, so the
# declared rule returns at the next relogin/switch.
if hyprctl keyword monitor "$internal,disable" >/dev/null 2>&1; then
notify-send "Display" "Laptop screen off workspaces moved to $external (this session; turn it back on from this menu)."
else
notify-send "Display" "Could not disable $internal."
fi
exit 0 ;;
"Screen on"*)
# preferred/auto/auto is good enough live; the declared
# scale/position come back at the next relogin/switch.
if hyprctl keyword monitor "$offmon,preferred,auto,auto" >/dev/null 2>&1; then
notify-send "Display" "$offmon is back on."
else
notify-send "Display" "Could not enable $offmon."
fi
exit 0 ;;
"Move workspace"*)
hyprctl dispatch movecurrentworkspacetomonitor +1 >/dev/null 2>&1
exit 0 ;;
"Swap workspaces"*)
hyprctl dispatch swapactiveworkspaces \
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
"$(printf '%s' "$mons" | jq -r '.[1].name')" >/dev/null 2>&1
exit 0 ;;
esac
name=''${name%% ·*} # strip the " · WxH@R" hint
modeBack=display
else

View File

@@ -17,6 +17,7 @@
# nomarchy.terminal = "kitty"; # swap the default terminal
# nomarchy.waybar.enable = false; # bring your own bar
# nomarchy.stylix.enable = false; # opt out of GTK/Qt theming
# nomarchy.dockAudio.enable = false; # keep audio put when docking
#
# Icon pack: Papirus ships by default (auto Dark/Light by theme mode) and
# is the ONLY icon pack in your closure. To use another, set the `icons`