diff --git a/README.md b/README.md index 72dba9a..96ed19b 100644 --- a/README.md +++ b/README.md @@ -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+` 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 | diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index 1d9f00e..e25ad84 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -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` diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 52ec8ad..0f02a12 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -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. diff --git a/agent/MEMORY.md b/agent/MEMORY.md index be4b5f4..4ecc2e8 100644 --- a/agent/MEMORY.md +++ b/agent/MEMORY.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 68c46e7..2c03a5f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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; diff --git a/docs/TESTING.md b/docs/TESTING.md index 106f297..2f29ec1 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 diff --git a/flake.nix b/flake.nix index 3cd5b89..2c089ca 100644 --- a/flake.nix +++ b/flake.nix @@ -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 ''; diff --git a/modules/home/dock-audio.nix b/modules/home/dock-audio.nix index fde4c26..aa4abc5 100644 --- a/modules/home/dock-audio.nix +++ b/modules/home/dock-audio.nix @@ -1,19 +1,11 @@ -# Automatic audio-output follow for docks / external monitors (#87 round -# 2). The WirePlumber priority rules (modules/nixos/dock-audio-rules.nix) -# only decide among sinks when there is NO stored user default — but -# WirePlumber persists every explicit pick (menu, pavucontrol, pactl) in -# its default-nodes state, and that stored default outranks priority. So -# on any machine where a device was ever picked, docking never moves the -# default — Bernardo's T14s dock test, 2026-07-12. This watcher makes the -# switch explicit: when a dock-class sink appears (same regexes as the -# wireplumber rules — imported from that file, single source of truth) it -# `pactl set-default-sink`s it and toasts. EasyEffects needs no handling: -# EE 8's useDefaultOutputDevice defaults to true, so its output follows -# the default device and effects stay in the chain (a user who pinned a -# device inside EE keeps their pin — that's an explicit choice). Unplug -# needs no handling either: the stored dock sink vanishes and WirePlumber -# falls back by priority to the built-in output. A manual pick made while -# docked sticks until the next plug event (a plug is treated as intent). +# Automatic audio-output follow for docks / external monitors (#100). +# WirePlumber's stored default outranks priority.session, and some HDMI +# codecs only expose their usable route after the display hotplug. The +# Hyprland monitor watcher therefore calls `reprobe monitoradded` for the +# unambiguous physical-plug event: restart the graph (the recovery used by +# the old working flake), wait for it to settle, then explicitly select an +# available dock-class sink. Ordinary sink changes never select anything, +# so a manual speaker choice sticks until a fresh monitor plug. { config, lib, pkgs, ... }: let @@ -23,83 +15,129 @@ let (map (m: lib.removePrefix "~" m."node.name") (lib.concatMap (r: r.matches) rules."monitor.alsa.rules")); - watcher = pkgs.writeShellScript "nomarchy-dock-audio" '' - set -euo pipefail + tool = pkgs.writeShellScriptBin "nomarchy-dock-audio" '' + set -u PACTL=${pkgs.pulseaudio}/bin/pactl JQ=${pkgs.jq}/bin/jq + SYSTEMCTL=${pkgs.systemd}/bin/systemctl + LOGGER=${pkgs.util-linux}/bin/logger + rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + lock="$rt/nomarchy-dock-audio-reprobe.lock" TAB=$(printf '\t') - # Self-gate: no PipeWire pulse socket (nomarchy.audio off, TTY-only - # session) means nothing to watch — exit clean, stay dead. - rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" - for _ in $(seq 60); do [ -e "$rt/pulse/native" ] && break; sleep 1; done - [ -e "$rt/pulse/native" ] || exit 0 - - is_dock() { - printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -qE '^(${dockSinkRe})$' - } - - switch_to() { # $1 = sink node name, $2 = description - [ "$($PACTL get-default-sink)" = "$1" ] && return 0 - $PACTL set-default-sink "$1" 2>/dev/null || return 0 - ${pkgs.libnotify}/bin/notify-send -a Nomarchy "Audio" \ - "Output → ''${2:-$1}" 2>/dev/null || true - } - - # Return dock-class sinks whose current port is usable. PipeWire often - # creates the HDMI/DP node before the cable is plugged and later flips its - # card/port availability, so presence alone is not a hotplug signal. - dock_sinks() { - $PACTL --format=json list sinks \ - | $JQ -r --arg re '^(${dockSinkRe})$' ' - .[] - | select(.name | test($re)) - | select( - ((.ports // []) | length) == 0 - or any(.ports[]?; (.availability // "unknown") != "not available") - ) - | "\(.name)\t\(.description)"' - } - - adopt_first_dock() { - dock_sinks | while IFS="$TAB" read -r name desc; do - [ -n "$name" ] || continue - switch_to "$name" "$desc" - break + log() { "$LOGGER" -t nomarchy-dock-audio -- "$*"; } + wait_for_pulse() { + tries=0 + while [ "$tries" -lt 40 ]; do + "$PACTL" info >/dev/null 2>&1 && return 0 + tries=$((tries+1)); sleep 0.25 done + return 1 } - # Startup sweep (login/relogin while already docked): adopt a dock - # sink only when the current default is NOT one — a service restart - # must not override a manual in-dock pick. - if ! is_dock "$($PACTL get-default-sink)"; then - adopt_first_dock - fi + # Highest-priority dock-class sink whose active port is not explicitly + # unavailable. With pro-audio profiles a sink may have no ports; its + # existence is then the only availability signal PipeWire exposes. + dock_sinks() { + "$PACTL" --format=json list sinks 2>/dev/null \ + | "$JQ" -r --arg re '^(${dockSinkRe})$' ' + [ .[] as $sink + | $sink + | select(.name | test($re)) + | select( + ((.ports // []) | length) == 0 + or .active_port == null + or ([.ports[]? + | select(.name == $sink.active_port) + | (.availability // "unknown")][0] + // "unknown") != "not available" + ) ] + | sort_by(.priority // 0) | reverse[] + | "\(.name)\t\(.description // .name)"' + } - # Event loop: handle both a genuinely new sink and the common case where - # an existing HDMI/DP card changes profile/port availability. Deliberately - # ignore ordinary sink "change" events (volume/mute) and server-default - # changes, so a manual speaker pick while docked remains sticky. - LC_ALL=C $PACTL subscribe | while read -r _ ev _ kind id; do - case "$ev:$kind" in - "'new':sink"|"'new':card"|"'change':card") ;; - *) continue ;; - esac - sleep 0.75 # let PipeWire finish the profile/route transition - adopt_first_dock - done + select_first_dock() { # $1 = observable trigger + trigger="$1" + candidate=$(dock_sinks | ${pkgs.coreutils}/bin/head -n 1) || candidate= + if [ -z "$candidate" ]; then + log "trigger=$trigger result=no-available-dock-sink" + return 1 + fi + name=''${candidate%%"$TAB"*} + desc=''${candidate#*"$TAB"} + if "$PACTL" set-default-sink "$name" 2>/dev/null; then + log "trigger=$trigger selected=$name description=$desc" + ${pkgs.libnotify}/bin/notify-send -a Nomarchy "Audio" \ + "Output → $desc" 2>/dev/null || true + return 0 + fi + log "trigger=$trigger result=set-default-failed candidate=$name" + return 1 + } + + case "''${1:-watch}" in + candidates) + dock_sinks ;; + select) + wait_for_pulse || { log "trigger=''${2:-manual} result=pulse-unavailable"; exit 1; } + select_first_dock "''${2:-manual}" ;; + reprobe) + trigger="''${2:-monitoradded}" + # mkdir is the debounce/lock: simultaneous monitoradded events from + # an MST dock collapse into one graph restart. Runtime-only state, + # removed on every exit path. + if ! ${pkgs.coreutils}/bin/mkdir "$lock" 2>/dev/null; then + log "trigger=$trigger result=debounced" + exit 0 + fi + trap '${pkgs.coreutils}/bin/rmdir "$lock" 2>/dev/null || true' EXIT INT TERM + log "trigger=$trigger action=reprobe-start" + sleep 2 + "$SYSTEMCTL" --user restart \ + pipewire.service pipewire-pulse.service wireplumber.service \ + >/dev/null 2>&1 || log "trigger=$trigger action=graph-restart-returned-error" + if wait_for_pulse; then + sleep 0.75 + select_first_dock "$trigger" || true + else + log "trigger=$trigger result=pulse-did-not-return" + fi ;; + watch) + # Selection is deliberately NOT done at service startup: a restart or + # relogin while docked must not erase a manual speaker choice. Only + # the compositor's fresh monitoradded event calls `reprobe`. + wait_for_pulse || true + # A graph restart closes `pactl subscribe`, sometimes with status 0. + # Always resubscribe; log one concise closure line for diagnosis. + while :; do + if LC_ALL=C "$PACTL" subscribe 2>/dev/null \ + | while IFS= read -r line; do + case "$line" in + *"'new' on sink"*|*"'change' on card"*) : ;; + esac + done + then status=0; else status=$?; fi + log "subscription-closed status=$status; retrying" + sleep 1 + wait_for_pulse || sleep 1 + done ;; + *) + echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]]" >&2 + exit 64 ;; + esac ''; in { config = lib.mkIf cfg.dockAudio.enable { + home.packages = [ tool ]; systemd.user.services.nomarchy-dock-audio = { Unit = { - Description = "Move the default audio sink to dock/monitor outputs on hotplug"; + Description = "Reprobe and select dock/monitor audio on display hotplug"; After = [ "graphical-session.target" ]; PartOf = [ "graphical-session.target" ]; }; Service = { - ExecStart = "${watcher}"; + ExecStart = "${tool}/bin/nomarchy-dock-audio watch"; Restart = "on-failure"; RestartSec = 2; }; diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index 7ec45b9..6d6e405 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -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 [external-rule]|undock |enable ]" >&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); } diff --git a/modules/home/options.nix b/modules/home/options.nix index 30c2c2d..f33411a 100644 --- a/modules/home/options.nix +++ b/modules/home/options.nix @@ -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; }; diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index 71b4af3..ac3cd36 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -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." diff --git a/tools/monitor-fallback.nix b/tools/monitor-fallback.nix index 5f2a1bb..b660da4 100644 --- a/tools/monitor-fallback.nix +++ b/tools/monitor-fallback.nix @@ -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") ''; }