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.
This commit is contained in:
2026-08-06 17:09:30 +01:00
parent 29707ba0f6
commit f8552bc7bb
+88 -14
View File
@@ -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.<n>.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";
};
};
};