# Moving from Niri and DMS to Noctalia v5 on NixOS

A reversible NixOS migration from Niri and DankMaterialShell to Noctalia v5, with shared keyring unlock, separate greeter trials, application colors, and recoverable GUI settings.

Author: Ruhani Rabin
Canonical: https://rabin.blog/move-niri-dms-to-noctalia-v5-nixos/
Published: 2026-10-17T16:22:00.000Z
Updated: 2026-10-18T04:45:00.000Z

I had Niri and DankMaterialShell working already. The launcher, tray, wallpaper colors and login screen were part of my normal day. I wanted to try Noctalia v5 and keep an easy way back to DMS.

This follows [my Niri and DankMaterialShell setup](https://rabin.blog/nixos-niri-dank-how-to/). Keep that working setup as the starting point. We are changing the desktop shell first, then the greeter. Niri stays where it is.

The first Noctalia login was great. The applications I tried loaded, the tray appeared, and Bitwarden showed an icon that I had not seen in my DMS session. GNOME Keyring also unlocked through both greeters. But some less obvious things were left behind: a DMS terminal theme, a Neovim startup hook calling `dms`, and a cursor setting that only existed in a DMS-generated file.

Those are the parts worth writing down. Installing another panel was the easy bit.

![niri noctalia v5 on nixos desktop](https://rabin.blog/_astro/niri-noctalia-v5-on-nixos-desktop.Bg83FYrj_1kQQvh.webp)

## TL;DR

* Keep Niri and the existing GNOME Keyring setup. Change the shell first, then test the greeter separately.
* Use NixOS specialisations to try Noctalia while keeping the working DMS desktop available.
* Separate shared Niri settings from shell-specific shortcuts, generated files and application themes.
* Keep generated palettes and plugin rules writable. Capture reviewed GUI settings and both Rules Studio files into the repository.
* Test login, keyring unlock and everyday applications before making Noctalia the default. Fresh-machine restoration still needs a separate test.

## What this guide starts with

You need an existing flake-based **NixOS** installation with Niri, upstream DMS 1.6.2 and its matching greeter, plus Home Manager integrated into the NixOS configuration. The earlier post shows that setup. Standalone Home Manager and other distributions need different service wiring; do not paste this NixOS module into them.

The examples use `alice`, the flake host `my-host`, and a checkout at `~/nixos-config`. Replace those consistently. Keep your existing Nixpkgs and Home Manager pins, hardware configuration and state versions. My source configuration uses NixOS/Home Manager 26.05; this guide is based on Noctalia **5.2.1** and Noctalia Greeter **1.6.0**, the revisions used for this migration, rather than claiming they are the latest releases.

The code below is a migration reference. It deliberately leaves your existing display/input/layout configuration, passwords, hardware and package choices in place. The fresh-login results described here came from my workstation.

**Warning**: Make a known-good commit and keep an older boot generation before starting. Do not combine this with a driver update, a bootloader replacement, an EFI change, or a network reconfiguration. Keep your existing `system.stateVersion` and `home.stateVersion` values.

If the checkout is clean, a small preparation step is enough:

```bash
cd ~/nixos-config
git status --short
git switch -c desktop/noctalia-trial
nix flake metadata
readlink -f /run/current-system
```

If it is dirty, review and commit only the files you intend to preserve first. A flake lock pins dependencies; it does not back up the keyring or mutable application data. Keep your normal backup too.

## Keep three choices separate

Niri arranges windows. Noctalia supplies the bar, launcher, notifications, tray and lock-screen interface. A greeter supplies the login screen before your session starts.

I tested them in this order:

| Checkpoint       | Compositor | Shell    | Login greeter               |
| ---------------- | ---------- | -------- | --------------------------- |
| Existing desktop | Niri       | DMS      | DMS                         |
| First trial      | Niri       | Noctalia | DMS                         |
| Second trial     | Niri       | Noctalia | Noctalia                    |
| Accepted default | Niri       | Noctalia | Noctalia, with DMS rollback |

The GNOME Keyring and its PAM login hooks stay shared. Changing a panel should not also change where applications keep their secrets.

There is no Umbriel installation in this guide. Umbriel is another compositor, so that would be a separate experiment after this one. Its runtime window controls also do not remove the need to preserve saved settings.

## 1. Add pinned inputs without updating everything else

Keep the `nixpkgs`, `home-manager`, DMS and DMS Greeter inputs from the earlier setup. Add these two entries **inside the existing `inputs` set** in `flake.nix`:

flake.nix — add to inputs

```nix
noctalia.url = "github:noctalia-dev/noctalia/v5.2.1";


noctalia-greeter = {
  url = "github:noctalia-dev/noctalia-greeter/6127c31e64624edea725c134e8c19a1dd25447e1";
  inputs.nixpkgs.follows = "noctalia/nixpkgs";
};
```

Keep the existing DMS shell and DMS Greeter inputs on their matching **v1.6.2** releases from the previous article.

I left Noctalia using its own upstream Nixpkgs input. Following the workstation’s Nixpkgs can reduce dependency duplication, but it can also prevent reuse of upstream cached builds. Neither choice requires globally trusting another cache. [Noctalia’s NixOS documentation](https://docs.noctalia.dev/noctalia/getting-started/nixos/) explains that trade-off.

After editing the inputs, run `nix flake lock` and review `git diff -- flake.lock`. Do not use a blanket dependency update as part of the desktop migration. Some lock-node labels can be renumbered; compare the actual locked revisions as well as the text diff.

## 2. Give the desktop bundle one selector

This is enough structure for the migration. Create the files below before the first build; the Home Manager module imports the persistence module introduced in step 4.

* nixos-config/

  * flake.nix

  * configuration.nix

  * home.nix

  * workstation.nix

  * modules/

    * desktop.nix
    * home-desktop.nix
    * home-noctalia-state.nix

  * app-configs/

    * niri/

      * config.kdl

      * common.kdl

      * shells/

        * dms.kdl
        * noctalia.kdl

    * noctalia/

      * config.toml
      * sync-state.py

    * noctalia-state/

      * noctalia-rules-studio.kdl
      * rules.json

The first selector keeps DMS as the ordinary default and adds two trial specialisations. A specialisation is an alternate configuration built alongside the normal system, normally selectable from the boot menu.

Create `workstation.nix`:

workstation.nix

```nix
{ lib, ... }:
{
  # Start with the known-good desktop as the ordinary default.
  workstation.desktop.profile = "niri-dms";
  workstation.desktop.greeter = "dms";


  specialisation.noctalia-trial.configuration = {
    workstation.desktop.profile = lib.mkForce "niri-noctalia";
    workstation.desktop.greeter = lib.mkForce "dms";
    boot.loader.grub.configurationName = "Niri + Noctalia / DMS Greeter";
  };
  specialisation.noctalia-greeter-trial.configuration = {
    workstation.desktop.profile = lib.mkForce "niri-noctalia";
    workstation.desktop.greeter = lib.mkForce "noctalia";
    boot.loader.grub.configurationName = "Niri + Noctalia / Noctalia Greeter";
  };
}
```

My first trial entry showed a 1970 date because the specialisation link had an epoch timestamp. That confused me for a moment. The system clock was fine, so I gave the GRUB entries explicit names.

They do not change EFI partitions or firmware boot order.

Create `modules/desktop.nix` as the owner of shell selection, login selection and shared authentication:

modules/desktop.nix

```nix
{ config, lib, pkgs, desktopUser, ... }:
let
  shell = if config.workstation.desktop.profile == "niri-dms" then "dms" else "noctalia";
  dmsGreeter = config.workstation.desktop.greeter == "dms";
in
{
  options.workstation.desktop = {
    profile = lib.mkOption {
      type = lib.types.enum [ "niri-dms" "niri-noctalia" ];
      default = "niri-dms";
    };
    greeter = lib.mkOption {
      type = lib.types.enum [ "dms" "noctalia" ];
      default = "dms";
    };
  };


  config = {
    programs.niri.enable = true;
    services.displayManager.defaultSession = "niri";
    services.displayManager.sddm.enable = false;
    services.displayManager.dms-greeter.enable = false;


    services.gnome.gnome-keyring.enable = true;
    security.pam.services.greetd.enableGnomeKeyring = true;
    security.pam.services.login.enableGnomeKeyring = true;
    security.pam.services.greetd.kwallet.enable = false;
    security.pam.services.login.kwallet.enable = false;
    xdg.portal.config.niri."org.freedesktop.impl.portal.Secret" =
      lib.mkForce "gnome-keyring";


    programs.dank-material-shell = lib.mkIf (shell == "dms") {
      enable = true;
      enableDynamicTheming = true;
      systemd.enable = true;
      systemd.target = "niri.service";
    };
    systemd.user.services.dms = lib.mkIf (shell == "dms") {
      after = [ "niri.service" ];
      requisite = [ "niri.service" ];
      partOf = lib.mkForce [ "niri.service" ];
      unitConfig.ConditionEnvironment = "XDG_CURRENT_DESKTOP=niri";
      serviceConfig.Environment = "QT_QPA_PLATFORMTHEME=qt6ct";
    };


    programs.noctalia = lib.mkIf (shell == "noctalia") {
      enable = true;
      systemd.enable = true;
      systemd.target = "niri.service";
    };
    systemd.user.services.noctalia = lib.mkIf (shell == "noctalia") {
      requisite = [ "niri.service" ];
      unitConfig.ConditionEnvironment = "XDG_CURRENT_DESKTOP=niri";
      serviceConfig.Environment = "QT_QPA_PLATFORMTHEME=qt6ct";
    };


    users.groups.dms-greeter = lib.mkIf dmsGreeter { };
    users.users.dms-greeter = lib.mkIf dmsGreeter {
      isSystemUser = true;
      group = "dms-greeter";
    };
    services.greetd.settings.default_session.user = lib.mkIf dmsGreeter "dms-greeter";
    programs.dms-greeter = lib.mkIf dmsGreeter {
      enable = true;
      configHome = "/home/${desktopUser}";
      compositor.name = "niri";
    };
    services.displayManager.noctalia-greeter = lib.mkIf (!dmsGreeter) {
      enable = true;
      cursorTheme.package = pkgs.bibata-cursors;
      settings = {
        session.default = "Niri";
        user.default = desktopUser;
        keyboard.layout = "us";
        cursor = { theme = "Bibata-Modern-Amber"; size = 24; };
      };
    };


    home-manager.users.${desktopUser} = {
      imports = [ ./home-desktop.nix ./home-noctalia-state.nix ];
      _module.args.desktopShell = shell;
    };
    assertions = [
      {
        assertion = config.programs.dank-material-shell.enable != config.programs.noctalia.enable;
        message = "Enable exactly one shell for Niri.";
      }
      {
        assertion = config.programs.dms-greeter.enable != config.services.displayManager.noctalia-greeter.enable;
        message = "Enable exactly one login greeter.";
      }
    ];
  };
}
```

This replaces the unconditional DMS shell and greeter blocks from the earlier configuration. **Move those declarations; do not keep a second copy enabled elsewhere.** Preserve any custom `programs.dms-greeter.compositor.customConfig` you already need inside its conditional DMS block. The example lets the greeter use its defaults instead of copying my monitors.

Likewise, remove any old `programs.dms-shell` declaration if you still have one from the distribution-packaged stage of the earlier article. This guide uses the upstream `programs.dank-material-shell` module. Disable competing display-manager or Secret Service startup declarations in your existing setup; leave their saved data alone.

Now extend your existing `outputs` function to accept `noctalia` and `noctalia-greeter`. The relevant host definition should have this shape. Merge it into the existing flake; keep unrelated modules and `specialArgs`:

flake.nix — relevant outputs wiring

```nix
outputs = inputs@{ nixpkgs, home-manager, dms, dms-greeter,
                   noctalia, noctalia-greeter, ... }: {
  nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
    system = "x86_64-linux";
    specialArgs.desktopUser = "alice";
    modules = [
      ./configuration.nix
      ./workstation.nix
      ./modules/desktop.nix
      dms.nixosModules.dank-material-shell
      dms-greeter.nixosModules.dank-greeter
      noctalia.nixosModules.default
      noctalia-greeter.nixosModules.default
      home-manager.nixosModules.home-manager
      {
        home-manager.useGlobalPkgs = true;
        home-manager.useUserPackages = true;
        home-manager.users.alice = import ./home.nix;
      }
    ];
  };
};
```

If a module is already imported, do not add a second differently wrapped copy of it. `configuration.nix` should still import your own hardware and package files. `home.nix` should still own your username, home directory and existing state version.

The greeter’s session name is `Niri`, which is the installed desktop entry’s display name. Confirm it on your installation before changing it.

## 3. Split Niri’s common settings from shell integration

Keep your existing display geometry, keyboard, mouse, workspaces and ordinary application rules. Put the shell-independent root content into `app-configs/niri/common.kdl`, keeping any relative include targets in the same relative locations.

Move DMS shortcuts, DMS window/layer rules, `spawn-at-startup` commands and generated DMS includes out of the common file. The selected shell gets its own file. A minimal common file looks like this; **merge your existing working settings rather than replacing them with this small example**:

app-configs/niri/common.kdl

```kdl
// Minimal example. Preserve your existing display, input and layout settings.
binds {
    Mod+Return { spawn "ghostty"; }
    Mod+Q { close-window; }
    Mod+H { focus-column-left; }
    Mod+L { focus-column-right; }
    Mod+Shift+E { quit; }
}
```

The new root is only two includes. Home Manager substitutes the selected shell path while building it:

app-configs/niri/config.kdl

```kdl
include "./common.kdl"
include "./shells/dms.kdl"
```

Create the DMS shell file and move your existing DMS integration into it. These are the essential shortcuts:

app-configs/niri/shells/dms.kdl

```kdl
binds {
    Mod+Space { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
    Mod+V { spawn "dms" "ipc" "call" "clipboard" "toggle"; }
    Mod+Alt+L { spawn "dms" "ipc" "call" "lock" "lock"; }
}
// Keep the other DMS-managed includes from your existing setup here.
```

For example, preserve the old optional `~/.config/niri/dms/cursor.kdl` include here, using your actual home path. Preserve `binds.kdl`, `input.kdl`, `layout.kdl`, `wpblur.kdl`, `alttab.kdl` and `windowrules.kdl` too if your DMS setup uses them. They must not be included in the Noctalia session.

Create the Noctalia shell file:

app-configs/niri/shells/noctalia.kdl

```kdl
cursor {
    xcursor-theme "Bibata-Modern-Amber"
    xcursor-size 38
}
binds {
    Mod+Space { spawn "noctalia" "msg" "panel-toggle" "launcher"; }
    Mod+V { spawn "noctalia" "msg" "panel-toggle" "clipboard"; }
    Mod+Shift+S { spawn "noctalia" "msg" "settings-toggle"; }
    Mod+N { spawn "noctalia" "msg" "panel-toggle" "control-center" "notifications"; }
    Mod+Alt+L { spawn "noctalia" "msg" "session" "lock"; }
    XF86AudioRaiseVolume allow-when-locked=true { spawn "noctalia" "msg" "volume-up"; }
    XF86AudioLowerVolume allow-when-locked=true { spawn "noctalia" "msg" "volume-down"; }
    XF86AudioMute allow-when-locked=true { spawn "noctalia" "msg" "volume-mute"; }
}
window-rule {
    match app-id="dev.noctalia.Noctalia"
    open-floating true
    default-column-width { fixed 1080; }
    default-window-height { fixed 920; }
}
layer-rule {
    match namespace="^noctalia-backdrop"
    place-within-backdrop true
}
```

I explicitly set the cursor because my previous Bibata selection lived in a DMS-generated fragment. Removing that fragment from the Noctalia profile also removed its cursor selection. The greeter still had Bibata, which made this look more mysterious than it was.

The size `38` is my session preference. Pick your own size and use it consistently in the Niri and Home Manager settings below. The greeter uses its own size; that is independent.

Create `modules/home-desktop.nix`. It validates the complete selected Niri tree during the build, then leaves Rules Studio’s generated file out of the immutable result so the plugin can write it later:

modules/home-desktop.nix

```nix
{ config, lib, pkgs, desktopShell, ... }:
let
  noctalia = desktopShell == "noctalia";
in
{
  xdg.configFile."niri" = {
    recursive = true;
    source = pkgs.runCommand "niri-config" { nativeBuildInputs = [ pkgs.niri ]; } ''
      cp -r ${../app-configs/niri} $out
      chmod -R u+w $out
      substituteInPlace $out/config.kdl \
        --replace-fail "./shells/dms.kdl" "./shells/${desktopShell}.kdl"
      ${lib.optionalString noctalia ''
        cp ${../app-configs/noctalia-state/noctalia-rules-studio.kdl} $out/noctalia-rules-studio.kdl
        # The plugin expects this exact path and include line.
        mkdir -p $out/cfg
        mv $out/noctalia-rules-studio.kdl $out/cfg/noctalia-rules-studio.kdl
        cat >> $out/config.kdl <<'KDL'
      // BEGIN NIRI RULES STUDIO
      include "./cfg/noctalia-rules-studio.kdl"
      // END NIRI RULES STUDIO
      KDL
      ''}
      niri validate --config $out/config.kdl
      ${lib.optionalString noctalia "rm $out/cfg/noctalia-rules-studio.kdl"}
    '';
  };


  xdg.configFile."noctalia/config.toml" = lib.mkIf noctalia {
    source = ../app-configs/noctalia/config.toml;
  };
  xdg.configFile."systemd/user/app-@autostart.service.d/override.conf".text = ''
    [Unit]
    After=${if noctalia then "noctalia" else "dms"}.service
  '';


  home.pointerCursor = lib.mkIf noctalia {
    package = pkgs.bibata-cursors;
    name = "Bibata-Modern-Amber";
    size = 38;
  };
  dconf.settings."org/gnome/desktop/interface" = lib.mkIf noctalia {
    cursor-theme = "Bibata-Modern-Amber";
    cursor-size = 38;
  };
  # Preserve the other mutable GTK settings left by the previous shell.
  home.activation.noctaliaGtkCursor = lib.mkIf noctalia
    (lib.hm.dag.entryAfter [ "writeBoundary" ] ''
      ${pkgs.python3}/bin/python3 - <<'PY'
      import configparser
      from pathlib import Path
      for version in (3, 4):
          path = Path.home() / ".config" / f"gtk-{version}.0" / "settings.ini"
          settings = configparser.ConfigParser(interpolation=None)
          settings.read(path)
          if not settings.has_section("Settings"):
              settings.add_section("Settings")
          settings["Settings"]["gtk-cursor-theme-name"] = "Bibata-Modern-Amber"
          settings["Settings"]["gtk-cursor-theme-size"] = "38"
          path.parent.mkdir(parents=True, exist_ok=True)
          with path.open("w") as stream:
              settings.write(stream)
      PY
    '');
}
```

**Remove** the earlier Home Manager `xdg.configFile."niri"` declaration and autostart override now that this module owns them. If Home Manager reports an existing regular file conflict, back up that specific file and move it aside before retrying. Do not recursively delete your Niri configuration. Keep existing GTK icon/color ownership; the activation above changes only cursor fields in the mutable GTK settings.

The shell starts through systemd, tied to `niri.service`. Do not also launch it from Niri’s `spawn-at-startup`, an XDG autostart entry or a second Home Manager Noctalia service.

Two startup owners make troubleshooting very entertaining. Momentarily.

Create the initial Noctalia TOML. I started with a small bar and app color templates disabled:

app-configs/noctalia/config.toml

```toml
# Author: Ruhani Rabin
[shell]
polkit_agent = true
launch_apps_as_systemd_services = true


[theme]
mode = "dark"
source = "builtin"
builtin = "Catppuccin"


[theme.templates]
enable_builtin_templates = false
enable_community_templates = false


[bar.default]
position = "top"
start = ["launcher", "workspaces", "active_window"]
center = ["clock"]
end = ["volume", "network", "tray", "notifications", "session"]


[widget.tray]
hide_passive = false
drawer = false
```

Noctalia v5 uses TOML. This is not the older Quickshell/JSON configuration. Its GUI writes an overlay at the following path, which takes precedence over the baseline file:

```text
~/.local/state/noctalia/settings.toml
```

An old GUI override can therefore win over a new baseline value. [Configuration precedence](https://docs.noctalia.dev/noctalia/configuration/) is worth checking when a setting appears to ignore Nix.

If your previous desktop ran another Polkit agent, choose one agent for the session. This example enables Noctalia’s agent so normal administrator prompts can be displayed.

## 4. Make GUI settings and window rules recoverable

Home Manager keeps the Niri root configuration read-only. Niri Rules Studio normally adds an include there on its first save, so I needed to declare that include beforehand and leave the generated rules writable.

I installed **Niri Rules Studio**, `setyvii/niri-rules-studio`, through Noctalia’s plugin interface. The version used in this migration was 0.1.0. It builds opening rules from the selected window and validates them with Niri.

We already declared that exact include during the Niri build. The plugin can find it without changing the root. Its candidate files and generated rules live in the writable `~/.config/niri/cfg/` directory, alongside the Home Manager symlinks for other fragments.

The two things to preserve are:

* `~/.config/niri/cfg/noctalia-rules-studio.kdl`, which Niri reads;
* The editable `rules.json` model the plugin reads, at:

```text
~/.local/state/noctalia/plugins/data/setyvii/niri-rules-studio/rules.json
```

Saving only the KDL restores the window behavior but loses the plugin’s editing model. Saving only the JSON loses the generated configuration. Keep both.

Prepare an empty seed **only for files that do not already exist**. If you already saved rules in the plugin, copy its current KDL and model into these destinations instead:

```bash
mkdir -p app-configs/noctalia-state
if [ ! -e app-configs/noctalia-state/noctalia-rules-studio.kdl ]; then
  printf '// Managed by Niri Rules Studio. No saved rules yet.\n' > app-configs/noctalia-state/noctalia-rules-studio.kdl
fi
if [ ! -e app-configs/noctalia-state/rules.json ]; then
  printf '[]\n' > app-configs/noctalia-state/rules.json
fi
```

Create `app-configs/noctalia/sync-state.py` with this capture/restore helper. It is adapted from my working script so the checkout path comes from the module rather than my username:

app-configs/noctalia/sync-state.py

```python
#!/usr/bin/env python3
# Author: Ruhani Rabin
"""Capture reviewed GUI state; restore only files missing on a fresh install."""
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import tomllib


home = Path.home()
repository = Path(os.environ["NOCTALIA_CAPTURE_REPO"])
repo = repository / "app-configs/noctalia-state"
state = Path(os.environ.get("NOCTALIA_STATE_HOME", os.environ.get("XDG_STATE_HOME", home / ".local/state"))) / "noctalia"
config = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) / "niri"
model = state / "plugins/data/setyvii/niri-rules-studio/rules.json"
rules = config / "cfg/noctalia-rules-studio.kdl"
pairs = [(state / "settings.toml", repo / "settings.toml"),
         (model, repo / "rules.json"), (rules, repo / rules.name)]


def write(path, content):
    if path.exists() and path.read_bytes() == content:
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
        stream.write(content)
        temporary = stream.name
    os.replace(temporary, path)


if sys.argv[1:] == ["restore"]:
    for live, saved in pairs:
        if not live.exists() and saved.exists():
            write(live, saved.read_bytes())
    model.parent.mkdir(parents=True, exist_ok=True)
    rules.parent.mkdir(parents=True, exist_ok=True)
    sys.exit(0)


# Only these reviewed shell/UI sections belong in Git. Arbitrary plugin
# configuration, external accounts, and runtime histories are never captured.
allowed = {"accessibility", "bar", "calendar", "desktop_widgets", "dock",
           "hot_corners", "location", "lockscreen", "lockscreen_widgets",
           "shell", "theme", "wallpaper", "widget"}
if pairs[0][0].exists():
    raw = pairs[0][0].read_text()
    parsed = tomllib.loads(raw)
    chunks = re.split(r"(?m)(?=^[ \t]*\[)", raw)
    kept = []
    for chunk in chunks:
        match = re.match(r"[ \t]*\[+([^\]]+)\]", chunk)
        section = match.group(1) if match else ""
        root = section.split(".")[0]
        # Accounts are mutable setup, even when their credentials live elsewhere.
        if section == "calendar.account" or section.startswith("calendar.account."):
            continue
        if root not in allowed and section != "plugins" and section != "":
            continue
        if re.search(r"(?im)^\s*[^=]*(password|token|secret|api_key|credential)[^=]*=", chunk):
            continue
        # Plugins only contributes its enabled list, never per-plugin settings.
        if section == "plugins":
            chunk = "[plugins]\nenabled = " + json.dumps(parsed.get("plugins", {}).get("enabled", [])) + "\n\n"
        kept.append(chunk)
    content = "".join(kept)
    tomllib.loads(content)
    write(repo / "settings.toml", content.encode())


pending = []
for live, saved in pairs[1:]:
    if live.exists():
        content = live.read_bytes()
        if live == model:
            json.loads(content)
        else:
            subprocess.run(["niri", "validate", "-c", str(live)], check=True, capture_output=True)
        pending.append((saved, content))
for saved, content in pending:
    write(saved, content)


inventory = []
for manifest in sorted((state / "plugins/materialized").glob("*/*/plugin.toml")):
    metadata = tomllib.loads(manifest.read_text())
    source = manifest.parent.parent.name
    result = subprocess.run(["git", "-C", str(state / "plugins/sources" / source / "repo"),
                             "rev-parse", "HEAD"], capture_output=True, text=True)
    inventory.append({"id": metadata["id"], "version": metadata.get("version"),
                      "source": source, "source_revision": result.stdout.strip() if result.returncode == 0 else None})
if inventory:
    write(repo / "plugins.json", (json.dumps(inventory, indent=2) + "\n").encode())
```

The script captures selected GUI sections and an inventory of materialized plugin IDs, versions and source revisions. It excludes account configuration, unreviewed top-level sections and sections containing credential-like keys. That filter is a guard, not a promise that arbitrary text can never contain a secret. Review every snapshot diff before committing it, particularly if you add plugins, custom commands or window rules. The rule model can record a window title even when title matching is disabled, so inspect that text too.

Do not add clipboard history, notification history, account databases, plugin caches or the whole Noctalia state directory to Git. Wallpaper files and application credentials remain part of your ordinary backup.

> The snapshot records wallpaper paths, not the images themselves.

Plugin inventory is also **not a plugin lockfile**. Recording a source revision does not cause Noctalia to install that exact revision during recovery.

Restore the configured sources and enabled plugins through Noctalia, then check API compatibility and the inventory. Fresh-machine fetching was not tested in this migration.

Create `modules/home-noctalia-state.nix` to provide the command, restore missing live files, and watch for edits or atomic file replacements:

modules/home-noctalia-state.nix

```nix
{ config, lib, pkgs, desktopShell, ... }:
let
  # Change this if you keep the checkout somewhere else.
  repository = "${config.home.homeDirectory}/nixos-config";
  sync = pkgs.writeShellScriptBin "noctalia-state-sync" ''
    export PATH=${lib.makeBinPath [ pkgs.git pkgs.niri ]}:"$PATH"
    export NOCTALIA_CAPTURE_REPO=${lib.escapeShellArg repository}
    exec ${pkgs.python3}/bin/python3 ${../app-configs/noctalia/sync-state.py} "$@"
  '';
in
lib.mkIf (desktopShell == "noctalia") {
  home.packages = [ sync ];
  home.activation.noctaliaStateRestore = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
    ${sync}/bin/noctalia-state-sync restore
  '';
  systemd.user.services.noctalia-state-sync = {
    Unit.Description = "Capture reviewed Noctalia GUI state";
    Service = {
      Type = "oneshot";
      ExecStart = "${sync}/bin/noctalia-state-sync";
    };
    Install.WantedBy = [ "graphical-session.target" ];
  };
  systemd.user.paths.noctalia-state-sync = {
    Unit.Description = "Watch Noctalia settings and managed rules";
    Path = {
      PathChanged = [
        "%h/.local/state/noctalia/settings.toml"
        "%h/.local/state/noctalia"
        "%h/.config/niri/cfg"
        "%h/.local/state/noctalia/plugins/data/setyvii/niri-rules-studio"
        "%h/.local/state/noctalia/plugins/materialized/community"
        "%h/.local/state/noctalia/plugins/materialized/official"
      ];
      Unit = "noctalia-state-sync.service";
    };
    Install.WantedBy = [ "graphical-session.target" ];
  };
}
```

The module assumes the normal XDG paths shown in the guide. If you use custom `XDG_STATE_HOME`, `XDG_CONFIG_HOME` or `NOCTALIA_STATE_HOME`, update the watcher paths as well as the script environment. Changing only the script would leave the watcher looking in the wrong place.

Restore seeds missing files. It does not overwrite an existing session’s choices on every rebuild. To intentionally restore an older saved setting, stop the shell and watcher, back up and move aside only the live file being restored, run `noctalia-state-sync restore`, then start them again. Do not treat a rebuild as “replace every GUI choice with Git.”

If you already have the DMS capture service from the earlier post, keep its declarations under `lib.mkIf (desktopShell == "dms")`. Scope its GTK/Qt bootstrap and mutable-state restore hooks the same way. They should not run in the Noctalia profile and put the old palette back. Preserve their existing implementation and paths; this guide does not delete DMS state.

## 5. Build first, then boot the shell trial

At this point all referenced files exist. Review the new files before staging them; Git-backed flakes generally ignore untracked files until they are added:

```bash
git status --short
git add flake.nix flake.lock workstation.nix modules app-configs/niri app-configs/noctalia app-configs/noctalia-state
nix flake check --no-build
nix build .#nixosConfigurations.my-host.config.system.build.toplevel --no-link
```

Stage any other files you intentionally changed, such as `home.nix`, separately. Read the diff rather than adding unrelated mutable state along for the ride.

`nix flake check --no-build` evaluates the flake; the system build also runs our Niri validation. For this reference I evaluated the DMS/DMS, Noctalia/DMS and Noctalia/Noctalia combinations. A successful build still does not exercise a login.

Install the boot configurations without switching the current desktop:

```bash
sudo nixos-rebuild boot --flake .#my-host
```

Reboot and choose **Niri + Noctalia / DMS Greeter**. If that entry is not where your bootloader exposes specialisations, check your bootloader’s arrangement; the label example is GRUB-specific. Keep the older known-good generation available.

On my workstation `nixcfg` is a local alias that stages files and runs an activating test rebuild. `ns` is another local alias for switching. They are not standard Nix commands, and I did not use that test alias for the initial boot-only handoff. Otherwise the ordinary DMS default could be activated while I was trying the alternate desktop.

After login, inspect the services:

```bash
systemctl --user is-active niri.service noctalia.service
systemctl --user is-active dms.service
systemctl --user status noctalia-state-sync.path noctalia-state-sync.service
journalctl --user -b -u noctalia.service --no-pager -n 60
```

Niri and Noctalia should be active; DMS should not be. A successful capture service normally shows `inactive (dead)` afterward because it is a oneshot. Check its exit status and the active path watcher before treating that as a failure.

Test the launcher, clipboard, notifications, lock/unlock, brightness/audio keys and the applications you actually use. For the tray, check both visibility and opening the menu/window. An icon in a screenshot is only half the job.

I saw Bitwarden in the Noctalia tray and tested several applications. pCloud’s old tray problem had already been solved in DMS 1.6, so a move to Noctalia should not be advertised as the fix for that earlier issue.

## 6. Verify the same keyring, then test the new greeter

Check the Secret Service owner and login collection in the first trial:

```bash
busctl --user --no-pager list | rg 'org.freedesktop.secrets|org.kde.kwallet'
busctl --user get-property org.freedesktop.secrets \
  /org/freedesktop/secrets/collection/login \
  org.freedesktop.Secret.Collection Locked
```

The owner should be GNOME Keyring. If your collection is named `login`, an unlocked collection returns `b false`. A different collection path needs its actual object path; do not reset a keyring just because that example path does not exist.

For a round-trip test, install `libsecret` and `util-linux` in a temporary shell and store a disposable value with a unique attribute. These are normal-user commands:

```bash
nix shell nixpkgs#libsecret nixpkgs#util-linux --command bash
probe_id="$(uuidgen)"
printf '%s' 'temporary-desktop-check' | secret-tool store \
  --label='Temporary desktop migration check' migration-probe "$probe_id"
secret-tool lookup migration-probe "$probe_id"
secret-tool clear migration-probe "$probe_id"
exit
```

The lookup should print the temporary value without a second unlock prompt, and the final command removes it. This is a synthetic test; it does not inspect your real passwords. The temporary shell uses the registry’s Nixpkgs, not necessarily the workstation pin. You can instead use the same packages already installed from your pin.

PAM unlocking assumes a normal password login and an appropriate keyring password. Autologin, fingerprint-only login or a mismatched keyring password need separate handling. Do not “fix” those by deleting the collection.

Once the shell trial is acceptable, reboot into **Niri + Noctalia / Noctalia Greeter**. Log in using your normal password, then repeat the keyring and application checks. I passed synthetic store/read/delete tests through both greeters. That is the reason I kept the shared authentication block unchanged.

The next acceptance checks are cold boot, both monitors, screen sharing/OBS, suspend/resume, lock/unlock and audio capture. Do these on your hardware. They are not implied by the keyring test.

## 7. Connect application colors to the selected shell

Only after the basic desktop works, enable the templates you want in Noctalia’s settings. Enable wallpaper colors if that is your preference, then select the relevant built-in and community templates.

Two files are involved in most application integrations: a generated palette and an application’s selector pointing to it. Having the first does not guarantee the second is in use.

### Ghostty and Alacritty

My Ghostty configuration still selected `dankcolors`. Noctalia generated `themes/noctalia`, but its hook could not rewrite the read-only Home Manager config. Alacritty had the same kind of problem: its palette existed without the import.

Add the following attributes **inside `modules/home-desktop.nix`’s result set**, and remove or merge the older declarations for these same fields. Keep your existing fonts, opacity and unrelated settings:

modules/home-desktop.nix — add inside the result set

```nix
programs.alacritty = {
  enable = true;
  settings.general.import = [
    (if noctalia
      then "~/.config/alacritty/themes/noctalia.toml"
      else "~/.config/alacritty/dank-theme.toml")
  ];
};


xdg.configFile."ghostty/config".text = ''
  font-size = 15
  theme = ${if noctalia then "noctalia" else "dankcolors"}
'';
```

The `noctalia` boolean already exists in that module’s `let` block. This is a small example, not a request to throw away your whole Ghostty config. Use one declaration for the final file.

Keep stable config immutable and generated palette content writable. Rebuild, reopen the terminals, then change the wallpaper and check both colors and the logs. Keep the corresponding Noctalia template enabled. If a hook still tries to edit an immutable file, inspect that specific template version rather than making the whole configuration writable.

### VS Code

The theme name is **NoctaliaTheme**, supplied by the `noctalia.noctaliatheme` extension. Install it, select **Preferences: Color Theme → NoctaliaTheme**, and keep the VS Code community template enabled. Reload the window if needed. The DMS extension can remain installed for rollback; it must not remain the active theme in the Noctalia session. [VS Code template instructions](https://docs.noctalia.dev/noctalia/templates/community/vscode/)

The extension installation and chosen editor theme are app preferences unless you separately declare them through your Home Manager VS Code setup. This guide does not silently make that extension declarative.

### Neovim / LazyVim

My startup error came from the DMS colorscheme calling the missing `dms` executable. For an existing LazyVim setup, add this declaration to `modules/home-desktop.nix`:

modules/home-desktop.nix — add inside the result set

```nix
home.file.".config/nvim/lua/config/desktop-theme.lua".text = ''
  return "${desktopShell}"
'';
```

In your existing DMS/base46 Lazy plugin specification, add this field to the plugin table so it loads only in DMS:

app-configs/nvim/lua/plugins/dms-base46.lua — add to the existing plugin table

```lua
enabled = require("config.desktop-theme") == "dms",
```

Then create `lua/plugins/base16.lua` in that same repo-managed Neovim tree:

app-configs/nvim/lua/plugins/base16.lua

```lua
return {
  {
    "LazyVim/LazyVim",
    opts = function(_, opts)
      if require("config.desktop-theme") == "noctalia" then
        opts.colorscheme = function()
          local ok, palette = pcall(require, "matugen")
          if ok then
            palette.setup()
          else
            vim.cmd.colorscheme("habamax")
          end
        end
      end
    end,
  },
  {
    "RRethy/base16-nvim",
    lazy = false,
    priority = 1000,
    enabled = require("config.desktop-theme") == "noctalia",
  },
}
```

Enable Noctalia’s Neovim template. It writes the mutable `~/.config/nvim/lua/matugen.lua`. Let Lazy install base16 and preserve your plugin lockfile using your existing backup/declarative policy. Keep that generated palette writable; do not place a fixed copy in the immutable Neovim source.

The LazyVim callback is important. Loading a palette early was not enough when LazyVim applied Tokyo Night later. With this selection, the generated palette loaded in my headless check, and a missing palette fell back to Habamax without the DMS startup error. A fresh machine still needs the plugin downloaded. [Neovim template](https://docs.noctalia.dev/noctalia/templates/community/neovim/)

### GTK3, GTK4 and Qt6

Inspect the generated files and their selectors. Paths on this setup were:

```bash
rg -n 'noctalia|dank|matugen' ~/.config/gtk-3.0/gtk.css ~/.config/gtk-4.0/gtk.css
rg -n 'color_scheme_path|custom_palette' ~/.config/qt6ct/qt6ct.conf
ls ~/.config/gtk-3.0/noctalia.css ~/.config/gtk-4.0/noctalia.css
ls ~/.config/qt6ct/colors/noctalia.conf
```

Keep `qt6ct` installed and `QT_QPA_PLATFORMTHEME=qt6ct` in the environment used to launch Qt applications. If you already set it in your session configuration, preserve it. The shell service declaration above supplies it to Noctalia’s launched children; it is not a universal environment change for every launch path.

In my session, GTK3/4 imported Noctalia’s stylesheet and Qt6ct selected `noctalia.conf` with a custom palette. Earlier DMS CSS imports can remain in the file, so inspect their order and whether later CSS overrides them. Reopen representative GTK and Qt apps and look at them; a path check cannot prove every application’s theme is correct. If Stylix or another theme manager owns the same files, choose one owner for each target. [GTK/Qt integration](https://docs.noctalia.dev/noctalia/templates/official/gtk-qt/)

## 8. Allow passwordless greeter appearance sync

Under **Settings → Security → Greeter**, leave the privilege command as `pkexec`. That program launches the privileged helper; it does not decide to skip authentication on its own.

Passwordless sync is optional. To allow it for the configured user, add this field to the Noctalia greeter block in `modules/desktop.nix`:

modules/desktop.nix — inside services.displayManager.noctalia-greeter

```nix
passwordless-sync-users = [ desktopUser ];
```

This spelling belongs to the pinned **upstream project module** used here. A nixpkgs module may expose `passwordlessSyncUsers` instead. Do not mix both modules or copy an option spelling without evaluating your chosen module.

The upstream rule authorizes the packaged constrained appearance-sync helper for that user’s active local session. It does not grant general passwordless root commands. The installed action must match `org.noctalia.greeter.sync-appearance` and its `--sync` argument. Keep an older helper’s legacy mode administrator-authenticated. [Sync authorization](https://docs.noctalia.dev/greeter/sync/) explains the version requirements and scope.

Build and activate, then test **Sync Now**. Auto-Sync is a separate GUI preference; the Polkit declaration does not enable it. Declarative greeter values also take precedence over synced values. If you pin a color scheme or monitor layout in Nix, sync will not override that pin. I left the scheme out of the generic example so readers can choose whether to pin it or follow sync.

The login greeter does not have the session lock screen’s widget editor. To customize the latter, run this while logged in and unlocked:

```bash
noctalia msg lockscreen-widgets-edit
```

Those are different screens. Copying wallpaper colors to the greeter does not copy your lock-screen widget arrangement.

## 9. Check that GUI edits return to the repository

In the Noctalia session, enable Niri Rules Studio and add its widget. Keep its `niri_config` setting at `~/.config/niri/config.kdl`, the root managed by this guide. Create a small rule for an application you can safely reopen.

Then check the capture path:

```bash
systemctl --user status noctalia-state-sync.path noctalia-state-sync.service
journalctl --user -u noctalia-state-sync.service --no-pager -n 30
noctalia-state-sync
git diff -- app-configs/noctalia-state
```

You should see both the model and KDL changes. Opening rules affect newly opened windows, so reopen the matching application before judging the result. A rule can be saved correctly but match the wrong app ID.

Do not edit the generated KDL and expect the plugin’s JSON model to update itself. The next GUI save can overwrite that manual edit. Keep hand-authored rules in the common Niri configuration and plugin-owned rules in the managed file. Rules later in the include order may override earlier matching settings.

In this migration the watcher was active, captures exited successfully, three saved rule models and their KDL matched the live files, and the retained GUI settings matched live values. Wallpaper rotation also produced a new settings snapshot and Helix palette while I was preparing a commit. A working capture mechanism is supposed to notice that.

I still needed to check persistence after restarting, restoration on a fresh machine, and whether each rule matched the intended application.

## 10. Promote Noctalia and keep one useful rollback

After both trials pass your checks, replace `workstation.nix` with this final selection:

workstation.nix — accepted default

```nix
{ lib, ... }:
{
  workstation.desktop.profile = "niri-noctalia";
  workstation.desktop.greeter = "noctalia";


  specialisation.niri-dms.configuration = {
    workstation.desktop.profile = lib.mkForce "niri-dms";
    workstation.desktop.greeter = lib.mkForce "dms";
    boot.loader.grub.configurationName = "Niri + DMS / DMS Greeter";
  };
}
```

Rebuild the next boot generation and reboot into the ordinary default:

```bash
nix flake check --no-build
nix build .#nixosConfigurations.my-host.config.system.build.toplevel --no-link
sudo nixos-rebuild boot --flake .#my-host
```

After the normal default is accepted, ordinary later changes can use `sudo nixos-rebuild switch --flake .#my-host`. Avoid switching shell bundles mid-session; use a fresh login or reboot for that change.

Removing the trial specialisations removes them from **future generations**. Old generations can still contain the old entries. That is expected. Keep the known-good generation and DMS rollback until you have finished the recovery checks. Do not run a blanket generation deletion just to make the boot menu shorter.

If the trial fails, boot the known-good generation. From a working console you can also revert the configuration changes to your saved commit and rebuild; inspect your worktree before using Git to undo anything. Do not hard-reset away later unrelated work or delete live application state.

A pre-existing DMS UI-state watcher can change palette files in the working tree when you return to DMS. Review those separately. I excluded the two generated DMS palette edits from the migration commit; that did not remove the DMS modules or their previously committed rollback state.

## EasyEffects startup and wired-profile selection

EasyEffects had an existing login problem. My workstation refreshed the PipeWire graph after login to recover a present-but-silent Wave:3 microphone. EasyEffects started first, then aborted when the refresh restarted PipeWire. Opening it manually afterward made its tray icon appear. That happened under DMS too.

If you have the same already-[existing](https://rabin.blog/use-elgato-wave-3-on-linux-with-noise-reduction-eq-compression-and-a-virtual-microphone/) `wave3-login-refresh.service`, the focused Home Manager ordering change is:

home.nix — only if this refresh service exists on your machine

```nix
xdg.configFile."systemd/user/app-com.github.wwmm.easyeffects@autostart.service.d/audio-ordering.conf".text = ''
  [Unit]
  Wants=wave3-login-refresh.service
  After=wave3-login-refresh.service
'';
```

Check the generated autostart unit name on your system before using it. Do not add my microphone restart workaround to a healthy audio setup. Test a fresh login, preset selection and a real recording; connected PipeWire devices alone did not catch this problem.

There was also a wired-profile surprise in Noctalia 5.2.1. After disconnecting through the panel and reconnecting, the built-in reconnect code tried saved profiles in D-Bus object-path order. It selected my alternate DNS profile even though that profile had autoconnect disabled. Explicit activation bypassed the preference I expected.

For that version, `nmtui` let me select the intended profile. Increasing autoconnect priority alone did not fix that code path. Treat this as a version-specific observation, not a reason to overwrite your network configuration during a desktop migration.

Noctalia became my normal desktop, with the DMS configuration kept for rollback. The applications I checked could unlock their secrets, and the capture service copied my GUI changes back into the repository.

From the second day onward, there was almost nothing left to adjust. I mostly used the Window Rules plugin to change a few window behaviors. That was about it.
