Noctalia V5 + LabWC on NixOS

Noctalia V5 + LabWC on NixOS

Noctalia V5 isn't released yet but you already can try it on Linux distro like Artix, Arch, NixOS. Please follow the official Noctalia documentation or copy the parts you need from my configuration.nix. The ram usage on NixOS is 366Mb for Noctalia V5 + LabWC, I also use Ly as minimal login manager.

# /etc/nixos/configuration.nix
{ config, pkgs, lib, ... }:

let
  noctalia = import (fetchTarball "https://github.com/noctalia-dev/noctalia/archive/cachix.tar.gz") {};
in

{
  imports = [
    ./hardware-configuration.nix
    noctalia.nixosModule
  ];

  nix.package = pkgs.nixVersions.stable;

  nix.settings = {
    experimental-features = [ "nix-command" "flakes" ];
    extra-substituters = [ "https://noctalia.cachix.org" ];
    extra-trusted-public-keys = [ "noctalia.cachix.org-1:pCOR47nnMEo5thcxNDtzWpOxNFQsBRglJzxWPp3dkU4=" ];
    # auto-optimise-store = true;  # disabled: slow on virtio HDD, run `nix store optimise` periodically
  };
  # system.autoUpgrade.enable = true;  # disabled: consumes CPU/RAM on single-core VM

  nix.gc = {
    automatic = true;
    dates = "weekly";
    options = "--delete-older-than 7d";
  };

  # Bootloader & kernel
  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;
  boot.kernelPackages = pkgs.linuxPackages_7_1;
  boot.tmp.useTmpfs = true;

  # Networking
  networking.hostName = "nixos";
  networking.networkmanager.enable = true;
  networking.firewall.enable = true;
  networking.firewall.allowedTCPPorts = [ 3000 ];

  # Host filesystem mounts
  # mount host dir
  fileSystems."/mnt/shared_host" = {
    fsType = "virtiofs";
    device = "shared_host";
    options = [ "rw" "noatime" "_netdev" "nofail" ];
  };

  # Time & locale
  time.timeZone = "Europe/Rome";

  i18n.defaultLocale = "en_US.UTF-8";
  i18n.extraLocaleSettings = {
    LC_ADDRESS = "it_IT.UTF-8";
    LC_IDENTIFICATION = "it_IT.UTF-8";
    LC_MEASUREMENT = "it_IT.UTF-8";
    LC_MONETARY = "it_IT.UTF-8";
    LC_NAME = "it_IT.UTF-8";
    LC_NUMERIC = "it_IT.UTF-8";
    LC_PAPER = "it_IT.UTF-8";
    LC_TELEPHONE = "it_IT.UTF-8";
    LC_TIME = "it_IT.UTF-8";
  };

  # Display server & WM
  services.xserver.enable = true;

  services.displayManager.ly.enable = true;

  services.displayManager.autoLogin.enable = true;
  services.displayManager.autoLogin.user = "g";
  services.displayManager.defaultSession = "labwc";

  services.displayManager.sessionPackages = [ pkgs.labwc ];

  programs.noctalia = {
    enable = true;
    systemd.enable = true;
  };

  systemd.user.services.noctalia.serviceConfig = {
    ExecStart = lib.mkForce "${pkgs.writeShellScriptBin "noctalia-wrap" ''
      # Wait for the Wayland socket (labwc may not have created it yet)
      while [ ! -S "$XDG_RUNTIME_DIR/''${WAYLAND_DISPLAY-wayland-0}" ]; do
        sleep 0.2
      done
      exec ${noctalia.package}/bin/noctalia
    ''}/bin/noctalia-wrap";
    RestartSec = lib.mkForce "2";
  };

  programs.xwayland.enable = true;
  programs.dconf.enable = true;
  security.polkit.enable = true;
  services.xserver.desktopManager.runXdgAutostartIfNone = true;

  xdg.portal = {
    enable = true;
    extraPortals = [ pkgs.xdg-desktop-portal-wlr pkgs.xdg-desktop-portal-gtk ];
    configPackages = [ pkgs.labwc ];
  };

  # Audio (PipeWire)
  services.pipewire = {
    enable = true;
    alsa.enable = true;
    alsa.support32Bit = true;
    pulse.enable = true;
  };
  security.rtkit.enable = true;

  # SPICE guest tools (VM clipboard, mouse, resolution)
  services.spice-vdagentd.enable = true;

  systemd.user.services.spice-vdagent = {
    description = "Spice vdagent";
    wantedBy = [ "graphical-session.target" ];
    serviceConfig = {
      ExecStart = "${pkgs.writeShellScriptBin "spice-vdagent-wrap" ''
        # Wait for Xwayland socket (spice-vdagent needs X even with -x)
        while [ ! -S /tmp/.X11-unix/X0 ]; do
          sleep 0.2
        done
        export DISPLAY=:0
        exec ${pkgs.spice-vdagent}/bin/spice-vdagent -x
      ''}/bin/spice-vdagent-wrap";
      Restart = "always";
      RestartSec = "2";
      StartLimitBurst = 5;
      StartLimitIntervalSec = 30;
    };
  };

  systemd.user.services.spice-clipboard-bridge = {
    description = "Bridge Wayland clipboard to X11 for SPICE";
    wantedBy = [ "graphical-session.target" ];
    partOf = [ "graphical-session.target" ];
    environment = {
      WAYLAND_DISPLAY = "wayland-0";
    };
    serviceConfig = {
      Restart = "on-failure";
      RestartSec = "2";
      ExecStart = "${pkgs.writeShellScriptBin "spice-clipboard-bridge" ''
        while true; do
          # Wait for the Wayland socket (labwc may not have created it yet)
          while [ ! -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]; do
            sleep 0.2
          done
          # Wait for the X server socket (xclip needs it)
          while [ ! -S /tmp/.X11-unix/X0 ]; do
            sleep 0.2
          done
          export DISPLAY=:0

          # Wayland -> X11 direction
          ${pkgs.wl-clipboard}/bin/wl-paste --watch ${pkgs.xclip}/bin/xclip -selection clipboard -i &
          WLPID=$!

          # X11 -> Wayland direction
          last=""
          while true; do
            # if wl-paste died (Wayland disconnect), restart outer loop
            kill -0 "$WLPID" 2>/dev/null || break
            # if either socket disappeared (relogin), restart outer loop
            [ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ] || break
            [ -S /tmp/.X11-unix/X0 ] || break
            clip=$(${pkgs.xclip}/bin/xclip -selection clipboard -o 2>/dev/null)
            if [ -n "$clip" ] && [ "$clip" != "$last" ]; then
              printf '%s' "$clip" | ${pkgs.wl-clipboard}/bin/wl-copy 2>/dev/null || true
              last="$clip"
            fi
            sleep 1
          done
          kill "$WLPID" 2>/dev/null
        done
      ''}/bin/spice-clipboard-bridge";
    };
  };

  systemd.user.services.set-resolution = {
    description = "Set display resolution";
    wantedBy = [ "graphical-session.target" ];
    after = [ "graphical-session.target" ];
    environment = {
      WAYLAND_DISPLAY = "wayland-0";
    };
    partOf = [ "graphical-session.target" ];
    serviceConfig = {
      Type = "simple";
      Restart = "on-failure";
      RestartSec = "2";
      ExecStart = "${pkgs.writeShellScriptBin "set-resolution" ''
        while true; do
          # wait for the Wayland socket
          while [ ! -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]; do
            sleep 0.2
          done
          # wait for the Virtual-1 output to appear
          while ! ${pkgs.wlr-randr}/bin/wlr-randr 2>/dev/null | grep -q "^Virtual-1"; do
            sleep 0.2
          done
          # set resolution and keep re-applying
          while true; do
            ${pkgs.wlr-randr}/bin/wlr-randr --output Virtual-1 --mode 1680x1050 2>/dev/null
            sleep 10
            # if the Wayland socket disappeared, restart the outer loop
            [ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ] || break
          done
        done
      ''}/bin/set-resolution";
    };
  };

  # Hardware & power
  hardware.cpu.amd.updateMicrocode = true;
  #hardware.bluetooth.enable = true;
  services.fstrim.enable = true;
  zramSwap.enable = true;

  # User accounts
  programs.fuse.userAllowOther = true;

  security.wrappers.fusermount3 = {
    owner = "root";
    group = "root";
    setuid = true;
    source = "${pkgs.fuse3}/bin/fusermount3";
  };

  users.users.g = {
    isNormalUser = true;
    description = "user";
    extraGroups = [ "networkmanager" "wheel" "docker" "fuse" ];
  };

  fonts.packages = with pkgs; [
    noto-fonts
    fira-code
  ];

  environment.systemPackages = with pkgs; [
    labwc
    wl-clipboard
    xclip

    wlr-randr
    kitty
    micro
    htop
    fzf
    ncdu
    fastfetch
    nano
    nautilus
    brave
    opencode
    gnome-text-editor
    fuse3
  ];

  nixpkgs.config.allowUnfree = true;

  system.stateVersion = "26.11";
}