feat(desktop): nwg-displays writes niri outputs, kanshi dropped

The port swapped moonarch's wdisplays-persistent for the nixpkgs wdisplays,
which cannot save -- so the kanshi config stayed empty and its unit was
skipped on every boot. nwg-displays supports niri and writes the layout to
~/.config/niri/monitor.kdl, which niri applies itself.

niri prefers a home config and does not merge, so config.kdl is seeded with
an include of /etc/niri/config.kdl, like moonarch does for stasis.rune.
This commit is contained in:
nevaforget committed 2026-09-08 16:47:21 +02:00
1 parent 058ec5c98d
commit f4b8b5dd50
4 files changed
+66 -37

No files matched your search

+35
View File
@@ -1,5 +1,40 @@
# Decisions # Decisions
## 2026-09-08 – nwg-displays replaces wdisplays; kanshi dropped for niri's own outputs
- **Who**: Dominik, ClaudeCode
- **Why**: Monitors could not be set persistently at all. moonarch pairs
`wdisplays-persistent` (AUR fork `sfs-pra/wdisplays`, see moonarch `README.md:25` and
its `DECISIONS.md:178`) with kanshi: the fork saves straight into the kanshi config.
The port took the nixpkgs `wdisplays` instead — upstream, with no save function — and
never recorded the difference. The result was a GUI that applies a layout and forgets
it, an always-empty `~/.config/kanshi/config`, and a `kanshi.service` whose
`ConditionPathExists` therefore skipped it on every boot.
- **Tradeoffs**: Packaging the fork would match moonarch exactly and keeps kanshi's
per-combination profiles, but it is unmaintained and would be ours to carry.
`nwg-displays` is in nixpkgs, actively developed, and supports niri as a first-class
target alongside sway and Hyprland; it writes `~/.config/niri/monitor.kdl` and adds the
`include` for it. That makes kanshi redundant — niri applies `output` blocks itself, on
hotplug and with live reload — so it goes, together with the hardening set it had just
been given. What is given up is kanshi's profiles *per attached combination*: niri has
one setting per output. Fixed monitors do not care; several docks with different
arrangements would, and then kanshi has to come back.
The seed is the same mechanism moonarch chose for stasis (its `DECISIONS.md`,
2026-04-22): niri has no config hierarchy either, so system-default-plus-user-override
cannot be modelled — the home file is seeded once and then belongs to the user.
- **How**: `modules/desktop.nix` — `wdisplays` → `nwg-displays`, `kanshi` dropped from
`environment.systemPackages`, new `niriUserConfig` in the `let` block holding
`include "/etc/niri/config.kdl"`, seeded through the existing
`systemd.user.tmpfiles.rules` block with `C` so later edits survive. The stale comment
about the undeployed kanshi config is gone from `environment.etc`, the paragraph in
`README.md` rewritten. `modules/services.nix` — `systemd.user.services.kanshi` removed
and `wlsunset`'s `after` reduced to `graphical-session.target`.
Verified: niri 26.04 does load `include` (an include pointing at a deliberately broken
file makes `niri validate` fail on that file, so it is parsed, not ignored);
`nwg-displays` 0.4.4 logs `Running on niri` and sees both outputs; without
`~/.config/niri/` it exits 0 with no window, and it creates `monitor.kdl` but no
`config.kdl` — which is what the seed is for.
## 2026-08-24 – power-profiles-daemon replaces auto-cpufreq ## 2026-08-24 – power-profiles-daemon replaces auto-cpufreq
- **Who**: Dominik, ClaudeCode - **Who**: Dominik, ClaudeCode
+7 -5
View File
@@ -257,11 +257,13 @@ NixOS module owns the destination:
| `waypaper/config.ini` | wallpaper path, rewritten and seeded into `$HOME` | | `waypaper/config.ini` | wallpaper path, rewritten and seeded into `$HOME` |
| fontconfig defaults | `/etc/fonts/conf.d` is owned by the NixOS module | | fontconfig defaults | `/etc/fonts/conf.d` is owned by the NixOS module |
`kanshi/config` is not deployed: the file in the config source is empty, and `kanshi/config` is not deployed and kanshi is not installed: niri configures its
monitor profiles are machine specific — they belong in outputs itself, and `nwg-displays` writes the layout it is given to
`~/.config/kanshi/config`. A missing config file is what kanshi treats as an `~/.config/niri/monitor.kdl`. niri prefers a home config over the deployed one
error (`failed to parse config file`), which with `Restart=on-failure` would and does not merge them, so `~/.config/niri/config.kdl` is seeded with an
spin, so the service starts only once that file exists. `include` of `/etc/niri/config.kdl` — the home config then adds the monitor
layout instead of replacing everything. nwg-displays also refuses to start
without that file.
Programs that read only from `$HOME` and have no system-wide fallback get a Programs that read only from `$HOME` and have no system-wide fallback get a
copy seeded through `systemd.user.tmpfiles` with the `C` directive, which never copy seeded through `systemd.user.tmpfiles` with the `C` directive, which never
+18 -7
View File
@@ -36,6 +36,20 @@ let
--replace-fail /usr/share/moonarch/wallpaper-blur.jpg ${wallpaperBlur} --replace-fail /usr/share/moonarch/wallpaper-blur.jpg ${wallpaperBlur}
''; '';
# niri prefers ~/.config/niri/config.kdl over /etc/niri/config.kdl and does not
# merge the two, so a home config would otherwise replace the whole deployed
# one. This seed pulls the system config back in through `include`, which niri
# 26.04 really does load (verified by pointing an include at a broken file and
# watching `niri validate` fail on it).
#
# It exists for nwg-displays, which writes the output layout to
# ~/.config/niri/monitor.kdl and adds an include for it here. Without a
# config.kdl in the home directory nwg-displays refuses to start at all
# ("Couldn't find niri config directory", exit 0, no window).
niriUserConfig = pkgs.writeText "niri-config.kdl" ''
include "/etc/niri/config.kdl"
'';
# PipeWire searches $PIPEWIRE_CONFIG_DIR, then $XDG_CONFIG_HOME/pipewire, then # PipeWire searches $PIPEWIRE_CONFIG_DIR, then $XDG_CONFIG_HOME/pipewire, then
# its compiled-in /etc/pipewire — never /etc/xdg (verified in PipeWire's # its compiled-in /etc/pipewire — never /etc/xdg (verified in PipeWire's
# src/pipewire/conf.c). The shared file therefore cannot be deployed the way # src/pipewire/conf.c). The shared file therefore cannot be deployed the way
@@ -93,11 +107,6 @@ in
"xdg/quickshell/moonarch".source = "${xdg}/quickshell/moonarch"; "xdg/quickshell/moonarch".source = "${xdg}/quickshell/moonarch";
"xdg/foot/foot.ini".source = "${xdg}/foot/foot.ini"; "xdg/foot/foot.ini".source = "${xdg}/foot/foot.ini";
"xdg/fastfetch/config.jsonc".source = "${xdg}/fastfetch/config.jsonc"; "xdg/fastfetch/config.jsonc".source = "${xdg}/fastfetch/config.jsonc";
# defaults/xdg/kanshi/config is an empty placeholder. Not
# deployed: monitor profiles are machine specific and belong in
# ~/.config/kanshi/config. An empty file parses fine; a missing one is what
# kanshi reports as "failed to parse config file", which is why the unit in
# modules/services.nix waits for the home config to exist.
"xdg/Kvantum/kvantum.kvconfig".source = "${xdg}/Kvantum/kvantum.kvconfig"; "xdg/Kvantum/kvantum.kvconfig".source = "${xdg}/Kvantum/kvantum.kvconfig";
"xdg/qt6ct/qt6ct.conf".source = "${xdg}/qt6ct/qt6ct.conf"; "xdg/qt6ct/qt6ct.conf".source = "${xdg}/qt6ct/qt6ct.conf";
"xdg/gtk-3.0/settings.ini".source = "${xdg}/gtk-3.0/settings.ini"; "xdg/gtk-3.0/settings.ini".source = "${xdg}/gtk-3.0/settings.ini";
@@ -286,7 +295,6 @@ in
# Compositor extras # Compositor extras
xwayland-satellite xwayland-satellite
kanshi
# Bar, notifications, clipboard. # Bar, notifications, clipboard.
# Neither waybar nor swaync are installed: the Quickshell bar owns both # Neither waybar nor swaync are installed: the Quickshell bar owns both
@@ -361,7 +369,7 @@ in
fwupd fwupd
upower upower
polkit_gnome polkit_gnome
wdisplays nwg-displays
wl-color-picker wl-color-picker
stasis stasis
@@ -399,6 +407,9 @@ in
# exists, so the moonarch config has to be in place before it starts. # exists, so the moonarch config has to be in place before it starts.
"C %h/.config/stasis/stasis.rune 0644 - - - ${xdg}/stasis/stasis.rune" "C %h/.config/stasis/stasis.rune 0644 - - - ${xdg}/stasis/stasis.rune"
"C %h/.config/swaylock/config 0644 - - - ${swaylockConfig}" "C %h/.config/swaylock/config 0644 - - - ${swaylockConfig}"
# nwg-displays needs this file to exist before it will start, and niri would
# otherwise ignore the monitor.kdl it writes next to it.
"C %h/.config/niri/config.kdl 0644 - - - ${niriUserConfig}"
"C %h/.zshrc 0644 - - - ${userZshrc}" "C %h/.zshrc 0644 - - - ${userZshrc}"
]; ];
} }
+6 -25
View File
@@ -112,30 +112,6 @@ in
# --- Firewall, replaces the ufw calls in post-install.sh --- # --- Firewall, replaces the ufw calls in post-install.sh ---
networking.firewall.enable = true; networking.firewall.enable = true;
# --- Session services ---
#
# Rebuilt rather than copied: the upstream units reference /usr/bin/,
# which does not exist under Nix.
systemd.user.services.kanshi = {
description = "Dynamic display configuration";
partOf = [ "graphical-session.target" ];
after = [ "graphical-session.target" ];
wantedBy = [ "graphical-session.target" ];
# Without a config kanshi exits with a parse error and, with
# Restart=on-failure, spins. Monitor profiles are per machine.
unitConfig.ConditionPathExists = "%h/.config/kanshi/config";
# The config is read, never written, so ProtectSystem=strict is enough and
# no exception for %h is needed. Not exercised at runtime here: this host
# has no kanshi config, so the condition skips the unit.
serviceConfig = {
Type = "simple";
ExecStart = lib.getExe pkgs.kanshi;
Restart = "on-failure";
RestartSec = 3;
} // waylandClientHardening;
};
# --- System services from defaults/etc/systemd/system/ --- # --- System services from defaults/etc/systemd/system/ ---
# #
# Rebuilt rather than copied, same reason as the user units: the originals # Rebuilt rather than copied, same reason as the user units: the originals
@@ -166,6 +142,11 @@ in
} // sysfsWriterHardening; } // sysfsWriterHardening;
}; };
# --- Session services ---
#
# Rebuilt rather than copied: the upstream units reference /usr/bin/,
# which does not exist under Nix.
# The bar, launcher and every popout. A unit rather than a spawn-at-startup so # The bar, launcher and every popout. A unit rather than a spawn-at-startup so
# a crash does not leave the session without a bar until the next login. # a crash does not leave the session without a bar until the next login.
systemd.user.services.quickshell = { systemd.user.services.quickshell = {
@@ -262,7 +243,7 @@ in
systemd.user.services.wlsunset = { systemd.user.services.wlsunset = {
description = "Wlsunset night light (blue light filter)"; description = "Wlsunset night light (blue light filter)";
partOf = [ "graphical-session.target" ]; partOf = [ "graphical-session.target" ];
after = [ "graphical-session.target" "kanshi.service" ]; after = [ "graphical-session.target" ];
wantedBy = [ ]; wantedBy = [ ];
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";