NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

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.