2026-10-08-dendritic-niri-noctalia-design.md (14543B)
1 # NixDaemon: dendritic flake, Niri + Noctalia beside Hyprland + Caelestia 2 3 Date: 2026-10-08. Status: approved in conversation, awaiting written review. 4 5 ## Goal 6 7 Rewrite `~/NixDaemon` in the dendritic pattern (flake-parts + import-tree, every 8 file a flake-parts module, outputs referenced by name through `self`), and add a 9 second desktop, Niri with Noctalia Shell, built the way vimjoyer's video 79 does 10 it (wrapper-modules, so the compositor and the shell are portable packages). 11 Both desktops are installed and chosen at login; either can be switched off 12 with one boolean. Theme for the new desktop: Rosé Pine Main (dark; never Moon). 13 14 Success: `nh os switch` builds from the new tree with no change to what the 15 Hyprland session does today; tuigreet lists both "Hyprland" and "niri"; 16 `nix run ~/NixDaemon#niri` and `#noctalia` work anywhere the flake is fetched. 17 18 ## Constraints and facts the design rests on 19 20 - home-manager runs as a NixOS module here (`useGlobalPkgs = true`). There is 21 no standalone home-manager profile and none is added. 22 - nixpkgs unstable already ships `niri` (26.04, with the `programs.niri` NixOS 23 module), `noctalia-shell` (4.7.7) and `xwayland-satellite`. No new package 24 inputs; three new flake inputs: `flake-parts`, `import-tree`, 25 `wrapper-modules` (`github:BirdeeHub/nix-wrapper-modules`). 26 - wrapper-modules facts (read from its source): 27 `inputs.wrapper-modules.wrappers.niri.wrap { inherit pkgs; settings = …; }` 28 (settings is a freeform set translated to KDL; `binds`, `layout`, 29 `spawn-at-startup`, `window-rules`, `outputs`, `extraConfig` are typed; the 30 wrapper runs `niri validate` at build time; the package passes through 31 `providedSessions`). `wrappers.noctalia-shell.wrap { inherit pkgs; settings; 32 colors; … }`: with only `settings` set it exports `NOCTALIA_SETTINGS_FILE` 33 pointing into the store and ships `bin/dump-noctalia-shell`, which prints the 34 live settings as Nix. 35 - Noctalia ships a predefined "Rosepine" scheme whose dark half is Rosé Pine 36 Main (`mSurface #191724`, `mPrimary #ebbcba`, …). Selecting it: 37 `colorSchemes = { predefinedScheme = "Rosepine"; darkMode = true; 38 useWallpaperColors = false; }`. 39 - import-tree imports every `*.nix` under `modules/` recursively and ignores any 40 path containing `/_`. Non-Nix files are never touched. 41 - Caelestia is started from the Hyprland Lua config, Noctalia from Niri's 42 `spawn-at-startup`; the shared user services (hyprpolkitagent, cliphist, 43 udiskie) are bound to `graphical-session.target`, which both uwsm/Hyprland and 44 `niri-session` manage. Portals are configured per desktop by NixOS. This is 45 why running both desktops side by side is safe. 46 47 ## Decisions 48 49 1. **Full dendritic rewrite.** Every Nix file becomes a flake-parts module. 50 Module *bodies* (the NixOS and home-manager settings) are carried over 51 unchanged except for relative paths that move with them. 52 2. **Both desktops installed, picked in tuigreet.** Two NixOS options, 53 `daemon.desktop.hyprland.enable` and `daemon.desktop.niri.enable`, both 54 default `true`, set in the host's `configuration.nix`. An assertion 55 requires at least one. Home-manager modules read them through `osConfig` 56 so the NixOS option is the single source of truth. 57 3. **`bootstrap` and `nixpkgs-stable` are removed.** Their own comment says to 58 delete them once `nixos` is in use. 59 4. **Niri and Noctalia settings live in Nix** (vimjoyer's way), not in an 60 out-of-store config dir. The GUI remains usable for trying settings in a 61 session; `dump-noctalia-shell` turns the live state into Nix to paste back. 62 63 ## Layout 64 65 ``` 66 flake.nix inputs; outputs = flake-parts.lib.mkFlake { inherit inputs; } (import-tree ./modules) 67 .sops.yaml, secrets/ unchanged, repo root 68 modules/ 69 parts.nix systems = [ "x86_64-linux" ]; imports home-manager.flakeModules.home-manager 70 and wrapper-modules.flakeModules.default 71 hosts/laptop/ 72 default.nix flake.nixosConfigurations.nixos = nixpkgs.lib.nixosSystem { specialArgs = { inherit inputs; user; }; 73 modules = [ self.nixosModules.laptop ]; } 74 configuration.nix flake.nixosModules.laptop: imports every self.nixosModules.laptop-* and the features 75 (workstation, home-manager, desktop-hyprland, desktop-niri); sets daemon.desktop.*; 76 body = today's hosts/laptop/default.nix minus its imports list 77 hardware.nix flake.nixosModules.laptop-hardware (hardware-configuration.nix, unchanged) 78 nvidia.nix flake.nixosModules.laptop-nvidia 79 ssd.nix flake.nixosModules.laptop-ssd 80 nix-settings.nix flake.nixosModules.laptop-nix-settings (nh, caches) 81 sops.nix flake.nixosModules.laptop-sops (path to ../../../secrets/secrets.yaml) 82 toolbox.nix flake.nixosModules.laptop-toolbox 83 fan-cli.nix flake.nixosModules.laptop-fan-cli 84 fan-extras.nix flake.nixosModules.laptop-fan-extras 85 fan-throttle-guard.nix flake.nixosModules.laptop-fan-throttle-guard 86 uniwill-laptop.nix flake.nixosModules.laptop-uniwill 87 uniwill-laptop/_package.nix the kernel-module derivation; underscore so import-tree skips it 88 fanfix, stability_guard.py data, untouched 89 features/ 90 workstation.nix flake.nixosModules.workstation (today's modules/workstation.nix) 91 home-manager.nix flake.nixosModules.home-manager: imports home-manager.nixosModules.home-manager; 92 useGlobalPkgs, useUserPackages, backupFileExtension = "hm-bak", 93 extraSpecialArgs = { inherit inputs; user; }, sharedModules (hyprland + caelestia 94 HM modules), users.${user} = self.homeModules.daemonsec 95 desktop/ 96 options.nix flake.nixosModules.desktop-options: the two options + assertion 97 hyprland.nix flake.nixosModules.desktop-hyprland: today's Hyprland/greetd/uwsm/portal block 98 from hosts/laptop/default.nix, wrapped in mkIf daemon.desktop.hyprland.enable 99 niri.nix perSystem.packages.niri (wrapped) and flake.nixosModules.desktop-niri: 100 programs.niri = { enable; package = self'.packages.niri }, mkIf daemon.desktop.niri.enable 101 noctalia.nix perSystem.packages.noctalia (wrapped, Rosé Pine) 102 home/ 103 default.nix flake.homeModules.daemonsec: imports every self.homeModules.* below; 104 home.username/homeDirectory/stateVersion, programs.home-manager, xdg 105 tools.nix … yazi.nix one flake.homeModules.<name> per today's home/modules/<name>.nix, bodies unchanged 106 hyprland.nix flake.homeModules.hyprland, body wrapped in mkIf osConfig.daemon.desktop.hyprland.enable 107 caelestia.nix flake.homeModules.caelestia, same gate 108 hypr/, caelestia/, kitty/, cheats/, gpg/, rmpc/ data directories, moved beside their modules 109 docs/superpowers/specs/ this file 110 ``` 111 112 Naming: NixOS modules for this host are `laptop-<topic>`; shared features are 113 bare (`workstation`, `desktop-niri`); home modules keep today's file names. 114 115 greetd/tuigreet is the login for both desktops, so it stays in 116 `configuration.nix` (host level), ungated. Only the Hyprland-specific lines 117 (`programs.hyprland` with uwsm, the GTK portal line that exists for Hyprland) 118 move to `desktop-hyprland.nix` and are gated. 119 120 ## The desktop switch 121 122 ```nix 123 # modules/features/desktop/options.nix 124 flake.nixosModules.desktop-options = { lib, config, ... }: { 125 options.daemon.desktop = { 126 hyprland.enable = lib.mkEnableOption "Hyprland with Caelestia Shell" // { default = true; }; 127 niri.enable = lib.mkEnableOption "Niri with Noctalia Shell" // { default = true; }; 128 }; 129 config.assertions = [{ 130 assertion = config.daemon.desktop.hyprland.enable || config.daemon.desktop.niri.enable; 131 message = "daemon.desktop: enable at least one desktop"; 132 }]; 133 }; 134 ``` 135 136 `configuration.nix` sets both explicitly so the choice is visible in the host 137 file. Switching desktops day to day is: log out, pick the other session in 138 tuigreet (it remembers the last one). Dropping one: set it to `false`, 139 `nh os switch`. 140 141 ## Niri (`modules/features/desktop/niri.nix`) 142 143 `perSystem = { pkgs, lib, self', ... }: { packages.niri = inputs.wrapper-modules.wrappers.niri.wrap { inherit pkgs; settings = { … }; }; }` 144 145 Settings (KDL generated by the wrapper): 146 147 - `input`: keyboard layout `us`, options `compose:caps,shift:both_capslock_cancel` 148 (same as the NixOS xkb settings); touchpad `tap`, `natural-scroll`; 149 `focus-follows-mouse`. 150 - `layout`: `gaps 8`; `focus-ring { width 2; active-color "#ebbcba"; inactive-color "#26233a"; }` 151 (rose on overlay); `border.off`; `preset-column-widths` 1/3, 1/2, 2/3. 152 - `prefer-no-csd`; `hotkey-overlay.skip-at-startup`. 153 - `xwayland-satellite.path = lib.getExe pkgs.xwayland-satellite`. 154 - `spawn-at-startup = [ (lib.getExe self'.packages.noctalia) ]`. 155 - `environment`: `ELECTRON_OZONE_PLATFORM_HINT=auto`, `QT_QPA_PLATFORM=wayland;xcb` 156 (mirrors the session variables set for Hyprland). 157 - `binds`, mirroring the Hyprland keys that have a Niri or Noctalia counterpart: 158 159 | Key | Action | 160 |---|---| 161 | Mod+Return | spawn kitty | 162 | Mod+Shift+Return, Mod+Shift+B | firefox | 163 | Mod+Shift+Alt+B | firefox --private-window | 164 | Mod+Shift+F | nautilus --new-window | 165 | Mod+Shift+O | obsidian | 166 | Mod+Shift+N | kitty -e $EDITOR | 167 | Mod+Space, Alt+Mod+Space | noctalia ipc call launcher toggle | 168 | Mod+Escape, Ctrl+Mod+P | noctalia ipc call sessionMenu toggle | 169 | Mod+A | noctalia ipc call controlCenter toggle | 170 | Mod+Comma | noctalia ipc call notifications clear | 171 | Ctrl+Mod+V | noctalia ipc call launcher clipboard | 172 | Ctrl+Mod+Space | noctalia ipc call wallpaper toggle | 173 | Mod+Q | close-window | 174 | Mod+F | maximize-column; Mod+G fullscreen-window; Mod+Shift+V toggle-window-floating | 175 | Mod+H / Mod+J / Mod+K / Mod+L (and arrows) | focus column left / window down / window up / column right | 176 | Mod+Shift+Escape | noctalia ipc call lockScreen lock | 177 | Mod+Shift+H/J/K/L | move column / window | 178 | Mod+1..9, Mod+Shift+1..9 | focus / move to workspace | 179 | Mod+Ctrl+H/L | set-column-width ∓5%; Mod+Ctrl+J/K set-window-height | 180 | Mod+WheelScrollUp/Down | focus workspace up/down | 181 | Print | grim -g "$(slurp)" to clipboard; Shift+Print full screen | 182 | XF86Audio{Raise,Lower}Volume, Mute, MicMute | wpctl | 183 | XF86MonBrightness{Up,Down} | brightnessctl | 184 | XF86Audio{Play,Pause,Next,Prev} | playerctl | 185 | Mod+Shift+E | quit (with confirmation) | 186 187 Programs are referenced with `lib.getExe pkgs.<x>` so the wrapped package 188 carries its own dependencies, exactly as in the video. 189 190 Then `flake.nixosModules.desktop-niri = { config, lib, pkgs, ... }: lib.mkIf config.daemon.desktop.niri.enable { programs.niri = { enable = true; package = self.packages.${pkgs.stdenv.hostPlatform.system}.niri; }; }`. 191 nixpkgs' `programs.niri` registers the session for tuigreet and adds the 192 GNOME portal, which is what Niri documents. 193 194 ## Noctalia (`modules/features/desktop/noctalia.nix`) 195 196 `perSystem = { pkgs, ... }: { packages.noctalia = inputs.wrapper-modules.wrappers.noctalia-shell.wrap { inherit pkgs; settings = { … }; }; }` 197 198 Settings (only the keys that differ from Noctalia's defaults): 199 200 - `colorSchemes = { predefinedScheme = "Rosepine"; darkMode = true; useWallpaperColors = false; }` 201 - `bar = { position = "top"; density = "compact"; }` 202 - `general = { lockOnSuspend = true; }` 203 - `ui = { fontDefault = "Noto Sans"; fontFixed = "DMMono Nerd Font"; }` 204 - `wallpaper = { enabled = true; directory = "<the rose-pine-dark wallpaper dir already used by Caelestia>"; fillMode = "crop"; }` 205 - `location = { name = "<town>"; useFahrenheit = false; use12hourFormat = false; }` (same weather spot as Caelestia) 206 - `appLauncher = { terminalCommand = "kitty -e"; }` 207 - `idle = { enabled = true; lockTimeout = 600; }`: Noctalia's own lock screen after ten idle minutes. 208 209 Bar widget layout stays Noctalia's default for the first build; the owner 210 tunes it in the GUI and pastes `dump-noctalia-shell` output back into this 211 file. Anything the dump adds that equals a default is left out. 212 213 ## Home-manager glue (`modules/features/home-manager.nix`) 214 215 The block that lives in `flake.nix` today moves here unchanged, except that the 216 user module is `self.homeModules.daemonsec`. `extraSpecialArgs` still passes 217 `inputs` and `user`; `sharedModules` keeps the Hyprland and Caelestia HM 218 modules (they only define options; the gated home modules decide whether they 219 do anything). 220 221 ## Path changes that come with the move 222 223 - `modules/hosts/laptop/sops.nix`: `defaultSopsFile = ../../../secrets/secrets.yaml`. 224 - `modules/home/sops.nix`: same depth change. 225 - `modules/home/cheats.nix`: `../cheats` becomes `./cheats`; same for 226 `caelestia.nix` (`./caelestia/...`), `hyprland.nix` (`./hypr/...`), 227 `terminal.nix` (`./kitty/...`), `gpg.nix`, `media.nix` (rmpc). 228 - `uniwill-laptop.nix`: `./uniwill-laptop/_package.nix`. 229 - `fan-throttle-guard.nix` and friends: their script paths (`./fanfix`, 230 `./stability_guard.py`) are unchanged because the files move with them. 231 232 ## Verification (before the live switch) 233 234 1. `nix flake check ~/NixDaemon` evaluates every output. 235 2. `nh os build && nvd diff /run/current-system result`. Expected: `niri`, 236 `noctalia-shell`, `xwayland-satellite`, `xdg-desktop-portal-gnome` and 237 their closure added; the removal of nothing that exists today. Any removal 238 is a bug in the port, fixed before switching. 239 3. `nix run ~/NixDaemon#niri` inside the running Hyprland session opens Niri 240 nested in a window with Noctalia in it (Alt is the modifier when nested); 241 `nix run ~/NixDaemon#noctalia` alone works too. 242 4. `nh os switch`; the Hyprland session keeps working; log out; tuigreet shows 243 "niri"; log in; Noctalia bar appears in Rosé Pine. 244 5. Rollback path if anything is wrong: `nh os rollback` (or the boot menu). 245 246 ## Out of scope 247 248 - Converting the Hyprland config itself to a wrapped package (it stays a 249 home-manager module; it works and the video does not cover it). 250 - Per-project tooling, dotfiles, secrets: untouched. 251 - A Rosé Pine Dawn variant for Niri/Noctalia. 252 - Committing: the owner commits (jj). The rewrite is left as working-tree 253 changes plus `git add` of the new files so the flake can see them.