From 6439105d38af05f3dcf44a80726bb1f102f21c62 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sun, 12 Jul 2026 14:22:49 +0100 Subject: [PATCH] feat(audio,menu): dock audio auto-follow watcher + docked Display/Audio menu rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 → " (live-only disable; a persisted one could black-screen an undocked boot), "Screen on · ", "Move workspace · → next monitor", "Swap workspaces" (exactly-2 gate); Audio gains a self-gated "Send output → " 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 --- README.md | 1 + agent/HARDWARE-QUEUE.md | 36 +++++++++++--- agent/JOURNAL.md | 24 ++++++++++ docs/ROADMAP.md | 22 +++++++++ modules/home/default.nix | 1 + modules/home/dock-audio.nix | 90 +++++++++++++++++++++++++++++++++++ modules/home/options.nix | 1 + modules/home/rofi.nix | 75 ++++++++++++++++++++++++++++- templates/downstream/home.nix | 1 + 9 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 modules/home/dock-audio.nix diff --git a/README.md b/README.md index 815caec..6e2af4e 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index e203648..665629f 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -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 → " 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 → " (only with >1 active output), "Screen on · + " 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 → " 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 diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 22f2be1..3dce68e 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -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 → " 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 — diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 408a317..8cfa947 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 → " (live-only `keyword monitor ,disable` — a + persisted disable could black-screen an undocked boot), "Screen on · + ", "Move workspace · → next monitor" + (`movecurrentworkspacetomonitor +1`), "Swap workspaces" + (`swapactiveworkspaces`, exactly-2 gate); Audio gains a self-gated + "Send output → " 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/ diff --git a/modules/home/default.nix b/modules/home/default.nix index 6197290..e5c086f 100644 --- a/modules/home/default.nix +++ b/modules/home/default.nix @@ -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 ]; diff --git a/modules/home/dock-audio.nix b/modules/home/dock-audio.nix new file mode 100644 index 0000000..5479f7c --- /dev/null +++ b/modules/home/dock-audio.nix @@ -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" ]; + }; + }; +} diff --git a/modules/home/options.nix b/modules/home/options.nix index df2aa69..9452fac 100644 --- a/modules/home/options.nix +++ b/modules/home/options.nix @@ -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; }; diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index 1aeae1b..898b161 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -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 diff --git a/templates/downstream/home.nix b/templates/downstream/home.nix index 6f2bfed..dee766e 100644 --- a/templates/downstream/home.nix +++ b/templates/downstream/home.nix @@ -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`