From f8552bc7bb39d370006d76a2f3e4a655aef23f0b Mon Sep 17 00:00:00 2001 From: ahmza Date: Thu, 6 Aug 2026 17:09:30 +0100 Subject: [PATCH] run hypridle and hyprsunset as graphical-session user units both were installed but never launched. they ship their own units, but in $out/share/systemd/user - not $out/lib/systemd/user - and that distinction is why they need redeclaring rather than merely enabling: * systemd.packages would collect nothing, since nixpkgs' generateUnits globs only $pkg/etc/systemd/user and $pkg/lib/systemd/user. * the units are nonetheless visible to the user manager, via environment.pathsToLink -> /run/current-system/sw/share/systemd/user, which is on the search path because XDG_DATA_DIRS ends with /run/current-system/sw/share. but visible is not enabled - nothing creates the WantedBy symlink, so they sit unstarted. * wantedBy alone would not help either: with no other keys nixos synthesises a replacement unit with an empty [Service] section, and it shadows upstream's because /etc/systemd/user takes precedence over $XDG_DATA_DIRS. binding them to graphical-session.target, which uwsm manages properly, means they start and stop with the session instead of leaking across logins - same reasoning as udiskie, so that moves into the same attrset. close to upstream's definitions but not identical: hyprsunset's Requires=graphical-session.target is dropped, because PartOf already propagates the stop while Requires would additionally make starting hyprsunset by hand pull up the whole session target. the Slice=session.slice asymmetry is upstream's own - hyprsunset sets it, hypridle does not - and is noted inline so it does not get "fixed" later. hypridle gets an explicit path because its config shells out to hyprlock, pidof, brightnessctl, hyprctl, loginctl, systemctl and notify-send. that config lives in the dotfiles repo, so this list can silently drift out of sync with it: a binary used there but missing here fails as a bare "command not found" inside hypridle, and Restart=on-failure will not catch it because hypridle itself stays up. flagged in a comment at both ends. --- modules/features/hyprland/default.nix | 102 ++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 14 deletions(-) diff --git a/modules/features/hyprland/default.nix b/modules/features/hyprland/default.nix index 14db122..6c9ae45 100644 --- a/modules/features/hyprland/default.nix +++ b/modules/features/hyprland/default.nix @@ -37,20 +37,94 @@ # are declared in the host's disks.nix instead. services.udisks2.enable = true; - # Run udiskie as a user unit rather than from the hypr autostart — it was - # previously only installed, never actually launched. Binding it to - # graphical-session.target (which UWSM manages properly) means it starts and - # stops with the session instead of leaking across logins. - systemd.user.services.udiskie = { - description = "udiskie removable media automounter"; - partOf = ["graphical-session.target"]; - after = ["graphical-session.target"]; - wantedBy = ["graphical-session.target"]; - serviceConfig = { - Type = "simple"; - # -s = smart tray: the icon appears only while removable media is present. - ExecStart = "${pkgs.udiskie}/bin/udiskie -s"; - Restart = "on-failure"; + # Session daemons run as systemd user units bound to graphical-session.target + # (which UWSM manages properly) rather than from the hypr autostart, so they + # start and stop with the session instead of leaking across logins. + # + # hypridle and hyprsunset both ship their own unit, but in + # $out/share/systemd/user — NOT $out/lib/systemd/user. That distinction is + # why they need redeclaring rather than just enabling: + # + # * `systemd.packages = [pkgs.hypridle]` would collect nothing. nixpkgs' + # generateUnits globs only $pkg/etc/systemd/user and $pkg/lib/systemd/user. + # * The units are nonetheless *visible* to the user manager, via + # environment.pathsToLink -> /run/current-system/sw/share/systemd/user, + # which is on the search path because XDG_DATA_DIRS ends with + # /run/current-system/sw/share. But visible is not enabled: nothing + # creates the WantedBy symlink, so they sit unstarted. + # * `systemd.user.services..wantedBy` alone would not help either — with + # no other keys NixOS synthesises a *replacement* unit with an empty + # [Service] section, shadowing upstream's via /etc/systemd/user (which + # takes precedence over $XDG_DATA_DIRS). + # + # So the full definition below is deliberate. It is close to upstream's but + # not identical: hyprsunset's `Requires=graphical-session.target` is dropped + # on purpose (PartOf already propagates the stop, while Requires would also + # make starting hyprsunset by hand pull up the whole session target), and + # hypridle gains an explicit `path` because its config shells out. + systemd.user.services = { + udiskie = { + description = "udiskie removable media automounter"; + partOf = ["graphical-session.target"]; + after = ["graphical-session.target"]; + wantedBy = ["graphical-session.target"]; + serviceConfig = { + Type = "simple"; + # -s = smart tray: the icon appears only while removable media is present. + ExecStart = "${pkgs.udiskie}/bin/udiskie -s"; + Restart = "on-failure"; + }; + }; + + hypridle = { + description = "Hyprland idle daemon"; + documentation = ["https://wiki.hyprland.org/Hypr-Ecosystem/hypridle"]; + partOf = ["graphical-session.target"]; + after = ["graphical-session.target"]; + wantedBy = ["graphical-session.target"]; + # ~/.config/hypr/hypridle.conf calls out to these: hyprlock (lock_cmd), + # pidof (the `pidof hyprlock ||` guard), brightnessctl (backlight + # listener), hyprctl (dpms), loginctl + systemctl (sleep/lock), + # notify-send (unlock_cmd). + # + # MAINTENANCE HAZARD: that config lives in a *different* repo (the stow'd + # `hypr` package in the dotfiles repo), so this list can silently drift + # out of sync with it. A binary used there but missing here fails as a + # bare "command not found" inside hypridle, and Restart=on-failure will + # NOT catch it because hypridle itself stays up — the idle action just + # quietly stops working. Add any new binary to both places. + path = with pkgs; [ + hyprlock + hyprland + brightnessctl + libnotify + procps + systemd + ]; + unitConfig.ConditionEnvironment = "WAYLAND_DISPLAY"; + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.hypridle}/bin/hypridle"; + Restart = "on-failure"; + }; + }; + + hyprsunset = { + description = "Hyprland blue-light filter"; + documentation = ["https://wiki.hyprland.org/Hypr-Ecosystem/hyprsunset"]; + partOf = ["graphical-session.target"]; + after = ["graphical-session.target"]; + wantedBy = ["graphical-session.target"]; + unitConfig.ConditionEnvironment = "WAYLAND_DISPLAY"; + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.hyprsunset}/bin/hyprsunset"; + Restart = "on-failure"; + # Intentionally only on hyprsunset, not hypridle/udiskie: upstream's + # hyprsunset.service sets Slice=session.slice and upstream's + # hypridle.service does not. Not an oversight — don't "fix" it. + Slice = "session.slice"; + }; }; };