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

@@ -245,7 +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 new-sink or card/port-availability hotplug events, with a toast; EasyEffects follows along; unplug falls back to built-in. Manual route: System Audio |
| `nomarchy.dockAudio.enable` | `true` | On a fresh external-monitor plug, PipeWire/WirePlumber are reprobed after hardware settles and the highest-priority available HDMI/DP/USB sink becomes default, with a toast and journal result. A later manual speaker choice sticks until the next physical plug; unplug falls back to built-in. Manual route: System Audio |
| `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 |
@@ -260,7 +260,7 @@ two tables below are split along exactly that line.
| `nomarchy.viewers.enable` | `true` | Document/image viewers: zathura (Stylix-themed PDF) + imv |
| `nomarchy.mime.enable` | `true` | Default "open with" associations (PDF/image/video/text/browser/directory); entries for apps you removed are skipped, so it degrades with the suite |
| `nomarchy.monitors` | `[]` | Declarative per-output layout → Hyprland `monitor` rules (applied on hotplug); `,preferred,auto,1` wildcard kept as fallback |
| `nomarchy.displayProfiles` | `{}` | Named layouts for the same outputs (docked/undocked/…), switched from System Display Profiles: instant via hyprctl, persisted in-flake (`settings.displayProfile`), baked over `nomarchy.monitors` at the next rebuild. The menu's Auto-switch row (`settings.displayProfileAuto`) makes plugging/unplugging outputs apply the matching profile instantly (driven by a dedicated Hyprland IPC socket watcher, no polling). A profile can also pin workspaces to outputs (`workspaces = { "1" = "DP-3"; }`) moved instantly on switch, baked as Hyprland `workspace` rules |
| `nomarchy.displayProfiles` | `{}` | Named layouts for the same outputs (docked/undocked/…), switched from System Display Profiles: instant via hyprctl, persisted in-flake (`settings.displayProfile`), baked over `nomarchy.monitors` at the next rebuild. Profiles that disable the laptop panel use the same safe ordering as Dock mode (external on → workspace handoff → internal off). The menu's Auto-switch row (`settings.displayProfileAuto`) applies the matching profile from Hyprland hotplug events. Workspace pins (`workspaces = { "1" = "DP-3"; }`) are moved instantly and baked as rules |
| `nomarchy.launchOrFocus` | `[]` | Launch-or-focus binds: `SUPER+<key>` focuses the app's window (case-insensitive class match) or launches it; entries land in the SUPER+? cheatsheet, and a bind whose app was removed notifies instead of failing silently |
| `nomarchy.themesDir` | Nomarchy's `themes/` | Where per-theme app overrides are probed |
| `nomarchy.package` | overlay's `nomarchy-theme-sync` | The theme/state tool package, overridable if you fork it |
@@ -282,13 +282,13 @@ option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
| `nomarchy.system.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) |
| `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet |
| `nomarchy.system.greeter.autoLogin` | `null` | Auto-login this user into Hyprland (installer sets it on LUKS machines) |
| `nomarchy.system.audio.enable` | `true` | Pipewire stack; WirePlumber prefers HDMI/USB (dock) sinks when present so default output follows dock/undock |
| `nomarchy.system.audio.enable` | `true` | PipeWire stack; WirePlumber priority rules provide HDMI/USB preference/fallback, while the Home Manager dock watcher performs the fresh-display reprobe and explicit default selection |
| `nomarchy.system.bluetooth.enable` | `true` | Bluetooth + blueman |
| `nomarchy.system.autoTimezone.enable` | `false` | Automatic timezone (geoclue + automatic-timezoned) — the clock follows your location; toggle from System Auto timezone (a menu enable rebuilds: it has to unset the static `time.timeZone`) |
| `nomarchy.system.snapper.enable` | `false` | Hourly/daily BTRFS timeline snapshots + `nixos-rebuild-snap` (installer enables it; no-op unless root is BTRFS) |
| `nomarchy.system.power.enable` | `true` | Active power management (see below) |
| `nomarchy.system.power.backend` | `"ppd"` | `"ppd"` (power-profiles-daemon + menu/Waybar switcher) or `"tlp"` (deeper battery tuning, no switcher) — mutually exclusive |
| `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present. With power management on, clamshell is also configured: lid close ignores suspend when logind considers the machine docked (external display) — `HandleLidSwitchDocked=ignore` |
| `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present. Clamshell uses logind's `HandleLidSwitchDocked=ignore`; the desktop additionally holds a low-level lid-switch inhibitor across dock/undock, releasing only after the internal panel is restored and the lid opens. Normal future undocked lid-close suspend remains enabled |
| `nomarchy.system.power.thermal.enable` | `false` | thermald (Intel-only); the installer enables it on a GenuineIntel CPU |
| `nomarchy.system.power.batteryChargeLimit` | `null` | Stop charging at this % (e.g. `80`) where the hardware supports it; needs `power.laptop` |
| `nomarchy.hardware.intel.enable` | `false` | Intel enablement above nixos-hardware (GuC/HuC firmware via `i915.enable_guc=3``.guc` toggles just that; the installer unsets it on `xe`-driver GPUs); the installer sets it on an Intel CPU/GPU |

View File

@@ -11,27 +11,32 @@ the **T14s** (webcam case).
## Any machine (dev box is fine)
- [ ] **Docking correction round 3 (2026-07-13 hardware-QA follow-up)**
prior result: lid-close stayed awake, but SUPER+M rendered on the
hidden laptop workspace; the keyboard never prompted; audio stayed
on speakers; the old laptop-off action lost some applications and
abrupt undock left eDP black. After updating to the commit carrying
this entry and rebuilding Home Manager, start with the laptop open
and play audio. Close the lid and plug the monitor/keyboard: SUPER+M
must render on the external focused workspace; the new keyboard must
ask for a searchable XKB layout (or set/change it at System
Keyboard); audio must toast and move to the monitor. System Audio
Output device remains the manual fallback; the current wallpaper must
cover the external output/workspaces within ~2s. Open Display and choose
**Dock mode**: all eDP workspaces/windows move intact to the external
and focus follows; no terminal/application exits. Yank the cable: the
internal panel is already the logical fallback, lights immediately,
and the moved workspaces/windows survive. Replug then manually choose
speakers: volume/mute changes must not force the monitor again; only a
fresh replug should. If audio still misses, capture `pactl subscribe`
during plug plus `pactl --format=json list sinks` while docked. If the
menu is still misplaced, capture `hyprctl monitors -j` immediately
before SUPER+M (the `.focused` field is the discriminator).
- [ ] **Docking recovery round 4 (#100, closed-lid BenQ sequence)** after
updating to the commit carrying this entry, run `nomarchy-home` and
relogin. With the lid open, put visible windows on at least workspaces
1, 2, and 3 and start audio; plug the BenQ GW2790QT. **Pass on plug:**
wallpaper appears, audio toasts and moves to a BenQ/GPU HDMI sink, and
`journalctl --user -t nomarchy-dock-audio -n 10` shows
`trigger=monitoradded` plus `selected=…` (not only a card event). Open
System Display **Dock mode**. **Pass on dock:** every workspace and
window is on the BenQ, focus follows, eDP is disabled in `hyprctl
monitors all -j`, and `systemd-inhibit --list` shows Nomarchy holding
`handle-lid-switch` for “Safe dock/undock display transition”; there is
no dangling laptop workspace and no application exits. Close the lid;
audio/session must stay awake. While still docked, manually choose the
speakers and change volume/mute: it must stay on speakers. Unplug the
BenQ **with the lid still closed**, wait 10 seconds, then open it.
**Pass on undock:** no suspend/crash/hard reset, eDP is already active,
workspaces 1/2/3 and their windows are intact on it, and the inhibitor
disappears only after lid-open. `journalctl -b --since '-5 min'` must
contain no cable-removal `systemd-logind: Suspending...`. Finally close
the undocked lid normally: the laptop must suspend, proving policy was
restored. Wake it, replug the BenQ: that fresh plug must move audio back
to HDMI (manual speakers do not survive a physical replug). If any step
fails, capture `journalctl --user -t nomarchy-display-watch -t
nomarchy-display-transition -t nomarchy-dock-audio --since '-10 min'`,
`systemd-inhibit --list`, `hyprctl monitors all -j`, `hyprctl workspaces
-j`, and `pactl --format=json list sinks` before changing state again.
- [ ] **Parallel fingerprint-or-password on the real reader** (AMD dev
box, 2026-07-12) — with a finger enrolled and
`fingerprint.pam = true` (parallel is the default): `sudo -k true`

View File

@@ -19,6 +19,28 @@ Template:
---
## 2026-07-13 — #100 dock transition recovery (this commit)
- **Task:** Fix the real T14s dangling workspace, closed-lid cable-removal
suspend/apparent crash, and BenQ audio miss after 7ebfab4.
- **Did:** Added one atomic dock/internal-first undock helper; the IPC watcher
owns a verified low-level lid inhibitor through physical lid-open, with
validated stale-process cleanup. `monitoradded` now debounces a settled
PipeWire/WirePlumber reprobe then picks an available dock sink; generic
audio events never override a manual choice. Named clamshell profiles share
the safe transition. Replaced the fallback VM tool with a bounded hotplug
workspace/output/inhibitor test and strengthened generated-artifact guards.
- **Verified:** V2 — `nix flake check --no-build`; targeted docking-UX,
option-docs, and template-SoT builds; and
`nix build --impure -f tools/monitor-fallback.nix --no-link -L` all green.
The KVM guest proved atomic workspace handoff, internal-first restoration,
inhibitor retention with the simulated lid closed, and release on lid-open.
The harness restores the DRM output before deleting QEMU's last synthetic
headless output because that backend-specific zero-output state aborts
Hyprland before a usable removal lifecycle is delivered.
- **Pending:** V3 exact closed-lid BenQ round 4 in HARDWARE-QUEUE. No
real-hardware or real monitor-audio claim.
- **Next suggestion:** run V3 round 4, then take installer #91.
## 2026-07-13 — Live ISO hardware findings queued (#91#99)
- **Task:** Transcribe Bernardo's Acer Aspire M5-481T install/session notes
and installer failure photo into the executable queue.

View File

@@ -110,6 +110,15 @@ iteration would otherwise rediscover.
check onto the whole return set or lazy attr access skips it.
## Design invariants
- **Dock transitions are ordered safety operations (#100):** dock in one
`hyprctl --batch` (external on → every internal workspace moved → focus
external → internal off); undock enables internal *before* moving anything.
The Hyprland watcher, not a shell-pipeline subshell, owns a low-level
`handle-lid-switch` inhibitor until the lid is physically open; startup
cleans a validated stale process group. HDMI availability may appear only
as `change:sink`, so fresh `monitoradded` is the intent boundary for the
settled PipeWire/WirePlumber reprobe + sink pick; generic audio changes must
not override a manual in-dock speaker choice.
- **Waybar status is never color-only** (item 28 sweep, iteration #69):
every status module must distinguish its states by SHAPE (glyph) or
presence (self-hide), never color alone — good/warn/bad collapse under

View File

@@ -402,14 +402,15 @@ 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 audio round 2 — auto-follow watcher (2026-07-12, superseded by
#100 below):** 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
`nomarchy.dockAudio.enable`, default on) — initially a `pactl subscribe`
watcher that selected new/card-changed 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
@@ -437,7 +438,7 @@ Design/decision records and a running log of shipped work (items marked
DRM is session-to-greeter on the re-lit panel, not a black brick).
Also fixed in the rescue: `grep -c .` exits 1 at count 0 — an `||`-guard on that
pipeline silently disabled the rescue exactly when it mattered.
✓ **Docking hardware-QA correction (2026-07-13):** real clamshell QA
✓ **Docking hardware-QA correction (2026-07-13, superseded by #100):** real clamshell QA
confirmed every warned residual: Rofi's default `monitor=-5` followed
the mouse pointer onto the hidden laptop workspace; the profile watcher
(and blackout rescue) was not launched with default
@@ -445,17 +446,47 @@ Design/decision records and a running log of shipped work (items marked
it, with application loss consistent with the known zero-output crash;
audio did not arrive as a new sink; and the keyboard picker was disabled
by an empty optional-candidate list. Correction: Rofi targets the focused
output (`monitor=-1`); interactive **Dock mode** explicitly moves eDP
workspaces/focus to the external but keeps eDP logically active as the
unplug fallback; the reconnecting display watcher runs unconditionally
output (`monitor=-1`); the first correction's interactive **Dock mode**
moved eDP workspaces/focus but kept eDP logically active as a fallback;
the reconnecting display watcher runs unconditionally
and watches through the racy removal snapshot; keyboard hotplug runs by
default, searches the pinned XKB catalog without D-Bus, persists
per-device, and has a manual System Keyboard path; dock audio handles new sinks and
card/profile/port-availability changes while ignoring volume/default
changes so manual picks stick; and monitor-added re-applies the current
per-device, and has a manual System Keyboard path; that correction's dock
audio reacted to new sinks and card/profile/port changes (replaced by
#100's monitor-event boundary); and monitor-added re-applies the current
awww wallpaper after the output settles (new outputs do not inherit the
existing image). `checks.docking-ux` guards the generated default-session
artifacts; the corrected real sequence remains V3.
✓ **Dock transition recovery (#100, 2026-07-13):** the second T14s pass
exposed why keeping eDP active was not safe: Hyprland requires an active
workspace on every active output, so one workspace necessarily dangled.
Cable removal with the lid closed was independently proven in the previous
boot journal to make logind enter s2idle, explaining the apparent crash.
`nomarchy-display-transition` now follows the old working flake's ordering:
dock is one batch (enable external, move all internal workspaces, focus
external, disable internal); undock enables internal first, waits boundedly,
then moves every workspace back. Named profiles that disable the internal
panel use the same transition when they have an external target.
`nomarchy-display-profile-watch` owns a block-mode, low-level
`handle-lid-switch` inhibitor from external detection through internal
restoration and physical lid-open; it uses process substitution so state
stays in the parent shell, verifies acquisition through logind, kills a
private process group on cleanup, and validates/cleans a stale runtime PID
after an unclean exit. Normal undocked lid-close suspend resumes after
release. Audio now uses `monitoradded` as the fresh-plug intent boundary:
after a debounce and two-second hardware settle it restarts PipeWire,
PipeWire Pulse, and WirePlumber, then explicitly selects the highest-priority
available sink using the original WirePlumber regex data. Generic
`change:sink`/card events never select, so a manual speaker choice sticks;
the subscription loop reconnects after graph restarts. Journal lines record
the trigger and selected/no-candidate result. `checks.docking-ux` syntax- and
invariant-checks generated artifacts; `tools/monitor-fallback.nix` is now a
bounded KVM test for workspace/output/inhibitor transitions and passed V2.
QEMU's headless backend aborts Hyprland if its final active synthetic output
is deleted while the DRM output is disabled, so the harness explicitly runs
the internal-first recovery before deletion and leaves real cable-removal
timing to V3. V3 is queued as round 4; no speculative audio power policy was
added.
✓ **Parallel fingerprint-or-password (2026-07-12, Bernardo promoted
from PROPOSED):** `nomarchy.hardware.fingerprint.parallel` (default
**true** — the better UX is what opting into fingerprint PAM gets you;

View File

@@ -107,8 +107,11 @@ read `/var/log/nomarchy-hm-preactivate.log` on the installed system.
Two full-desktop VM harnesses (softGL Hyprland, too heavy for `checks.*`)
live next to the scripts: `tools/theme-shot.nix` (themed-desktop
screenshots) and `tools/monitor-fallback.nix` (undock blackout rescue —
disabled panel must re-enable when the last active output disappears).
screenshots) and `tools/monitor-fallback.nix` (KVM display transitions:
atomic dock handoff, internal-first undock, and low-level lid-inhibitor
lifecycle). The latter restores the DRM output before deleting QEMU's final
synthetic headless output; physical cable-removal timing is a V3 check because
Hyprland's headless backend otherwise aborts in a VM-only zero-output state.
## 5. VM-specific gotchas

View File

@@ -789,7 +789,6 @@
.home-manager.users.${username};
gen = hm.home.activationPackage;
hyprconf = hm.xdg.configFile."hypr/hyprland.conf".source;
audio = toString hm.systemd.user.services.nomarchy-dock-audio.Service.ExecStart;
in
assert hm.programs.rofi.extraConfig.monitor == -1;
pkgs.runCommand "nomarchy-docking-ux"
@@ -799,11 +798,15 @@
menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1)
keyboard=$(find ${gen}/home-path/bin -name nomarchy-keyboard-layout | head -1)
watcher=$(find ${gen}/home-path/bin -name nomarchy-display-profile-watch | head -1)
test -n "$menu" -a -n "$keyboard" -a -n "$watcher"
transition=$(find ${gen}/home-path/bin -name nomarchy-display-transition | head -1)
audio=$(find ${gen}/home-path/bin -name nomarchy-dock-audio | head -1)
test -n "$menu" -a -n "$keyboard" -a -n "$watcher" \
-a -n "$transition" -a -n "$audio"
bash -n "$menu"
bash -n "$keyboard"
bash -n "$watcher"
bash -n ${audio}
bash -n "$transition"
bash -n "$audio"
"$keyboard" layouts > layouts.txt
test "$(wc -l < layouts.txt)" -gt 50
grep -qx us layouts.txt
@@ -814,12 +817,31 @@
grep -q 'nomarchy-display-profile-watch' ${hyprconf}
grep -q -- '--quiet wallpaper' "$watcher"
grep -q 'Dock mode' "$menu"
grep -q 'moveworkspacetomonitor' "$menu"
grep -q 'nomarchy-display-transition dock' "$menu"
grep -q -- '--what=handle-lid-switch' "$watcher"
grep -q 'systemd-inhibit' "$watcher"
grep -q 'systemd-inhibit --list --json=short' "$watcher"
grep -q 'lid-inhibitor=stale-cleaned' "$watcher"
grep -q 'setsid.*systemd-inhibit' "$watcher"
grep -q 'nomarchy-dock-lid-inhibitor' "$watcher"
! grep -q 'exec -a nomarchy-dock-lid-inhibitor' "$watcher"
grep -Fq 'kill -TERM -"$pid"' "$watcher"
grep -q 'done < <(' "$watcher"
grep -q '"$TRANSITION" undock' "$watcher"
grep -q 'nomarchy-dock-audio reprobe monitoradded' "$watcher"
grep -q 'hyprctl --batch' "$transition"
grep -q 'keyword monitor.*disable' "$transition"
grep -q 'result=no-lid-inhibitor' "$transition"
grep -q 'transition=undock.*action=enable\|keyword monitor.*preferred' "$transition"
grep -q 'nomarchy-keyboard-layout' "$menu"
! grep -q 'Laptop screen off' "$menu"
grep -q "'change':card" ${audio}
grep -q 'availability' ${audio}
echo "docking-ux: focused menu + safe display + keyboard/audio watchers present"
grep -q 'action=reprobe-start' "$audio"
grep -q 'restart.*pipewire\|pipewire.service' "$audio"
grep -q 'set-default-sink' "$audio"
grep -q 'subscription-closed' "$audio"
grep -q 'result=no-available-dock-sink' "$audio"
! grep -q 'power_save' "$audio"
echo "docking-ux: atomic display, lid inhibitor, and monitor-triggered audio reprobe present"
touch $out
'';

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})$'
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
}
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.
# 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 \
| $JQ -r --arg re '^(${dockSinkRe})$' '
.[]
"$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 any(.ports[]?; (.availability // "unknown") != "not available")
)
| "\(.name)\t\(.description)"'
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)"'
}
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
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
}
# 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 ;;
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
sleep 0.75 # let PipeWire finish the profile/route transition
adopt_first_dock
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."

View File

@@ -1,21 +1,16 @@
# Undock blackout-rescue harness — boots the full desktop headlessly
# (softGL Hyprland, same recipe as theme-shot.nix) and replays the
# dangerous profile edge: add a headless "external", soft-disable the
# internal panel (a named profile can still do this), then remove the
# external. The interactive Display menu no longer disables the panel: its
# safe Dock mode moves workspaces/focus but keeps eDP as the unplug fallback.
# Hyprland does NOT re-enable a profile-disabled monitor on
# its own (verified 2026-07-12 — the session ends with zero active
# outputs), so the display-profile watcher's rescue_blackout
# (modules/home/hyprland.nix) must bring the panel back; this asserts it
# does. KNOWN FLAKE (2026-07-12, Hyprland 0.55.4): the zero-active-output
# state itself crashes Hyprland in this VM ~4/5 runs (ABRT in aquamarine
# CBackend::dispatchIdle, faster than any userland rescue) — a control
# run removing the external with the internal ACTIVE survives every
# time, so it's the zero-output state, not headless removal. A crashed
# run is that upstream bug, not a rescue regression; the assertion can
# only pass on a surviving run. Maintainer tool, NOT a checks.* gate
# (full-desktop VM — same weight call as theme-shot.nix). Run:
# Dock-transition hotplug harness — full softGL Hyprland VM with real KVM
# acceleration when available. It uses QEMU's Virtual-1 DRM output as the
# laptop panel and creates a named DP-1 headless output as the dock monitor,
# exercises the generated transition helper and the live IPC watcher, and
# proves the safety sequence that regressed on hardware:
#
# dock: all internal workspaces -> external, focus external, eDP disabled,
# low-level logind lid-switch inhibitor held
# undock: eDP enabled before workspace restoration; inhibitor remains while
# the simulated lid is closed, then releases when it opens
#
# Every wait is bounded. Maintainer V2 harness (full desktop, so intentionally
# not part of the cheap default checks):
# nix build --impure -f tools/monitor-fallback.nix --no-link -L
let
flake = builtins.getFlake ("git+file://" + toString ../.);
@@ -27,10 +22,10 @@ let
};
in
pkgs.testers.runNixOSTest {
name = "monitor-fallback";
name = "dock-transition";
node.pkgsReadOnly = false;
node.specialArgs = { username = "nomarchy"; };
nodes.machine = { pkgs, lib, ... }: {
nodes.machine = { pkgs, ... }: {
imports = [ flake.nixosModules.nomarchy home-manager.nixosModules.home-manager ];
virtualisation.memorySize = 4096;
virtualisation.cores = 4;
@@ -47,6 +42,7 @@ pkgs.testers.runNixOSTest {
systemd.tmpfiles.rules = [
"d /home/nomarchy/.nomarchy 0755 nomarchy users -"
"C /home/nomarchy/.nomarchy/theme-state.json 0644 nomarchy users - ${flake + "/themes/boreal.json"}"
"f /run/nomarchy-test-lid-state 0644 nomarchy users - state:_closed"
];
services.greetd.settings.initial_session = {
command = "start-hyprland";
@@ -57,59 +53,106 @@ pkgs.testers.runNixOSTest {
imports = [ flake.homeModules.nomarchy ];
nomarchy.stateFile = flake + "/themes/boreal.json";
nomarchy.idle.enable = false;
# Audio has its own generated-artifact guard; avoid restarting the VM's
# graph during display-only hotplug assertions.
nomarchy.dockAudio.enable = false;
home.packages = [ pkgs.foot ];
# QEMU's DRM output is Virtual-1 rather than eDP-*; this test-only
# override exercises the same production lifecycle with that connector.
wayland.windowManager.hyprland.settings.env = [
"NOMARCHY_LID_STATE_FILE,/run/nomarchy-test-lid-state"
"NOMARCHY_INTERNAL_OUTPUT,Virtual-1"
];
};
};
testScript = ''
import json, time
import json
machine.wait_for_unit("multi-user.target")
machine.wait_for_file("/run/user/1000/hypr", 180)
machine.sleep(20) # softGL first paint settle
machine.sleep(15)
machine.wait_until_succeeds(
"pgrep -af nomarchy-display-profile-watch >/dev/null", timeout=30
)
hy = ("su - nomarchy -c 'XDG_RUNTIME_DIR=/run/user/1000 "
"HYPRLAND_INSTANCE_SIGNATURE=$(ls /run/user/1000/hypr | head -1) hyprctl ")
env = ("XDG_RUNTIME_DIR=/run/user/1000 "
"HYPRLAND_INSTANCE_SIGNATURE=$(ls /run/user/1000/hypr | head -1) "
"WAYLAND_DISPLAY=wayland-1 ")
hy = f"su - nomarchy -c '{env}hyprctl "
user = lambda command: machine.succeed(f"su - nomarchy -c '{env}{command}'")
def mons(all=False):
out = machine.succeed(hy + ("-j monitors all'" if all else "-j monitors'"))
return json.loads(out)
def mons(all_outputs=False):
suffix = "-j monitors all'" if all_outputs else "-j monitors'"
return json.loads(machine.succeed(hy + suffix))
initial = mons()
assert len(initial) == 1, f"expected 1 initial monitor, got {initial}"
internal = initial[0]["name"]
print(f"internal panel: {internal}")
def names(all_outputs=False):
return [m["name"] for m in mons(all_outputs)]
# "Plug the dock": add a headless output as the external monitor.
machine.succeed(hy + "output create headless'")
for _ in range(20):
if len(mons()) == 2: break
time.sleep(1)
names = [x["name"] for x in mons()]
ext = [n for n in names if n != internal][0]
print(f"external (headless): {ext}")
initial = names()[0]
print(f"bootstrap output: {initial}")
# Named display-profile edge: eDP is disabled before an abrupt undock.
machine.succeed(hy + f"keyword monitor {internal},disable'")
for _ in range(20):
if [x["name"] for x in mons()] == [ext]: break
time.sleep(1)
assert [x["name"] for x in mons()] == [ext], f"disable failed: {mons()}"
alln = {x["name"]: x.get("disabled", False) for x in mons(all=True)}
assert alln.get(internal) is True, f"{internal} not marked disabled: {alln}"
print("laptop panel disabled, only external active now yank it")
assert initial == "Virtual-1", f"test override expects Virtual-1, got {initial}"
# Sudden undock: remove the external while the internal is disabled.
machine.succeed(hy + f"output remove {ext}'")
recovered = False
for i in range(30):
if [x["name"] for x in mons()] == [internal]:
recovered = True
print(f"recovered after {i+1}s: {internal} re-enabled")
break
time.sleep(1)
print(f"monitors now: {mons(all=True)}")
assert recovered, "internal panel did NOT come back after undock"
# Two real mapped clients keep workspaces 1 and 2 alive for handoff.
machine.succeed(hy + "dispatch workspace 1'")
user("foot --app-id dock-test-1 >/dev/null 2>&1 &")
machine.wait_until_succeeds(hy + "-j clients' | jq -e 'length >= 1'", timeout=30)
machine.succeed(hy + "dispatch workspace 2'")
user("foot --app-id dock-test-2 >/dev/null 2>&1 &")
machine.wait_until_succeeds(hy + "-j clients' | jq -e 'length >= 2'", timeout=30)
machine.succeed(hy + "output create headless DP-1'")
machine.wait_until_succeeds(
hy + "-j monitors' | jq -e 'any(.[]; .name == \"DP-1\")'", timeout=20
)
machine.wait_until_succeeds(
"systemd-inhibit --list --json=short | jq -e 'any(.[]; .what == \"handle-lid-switch\" and .why == \"Safe dock/undock display transition\" and .mode == \"block\")'",
timeout=20,
)
print("external added: low-level lid inhibitor held")
transition = "$(command -v nomarchy-display-transition)"
user(f"{transition} dock Virtual-1 DP-1")
machine.wait_until_succeeds(
hy + "-j monitors' | jq -e 'length == 1 and .[0].name == \"DP-1\"'", timeout=20
)
ws = json.loads(machine.succeed(hy + "-j workspaces'"))
assert {w["id"] for w in ws} >= {1, 2}, ws
assert all(w["monitor"] == "DP-1" for w in ws if w["id"] in (1, 2)), ws
all_state = {m["name"]: m.get("disabled", False) for m in mons(True)}
assert all_state.get("Virtual-1") is True, all_state
assert mons()[0].get("focused") is True, mons()
print("dock transition: both workspaces on DP-1, internal disabled")
# QEMU's synthetic headless backend aborts Hyprland if its last active
# output is deleted while the DRM output is disabled. Exercise the exact
# production recovery primitive first, then remove DP-1: the real
# monitorremoved event still proves that the watcher retains the inhibitor
# until lid-open, without mistaking a backend limitation for product
# behavior. Physical cable-yank timing remains the bounded V3 check.
user(f"{transition} undock Virtual-1")
machine.wait_until_succeeds(
hy + "-j monitors' | jq -e 'any(.[]; .name == \"Virtual-1\")'", timeout=20
)
machine.wait_until_succeeds(
hy + "-j workspaces' | jq -e 'all(.[]; (.id != 1 and .id != 2) or .monitor == \"Virtual-1\")'",
timeout=20,
)
machine.succeed(hy + "output remove DP-1'")
machine.wait_until_succeeds(
hy + "-j monitors' | jq -e 'length == 1 and .[0].name == \"Virtual-1\"'", timeout=20
)
machine.succeed(
"systemd-inhibit --list --json=short | jq -e 'any(.[]; .what == \"handle-lid-switch\" and .why == \"Safe dock/undock display transition\" and .mode == \"block\")'"
)
print("undock transition: eDP/workspaces restored; closed-lid inhibitor retained")
machine.succeed("printf 'state: open\\n' > /run/nomarchy-test-lid-state")
machine.wait_until_fails(
"systemd-inhibit --list --json=short | jq -e 'any(.[]; .what == \"handle-lid-switch\" and .why == \"Safe dock/undock display transition\" and .mode == \"block\")'",
timeout=20,
)
machine.fail("test -e /run/user/1000/nomarchy-dock-lid-inhibitor.pid")
print("lid-open transition: inhibitor released; normal future lid policy restored")
'';
}