diff --git a/DECISIONS.md b/DECISIONS.md
index 2c56df7..cd90d78 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1,5 +1,145 @@
# Decisions
+## 2026-09-24 – One topic per module, split out of desktop.nix
+
+- **Who**: Dominik, ClaudeCode
+- **Why**: `desktop.nix` had grown to 637 lines carrying thirteen topics, and
+ the Firefox preconfiguration took its browser block from 5 lines to 70. The
+ deeper problem only showed on reading it: the existing split followed the
+ NixOS option namespace rather than the topic. `services.pipewire` lived in
+ `services.nix` while its RNNoise `configPackages` sat in `desktop.nix`;
+ `services.gvfs` and `services.udisks2` there, their packages here; Bluetooth
+ likewise. Touching audio meant opening two files and knowing that it did.
+- **Tradeoffs**: (1) **No enable option per module.** The stricter form would
+ give each module an `options` block so a host could switch it off. `disk.nix`
+ and `user.nix` have that for a reason — every host sets a different device and
+ login name — but all four hosts run the identical desktop, so the option would
+ never take a second value. This matches what `nix-starter-configs` reserves
+ `modules/` for: abstractions with options, justified by reuse across hosts.
+ (2) **Flat, not a directory per topic.** At fourteen files a directory level
+ would add a path component and nothing else. (3) **Comments moved with their
+ code**, and the three that pointed "above" or "below" across the new file
+ boundary were rewritten to name the module instead — they had become wrong,
+ which is the only reason to touch a comment.
+- **How**: Eight steps, one module each, ordered from independent to entangled:
+ browsers, printing, lock, shell, theme, audio, files, network. The last three
+ are the merges that pull a service declaration out of `services.nix` to join
+ its packages. `desktop.nix` ends at 194 lines with the compositor, the XDG
+ config deployment, portals and the packages that belong to no topic;
+ `services.nix` at 362 with the flake-age check, the hardening profiles and the
+ user units.
+
+ The refactor had to change nothing, and that was made provable rather than
+ assumed. The first step already showed the naive check does not work: moving a
+ package to another module reorders `environment.systemPackages`, which changes
+ the `system-path` hash, which propagates into every file naming it — the store
+ path of the system differs although nothing about it does. The criterion is
+ therefore: `nix store diff-closures` empty, and every differing file identical
+ once store hashes are normalised away. Two steps also reordered the user
+ tmpfiles list; that counts as neutral only because no two rules name the same
+ path, which is checked rather than presumed. Both hosts were built and
+ compared after each of the eight steps.
+
+## 2026-09-24 – Firefox preconfiguration through policies
+
+- **Who**: Dominik, ClaudeCode
+- **Why**: Firefox arrived with Betterfox as its preference base, but Betterfox
+ covers telemetry, the new tab page and sponsored content — nothing of what was
+ actually wanted on top: uBlock Origin, the Catppuccin theme, no Pocket, no
+ Sync, autoscroll, DuckDuckGo, no translation offer, no password prompts, a
+ hidden bookmarks toolbar and about:blank as the home page. A grep over its
+ user.js finds not one of those.
+- **Tradeoffs**: (1) **normal_installed over force_installed** for both add-ons.
+ Force-installing is the usual choice for an ad blocker, but it also takes away
+ the ability to remove it, and leaving LibreWolf was precisely about hardening
+ that could only be undone by hand. (2) **preferencesStatus "default" over the
+ option's own "locked" default** — locked preferences cannot be changed in
+ about:config, which would make these three stricter than the 115 Betterfox
+ values around them; Betterfox rewrites user_pref to defaultPref for that same
+ reason. (3) **Two policies for Pocket, not one.** DisablePocket is marked
+ deprecated and, by Mozilla's own note, does not touch the new tab page. Pocket
+ the service is discontinued; since Firefox 141 the block there is Stories, so
+ FirefoxHome has to cover the other half. Checked before relying on it:
+ SearchEngines was ESR-only until Firefox 139 and is now available everywhere,
+ and all three preferences fall under prefixes the Preferences policy permits —
+ general.autoScroll is listed by name.
+- **How**: `policies` and `preferences` on the existing `programs.firefox` block
+ in `modules/desktop.nix`. The theme needs two entries rather than one:
+ ExtensionSettings installs it, `extensions.activeThemeID` selects it, and
+ without the second Firefox stays on the default look. The add-on ID sits in a
+ `let` binding because both read it. Verified at eval that the module's own
+ ExtensionSettings entry for the German language pack survives alongside the two
+ added ones — the option is a JSON-format type, so attribute sets merge per key.
+ Two of the settings are starting points rather than fixed states, which the
+ policies cannot change: DisplayBookmarksToolbar sets the toolbar's initial
+ state and the View menu still brings it back, and Homepage without `Locked`
+ leaves about:blank editable. Locking either one was not the point — the reason
+ for leaving LibreWolf was settings that could not be undone.
+
+## 2026-09-24 – ungoogled-chromium as the second browser, Firefox as the handler
+
+- **Who**: Dominik, ClaudeCode
+- **Why**: Testing against a Blink engine needs a Chromium next to Firefox.
+ That makes the default browser a question for the first time: until now
+ `firefox.desktop` was the only entry in the system path claiming
+ `x-scheme-handler/http`, so `xdg-open` had exactly one candidate and no
+ registration was needed. With a second claimant the choice is whatever order
+ the entries are found in.
+- **Tradeoffs**: (1) **Which Chromium** — the ungoogled variant, because stock
+ `chromium` carries the Google endpoints, and the point here is a rendering
+ engine, not a second account-bound browser. The usual objection is the build:
+ ungoogled-chromium is a patch set on Chromium and building it locally takes
+ hours. Checked before committing to it — all four paths come from the binary
+ cache (209 MiB download), nothing is built here. Had that not held, stock
+ `chromium` would have been the answer. (2) **Which types get the default** —
+ the four `firefox.desktop` declares, no more: `text/html`,
+ `application/xhtml+xml` and the two scheme handlers. A wildcard would claim
+ types neither browser should own.
+- **How**: `ungoogled-chromium` in `environment.systemPackages` and
+ `xdg.mime.defaultApplications` in `modules/desktop.nix`, which writes
+ `/etc/xdg/mimeapps.list`. Verified on the built system: both
+ `chromium-browser.desktop` and `firefox.desktop` claim http, and the
+ generated `mimeapps.list` points all four types at Firefox.
+
+## 2026-09-24 – The browser is Firefox from nixpkgs, hardened by Betterfox
+
+- **Who**: Dominik, ClaudeCode
+- **Why**: LibreWolf ships no language packs, so the interface stays English,
+ and its hardening is a property of the build: `resistFingerprinting`,
+ clearing on shutdown and restricted WebGL have to be undone by hand wherever
+ they get in the way. The 2026-08-20 entry named that price when it chose
+ LibreWolf; it turned out to be the reason to leave. Firefox from nixpkgs has
+ `programs.firefox.languagePacks`, and moving the hardening into this repo
+ makes it a file that can be swapped instead of a property of the browser.
+- **Tradeoffs**: (1) **Which preference set** — `arkenfox-userjs` is in
+ nixpkgs, which would need no input at all, but it sits at `144.0` while the
+ Firefox from this flake is `156.0`. Betterfox tracks releases more closely
+ (`154.0`) and is not in nixpkgs, so it comes in as an input with
+ `flake = false`, the same shape as `moonarch` and `sweet-cursors`. That way
+ `nix flake update` moves it and neither version nor hash is kept by hand —
+ the argument that sank the self-packaged Waterfox on 2026-08-20. (2) **Which
+ Betterfox file** — the curated `user.js` from the repository root, not the
+ modules it is assembled from (`Securefox.js` alone is ten times its size).
+ Leaving LibreWolf's strictness behind is the point of the move, so the
+ moderate set is the one that fits; switching to the modules later is one line
+ in the `installPhase`. (3) **How the preferences arrive** — without
+ home-manager there is no declarative way to place a `user.js` in a profile.
+ `programs.firefox.autoConfigFiles` is the way in: the wrapper concatenates
+ each file into `mozilla.cfg`, which Firefox reads before a profile exists.
+ AutoConfig has no `user_pref`, so the package installs the list twice and
+ rewrites the copy to `defaultPref` — the shape nixpkgs' `arkenfox-userjs`
+ uses, and it leaves every value changeable in `about:config`.
+- **How**: `betterfox` as a flake input, `pkgs/betterfox.nix` building the two
+ files, and `programs.firefox` in `modules/desktop.nix` with
+ `languagePacks = [ "de" ]` and the `user.cfg` in `autoConfigFiles`.
+ `librewolf` is gone from the package list; the module installs the wrapped
+ Firefox itself, which is why no browser is named there any more. Verified by
+ building both hosts: `mozilla.cfg` carries 115 `defaultPref` lines and no
+ `user_pref`, the language pack policy names `langpack-de@firefox.mozilla.org`,
+ and nothing called LibreWolf is left in the system path. The language pack is
+ not in the store — the policy has Firefox fetch it from releases.mozilla.org
+ on first start, so without a network the interface stays English until then.
+
## 2026-09-23 – The ThinkPad keeps amdgpu out of the initrd so LUKS stays readable
- **Who**: Dominik, ClaudeCode
diff --git a/README.md b/README.md
index 3989725..8476781 100644
--- a/README.md
+++ b/README.md
@@ -21,10 +21,19 @@ flake.nix Inputs and outputs
pkgs/ Build recipes
sweet-cursors.nix cursor theme
moonarch-scripts.nix battery and camera helpers
+ betterfox.nix Firefox preference set
modules/
user.nix moonarch.user option, set per host
disk.nix partitioning: GPT, optional LUKS2, btrfs subvolumes
- desktop.nix /etc deployment, package set, theme
+ desktop.nix compositor, /etc deployment, portals, the rest of the packages
+ theme.nix GTK, Qt, Kvantum, cursors, fonts
+ browsers.nix Firefox with its preconfiguration, Chromium, handler
+ shell.nix zsh and the command line tools
+ lock.nix gtklock and its PAM stack
+ audio.nix PipeWire and the RNNoise denoising
+ files.nix thunar, thumbnails, mounting
+ network.nix NetworkManager VPN plugin, Bluetooth, firewall
+ printing.nix driverless CUPS and Avahi
greetd.nix greetd + regreet, autologin per host
services.nix systemd system and user services
zed.nix Zed editor, nix-ld, seeded user config
@@ -35,7 +44,7 @@ hosts/
```
`nixosModules.moonarch` is the entry point: it imports disko's NixOS module
-alongside the six above and hands them the packages and the config source.
+alongside the fourteen above and hands them the packages and the config source.
## Usage
diff --git a/flake.lock b/flake.lock
index 639eab4..17f582b 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,5 +1,21 @@
{
"nodes": {
+ "betterfox": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1788713351,
+ "narHash": "sha256-mIP/WcXUcGrJsWCJzR4zqPOmt0BbbpTZVaN/MbIwBbw=",
+ "owner": "yokoffing",
+ "repo": "Betterfox",
+ "rev": "067172a4b0dc90e78e5b8b94d9abfe6430c6a7be",
+ "type": "github"
+ },
+ "original": {
+ "owner": "yokoffing",
+ "repo": "Betterfox",
+ "type": "github"
+ }
+ },
"disko": {
"inputs": {
"nixpkgs": [
@@ -24,11 +40,11 @@
"moonarch": {
"flake": false,
"locked": {
- "lastModified": 1789404100,
- "narHash": "sha256-Hirv+gCdFDqY79CZZ0gd2QQsGGXM1E/rfTTebdUTrw4=",
+ "lastModified": 1790177314,
+ "narHash": "sha256-2YTKIvK8gtoDf+zE0QyLyoWT8KQW82O0WQ4pWkHgX6g=",
"ref": "refs/heads/main",
- "rev": "a172f90c1c00d9c8f71e6403a2cdf39e667ca85a",
- "revCount": 187,
+ "rev": "2774d0947de08376651c05fb1b68907c7e3ffd61",
+ "revCount": 188,
"type": "git",
"url": "https://gitea.moonarch.de/nevaforget/moonarch.git"
},
@@ -86,6 +102,7 @@
},
"root": {
"inputs": {
+ "betterfox": "betterfox",
"disko": "disko",
"moonarch": "moonarch",
"nixos-hardware": "nixos-hardware",
diff --git a/flake.nix b/flake.nix
index 3a75569..0c66182 100644
--- a/flake.nix
+++ b/flake.nix
@@ -14,6 +14,14 @@
inputs.nixpkgs.follows = "nixpkgs";
};
+ # Firefox preference set, consumed by pkgs/betterfox.nix. Not in nixpkgs;
+ # carried as an input so `nix flake update` moves it and neither version
+ # nor hash have to be kept by hand.
+ betterfox = {
+ url = "github:yokoffing/Betterfox";
+ flake = false;
+ };
+
# Project sources. These repos are not flakes themselves, they are
# consumed as plain source trees following their default branch.
moonarch = {
@@ -32,6 +40,11 @@
pkgs = nixpkgs.legacyPackages.${system};
moonarchPackages = {
+ betterfox = pkgs.callPackage ./pkgs/betterfox.nix {
+ src = inputs.betterfox;
+ version = "154.0-unstable";
+ };
+
sweet-cursors = pkgs.callPackage ./pkgs/sweet-cursors.nix {
src = inputs.sweet-cursors;
version = "0-unstable";
@@ -90,6 +103,14 @@
./modules/user.nix
./modules/disk.nix
./modules/desktop.nix
+ ./modules/browsers.nix
+ ./modules/printing.nix
+ ./modules/lock.nix
+ ./modules/shell.nix
+ ./modules/theme.nix
+ ./modules/audio.nix
+ ./modules/files.nix
+ ./modules/network.nix
./modules/greetd.nix
./modules/services.nix
./modules/zed.nix
diff --git a/modules/audio.nix b/modules/audio.nix
new file mode 100644
index 0000000..ecf8e08
--- /dev/null
+++ b/modules/audio.nix
@@ -0,0 +1,49 @@
+# ABOUTME: PipeWire and the RNNoise input denoising from the shared config.
+# ABOUTME: Both halves in one place; they used to sit in two separate modules.
+
+{ pkgs, moonarchSrc, ... }:
+
+let
+ xdg = "${moonarchSrc}/defaults/xdg";
+
+ # PipeWire searches $PIPEWIRE_CONFIG_DIR, then $XDG_CONFIG_HOME/pipewire, then
+ # 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
+ # the other XDG configs are; it goes through the module's configPackages,
+ # which is what its own option documentation shows for a conf.d file.
+ #
+ # The file names the plugin as `librnnoise_ladspa`, resolved through
+ # LADSPA_PATH, which points at nothing here. Substituted to the
+ # store path, the way modules/desktop.nix does it for the wallpaper. LADSPA
+ # has no counterpart to services.pipewire.extraLv2Packages, so the path
+ # belongs in the file.
+ denoisingConfig = pkgs.runCommand "moonarch-pipewire-denoising" { } ''
+ mkdir -p "$out/share/pipewire/pipewire.conf.d"
+ substitute ${xdg}/pipewire/pipewire.conf.d/99-input-denoising.conf \
+ "$out/share/pipewire/pipewire.conf.d/99-input-denoising.conf" \
+ --replace-fail "plugin = librnnoise_ladspa" \
+ "plugin = ${pkgs.rnnoise-plugin}/lib/ladspa/librnnoise_ladspa.so"
+ '';
+in
+{
+ services.pipewire = {
+ enable = true;
+ alsa.enable = true;
+ alsa.support32Bit = true;
+ pulse.enable = true;
+ jack.enable = true;
+
+ # RNNoise input denoising, from the shared config. The filter chain appears
+ # as a separate "Noise Canceling source" input; it does not replace the raw
+ # microphone, so applications have to select it.
+ configPackages = [ denoisingConfig ];
+ };
+
+ # No pulseaudio for pactl: Steam probes wpctl and pactl both and takes
+ # whichever is on PATH (nixpkgs#525793), and wireplumber puts wpctl there.
+ # Its "pactl: command not found" is noise from Steam, not a gap.
+ environment.systemPackages = with pkgs; [
+ pavucontrol
+ alsa-utils
+ ];
+}
diff --git a/modules/browsers.nix b/modules/browsers.nix
new file mode 100644
index 0000000..dd19c87
--- /dev/null
+++ b/modules/browsers.nix
@@ -0,0 +1,111 @@
+# ABOUTME: Firefox with its preconfiguration, and Chromium as the second browser.
+# ABOUTME: Carries the default-handler registration the two of them make necessary.
+
+{ pkgs, moonarchPkgs, ... }:
+
+let
+ # The Firefox theme's add-on ID, read twice below: once to install the theme,
+ # once to select it. This is upstream Catppuccin's own build; the similarly
+ # named catppuccin-mocha on the same store belongs to a third party.
+ catppuccinThemeId = "{8446b178-c865-4f5c-8ccc-1d7887811ae3}";
+in
+{
+ # Browser. moonarch runs waterfox-bin, which nixpkgs does not carry; see
+ # DECISIONS.md for why this is not packaged here.
+ #
+ # The module puts the wrapped Firefox into environment.systemPackages itself,
+ # which is why the package list below names only the second browser.
+ # autoConfigFiles is what carries Betterfox: the wrapper concatenates every
+ # file named here into mozilla.cfg, which Firefox reads before a profile
+ # exists — the only way in without home-manager.
+ programs.firefox = {
+ enable = true;
+ languagePacks = [ "de" ];
+ autoConfigFiles = [ "${moonarchPkgs.betterfox}/user.cfg" ];
+
+ # Betterfox rewrites user_pref to defaultPref so that each of its 115 values
+ # stays changeable in about:config. This option defaults to "locked", which
+ # would nail these three down harder than the set they sit in.
+ preferencesStatus = "default";
+ preferences = {
+ "general.autoScroll" = true;
+
+ # Only the offer to translate. Translating by hand from the context menu
+ # keeps working.
+ "browser.translations.automaticallyPopup" = false;
+
+ # ExtensionSettings installs the theme but does not select it; without
+ # this, Firefox starts on the default look with Catppuccin sitting unused
+ # in the add-on manager.
+ "extensions.activeThemeID" = catppuccinThemeId;
+ };
+
+ # normal_installed rather than force_installed: both add-ons stay removable.
+ # Leaving LibreWolf was about hardening that could only be undone by hand.
+ #
+ # The latest.xpi URLs redirect to whatever the current version is, so
+ # neither add-on needs a version bump here.
+ policies = {
+ ExtensionSettings = {
+ "uBlock0@raymondhill.net" = {
+ installation_mode = "normal_installed";
+ install_url = "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi";
+ };
+ ${catppuccinThemeId} = {
+ installation_mode = "normal_installed";
+ install_url = "https://addons.mozilla.org/firefox/downloads/latest/catppuccin-mocha-lavender-git/latest.xpi";
+ };
+ };
+
+ SearchEngines.Default = "DuckDuckGo";
+ OfferToSaveLogins = false;
+
+ # Startup and the home button both land on a blank page. StartPage
+ # "homepage" is what makes the URL apply to startup as well; "none" would
+ # reach the same screen but leave the home button pointing elsewhere.
+ Homepage = {
+ URL = "about:blank";
+ StartPage = "homepage";
+ };
+
+ # A new tab opens blank as well, rather than on the Firefox start page
+ # with its search field and shortcuts.
+ NewTabPage = false;
+
+ # The initial state only — the toolbar stays something you can bring back
+ # from the View menu. There is no policy that locks it away.
+ DisplayBookmarksToolbar = "never";
+
+ # This is the Sync policy. There is no DisableSync: the CCK2 name of this
+ # one is disableSync, and it sets identity.fxaccounts.enabled.
+ DisableFirefoxAccounts = true;
+
+ # DisablePocket is deprecated and, by Mozilla's own note, leaves the new
+ # tab page alone. FirefoxHome covers that half: Stories replaced Pocket
+ # there in Firefox 141, after the service was discontinued.
+ DisablePocket = true;
+ FirefoxHome = {
+ Stories = false;
+ SponsoredStories = false;
+ };
+ };
+ };
+
+ # Both browsers claim x-scheme-handler/http in their desktop entries, and
+ # with two candidates xdg-open picks by whatever order it finds them in.
+ # These four types are exactly what firefox.desktop declares.
+ xdg.mime.defaultApplications = {
+ "text/html" = "firefox.desktop";
+ "application/xhtml+xml" = "firefox.desktop";
+ "x-scheme-handler/http" = "firefox.desktop";
+ "x-scheme-handler/https" = "firefox.desktop";
+ };
+
+ environment.systemPackages = [
+ # Second browser, for testing against a Blink engine. The ungoogled
+ # variant because the stock chromium carries the Google endpoints; it is
+ # fully substitutable, so the patch set does not have to be built here.
+ # Firefox comes from programs.firefox above and is the default handler.
+ pkgs.ungoogled-chromium
+ ];
+}
diff --git a/modules/desktop.nix b/modules/desktop.nix
index 3fc3225..43b739b 100644
--- a/modules/desktop.nix
+++ b/modules/desktop.nix
@@ -1,7 +1,7 @@
# ABOUTME: Deploys the XDG configs from the moonarch repo and the package set.
-# ABOUTME: Niri, Quickshell, terminal, theming, portals.
+# ABOUTME: Niri, Quickshell, terminal, portals and the packages without a theme.
-{ pkgs, lib, moonarchSrc, moonarchPkgs, moonarchThemes, ... }:
+{ pkgs, moonarchSrc, moonarchPkgs, moonarchThemes, ... }:
let
xdg = "${moonarchSrc}/defaults/xdg";
@@ -16,7 +16,6 @@ let
'';
wallpaper = "${moonarchSrc}/defaults/backgrounds/wallpaper.jpg";
- wallpaperBlur = "${moonarchSrc}/defaults/backgrounds/wallpaper-blur.jpg";
# The FHS path the wallpaper is published under, provided by the tmpfiles
# link further down. waypaper's config names /usr/share/moonarch/wallpaper.jpg
@@ -32,56 +31,6 @@ let
--replace-fail /usr/share/moonarch/wallpaper.jpg ${wallpaperPath}
'';
- # gtklock reads $XDG_CONFIG_HOME/gtklock/config.ini and gtklock/config.ini
- # under every $XDG_CONFIG_DIRS entry, so /etc/xdg is enough and nothing has
- # to be seeded into the home directory — the one thing swaylock could not do,
- # its sysconfdir being inside the store.
- #
- # The background stays the pre-blurred image. gtklock cannot blur either:
- # GTK3 has no `filter` CSS property at all (checked against
- # libgtk-3.so.0.2420.32 — it knows `background-image` and `box-shadow`, and
- # neither `filter` nor `backdrop-filter`), and it takes no screenshot of the
- # session, so there is nothing to blur at lock time in the first place.
- #
- # No gtk-theme key: gtklock is GTK3 and reads the session theme out of
- # /etc/xdg/gtk-3.0/settings.ini, so the theme name keeps living in one place.
- #
- # Modules are named by absolute store path. `-m` resolves a bare name against
- # the directory compiled into gtklock, which under Nix is gtklock's own store
- # path and contains no module at all. powerbar needs no configuration: its
- # built-in commands are `systemctl reboot|-i poweroff|suspend`, and systemd
- # is on the PATH the stasis unit sets in modules/services.nix.
- gtklockConfig = pkgs.writeText "gtklock-config.ini" ''
- [main]
- background=${wallpaperBlur}
- modules=${moduleList}
- '';
-
- moduleList = lib.concatStringsSep ";" [
- "${pkgs.gtklock-userinfo-module}/lib/gtklock/userinfo-module.so"
- "${pkgs.gtklock-powerbar-module}/lib/gtklock/powerbar-module.so"
- ];
-
- # The GTK theme name, in the one place both consumers below read it from.
- gtkTheme = "catppuccin-mocha-lavender-standard";
-
- # Both distributions ship the same Catppuccin GTK theme, 1.0.3, and still
- # disagree on its name — permanently, not as a migration. Arch installs the
- # upstream release zips through catppuccin-gtk-theme-mocha, whose directories
- # carry the full build name with the `+default` tweak marker; nixpkgs builds
- # the same source with no tweaks and so names it without that suffix. Neither
- # side can adopt the other's name without going out of its way.
- #
- # The rewrite therefore has to stay. It matters because GTK3 reads
- # settings.ini directly — the dconf entry further down only reaches
- # applications that ask GSettings, which on Wayland the GTK3 ones do not — so
- # a name that does not resolve puts every GTK3 application on Adwaita, with
- # no error anywhere.
- gtkSettings = version: pkgs.runCommand "gtk${version}-settings.ini" { } ''
- substitute ${xdg}/gtk-${version}/settings.ini "$out" \
- --replace-fail catppuccin-mocha-lavender-standard+default ${gtkTheme}
- '';
-
# 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
@@ -95,52 +44,6 @@ let
niriUserConfig = pkgs.writeText "niri-config.kdl" ''
include "/etc/niri/config.kdl"
'';
-
- # PipeWire searches $PIPEWIRE_CONFIG_DIR, then $XDG_CONFIG_HOME/pipewire, then
- # 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
- # the other XDG configs are; it goes through the module's configPackages,
- # which is what its own option documentation shows for a conf.d file.
- #
- # The file names the plugin as `librnnoise_ladspa`, resolved through
- # LADSPA_PATH, which points at nothing here. Substituted to the
- # store path, like the wallpaper paths above. LADSPA has no counterpart to
- # services.pipewire.extraLv2Packages, so the path belongs in the file.
- denoisingConfig = pkgs.runCommand "moonarch-pipewire-denoising" { } ''
- mkdir -p "$out/share/pipewire/pipewire.conf.d"
- substitute ${xdg}/pipewire/pipewire.conf.d/99-input-denoising.conf \
- "$out/share/pipewire/pipewire.conf.d/99-input-denoising.conf" \
- --replace-fail "plugin = librnnoise_ladspa" \
- "plugin = ${pkgs.rnnoise-plugin}/lib/ladspa/librnnoise_ladspa.so"
- '';
-
- # The theme deployed Kvantum/kvantum.kvconfig selects. It goes into
- # systemPackages: nixpkgs patches Kvantum to search XDG_DATA_DIRS for themes
- # (tsujan/Kvantum 01989083, in the package's patches list), and
- # /run/current-system/sw/share is on that path, so a theme package is found
- # system-wide with nothing seeded into the home directory.
- #
- # Verified under strace: a Qt6 client opens the theme's .kvconfig straight
- # out of the package's store path. The patch only takes in the Qt6 build
- # (NixOS/nixpkgs#355277) — a Qt5 client, here only VLC, falls back to the
- # default style.
- kvantumTheme = pkgs.catppuccin-kvantum.override {
- accent = "lavender";
- variant = "mocha";
- };
-
- # zsh/newuser runs its setup wizard on every interactive login for as long as
- # the user has none of .zshenv, .zprofile, .zshrc or .zlogin — the whole
- # configuration living in /etc/zshrc does not count. Seeded below to stop
- # that, and deliberately without configuration: moonarch's own copy of this
- # file sources the shared zshrc, which here would run it a second time, on
- # top of the one /etc/zshrc already did.
- userZshrc = pkgs.writeText "zshrc" ''
- # The moonarch zsh configuration is system-wide, in /etc/zshrc.
- #
- # This file keeps zsh from running zsh-newuser-install and is the place for
- # your own additions: zsh reads it after /etc/zshrc, so it wins over it.
- '';
in
{
# --- XDG configs -> /etc/xdg/ ---
@@ -157,20 +60,9 @@ in
"xdg/niri/config.kdl".source = "${xdg}/niri/config.kdl";
"xdg/quickshell/moonarch".source = "${xdg}/quickshell/moonarch";
"xdg/foot/foot.ini".source = "${xdg}/foot/foot.ini";
- "xdg/fastfetch/config.jsonc".source = "${xdg}/fastfetch/config.jsonc";
- "xdg/Kvantum/kvantum.kvconfig".source = "${xdg}/Kvantum/kvantum.kvconfig";
- "xdg/qt6ct/qt6ct.conf".source = "${xdg}/qt6ct/qt6ct.conf";
- # moonarch ships defaults/xdg/gtklock/config.ini, but it is not deployed
- # here: it names /usr/share/moonarch for the background and resolves the
- # modules by bare name against /usr/lib/gtklock. Both are store paths under
- # Nix, so the file is generated above rather than copied — the same reason
- # PipeWire's config takes its own route.
- "xdg/gtklock/config.ini".source = gtklockConfig;
- "xdg/gtk-3.0/settings.ini".source = gtkSettings "3.0";
- "xdg/gtk-4.0/settings.ini".source = gtkSettings "4.0";
# No xdg/pipewire/ entry: PipeWire does not read /etc/xdg, so the file was
# deployed and inert. It goes through services.pipewire.configPackages
- # below instead.
+ # in modules/audio.nix instead.
# Kept for layout parity with moonarch's tree; waypaper itself only reads
# the copy in the home directory, seeded below.
"xdg/waypaper/config.ini".source = waypaperConfig;
@@ -183,34 +75,6 @@ in
"${etc}/xdg-desktop-portal/niri-portals.conf";
};
- # Generic-family defaults. Same content as
- # defaults/etc/fonts/conf.d/65-moonarch-fonts.conf, but routed through the
- # fontconfig module: /etc/fonts/conf.d/ is owned by NixOS and rejects
- # environment.etc entries.
- # localConf is written to /etc/fonts/local.conf verbatim, so it has to be a
- # complete XML document — not just the inner elements.
- fonts.fontconfig.localConf = ''
-
-
-
- Moonarch generic-family defaults
-
-
- sans-serif
-
- UbuntuSans Nerd Font
-
-
-
- monospace
-
- UbuntuSansMono Nerd Font
-
-
-
- '';
-
# --- Compositor ---
programs.niri.enable = true;
@@ -232,118 +96,11 @@ in
"L+ ${wallpaperPath} - - - - ${wallpaper}"
];
- # --- Shell ---
- programs.zsh.enable = true;
-
- # moonarch's shared zshrc, which it installs as /etc/zsh/zshrc.moonarch. zsh reads
- # /etc/zshrc before ~/.zshrc, so a user file still wins over it.
- programs.zsh.interactiveShellInit = ''
- source ${moonarchSrc}/defaults/shell/zshrc
- '';
-
- # /etc/zshrc places promptInit after interactiveShellInit, and its default
- # ends in `prompt suse`, which assigns PS1 — the same parameter as PROMPT.
- # Left at the default it overwrites the prompt the sourced zshrc just set.
- # The zshrc owns the prompt, so nothing else has to run here.
- programs.zsh.promptInit = "";
-
- # The zshrc sources both plugins from /usr/share/zsh/plugins; the lookups are
- # guarded and silently find nothing here. These modules source the same
- # plugins from the store instead, with the strategy the zshrc sets.
- programs.zsh.syntaxHighlighting.enable = true;
- programs.zsh.autosuggestions = {
- enable = true;
- strategy = [ "history" "completion" ];
- };
-
- # environment.shellAliases carries ls, ll and l as defaults, and /etc/zshrc
- # renders them after interactiveShellInit, so they shadow the eza aliases of
- # the sourced zshrc. null drops an alias. Scoped to zsh: bash keeps the
- # coreutils ls, which is the only thing it has.
- programs.zsh.shellAliases = {
- ls = null;
- ll = null;
- l = null;
- };
-
- # --- File manager ---
- #
- # Counterpart to thunar, thunar-volman, thunar-archive-plugin, tumbler and
- # ffmpegthumbnailer in packages/official.txt.
- programs.thunar = {
- enable = true;
- plugins = with pkgs; [ thunar-volman thunar-archive-plugin ];
- };
- services.tumbler.enable = true;
-
- # --- Theme, systemwide instead of the gsettings calls in post-install.sh ---
- programs.dconf = {
- enable = true;
- profiles.user.databases = [{
- settings."org/gnome/desktop/interface" = {
- gtk-theme = gtkTheme;
- icon-theme = "Colloid-Grey-Catppuccin-Dark";
- color-scheme = "prefer-dark";
- font-name = "UbuntuSans Nerd Font 11";
- };
- }];
- };
-
- # Qt applications are wrapped, so each one looks for plugins in its own
- # closure and never finds a style installed alongside it — which is why VLC
- # kept coming up in the stock Qt style. Enabling this puts the profile's Qt5
- # and Qt6 plugin directories on QT_PLUGIN_PATH, which the wrappers append to.
- # platformTheme and style stay null: it would add packages that are already
- # listed below, and niri's environment block sets both variables already.
- qt.enable = true;
-
# --- Portals ---
xdg.portal = {
enable = true;
extraPortals = with pkgs; [ xdg-desktop-portal-gnome xdg-desktop-portal-gtk ];
};
-
- fonts.packages = with pkgs; [
- nerd-fonts.ubuntu-sans
- openmoji-color
- ];
-
- # --- Audio ---
- #
- # RNNoise input denoising, from the shared config. The filter chain appears as
- # a separate "Noise Canceling source" input; it does not replace the raw
- # microphone, so applications have to select it.
- services.pipewire.configPackages = [ denoisingConfig ];
-
- # --- Printing ---
- #
- # Driverless only, and no printer is declared here. Asked over IPP, the
- # Samsung M267x 287x answers with image/urf and application/PCLm among its
- # document formats, so CUPS builds the queue from the capabilities the device
- # reports about itself — no PPD, no driver package, nothing about anyone's
- # network in this repository. Any AirPrint-capable printer works the same way,
- # including ones this machine has never seen.
- #
- # Avahi is the discovery half: without it the printer is not found and its
- # .local name does not resolve. openFirewall defaults to true and opens
- # UDP 5353 for mDNS.
- services.printing.enable = true;
-
- services.avahi = {
- enable = true;
- nssmdns4 = true;
- openFirewall = true;
- };
-
- # --- VPN ---
- #
- # networking.networkmanager.plugins defaults to an empty list, and without the
- # OpenVPN plugin NetworkManager cannot create such a connection at all —
- # neither nmcli, nmtui nor nm-applet offer the type. Counterpart to
- # networkmanager-openvpn in packages/official.txt. nm-applet, installed below,
- # is what hands the keyring password to the connection.
- networking.networkmanager.plugins = [ pkgs.networkmanager-openvpn ];
-
environment.systemPackages = with pkgs; [
# XDG plumbing, before anything that calls into it. xdg-terminal-exec
# resolves a caller to whichever TerminalEmulator entry the XDG data
@@ -352,25 +109,8 @@ in
xdg-terminal-exec
# Own programs
- moonarchPkgs.sweet-cursors
moonarchPkgs.moonarch-scripts
- # Browser. moonarch runs waterfox-bin, which nixpkgs does not carry; see
- # DECISIONS.md for why this is not packaged here.
- librewolf
-
- # Session lock. The power menu is a Quickshell popout, not a package.
- # gtklock speaks the same ext-session-lock-v1 as swaylock, through
- # gtk-session-lock, so a crash still leaves the screen locked. The modules
- # are the reason for it: avatar and clock, and the power buttons. They are
- # installed for the sake of `nix-store --query --roots`; gtklock itself
- # loads them from the store paths named in the config above. playerctl is
- # deliberately absent — it needs playerctld, which nothing here runs.
- gtklock
- gtklock-userinfo-module
- gtklock-powerbar-module
-
- # Compositor extras
xwayland-satellite
# Bar, notifications, clipboard.
@@ -387,15 +127,14 @@ in
# parent's cgroup, so everything started from the launcher lands inside
# quickshell.service — which systemd kills whole on every restart of the
# unit (KillMode=control-group is the default). A crash of the bar took the
- # open browser with it, and Librewolf counted each one as a crash until it
- # stopped restoring the session at all. app2unit wraps systemd-run to put
+ # open browser with it, which counted each one as a crash until it stopped
+ # restoring the session at all. app2unit wraps systemd-run to put
# each application in its own transient app-*.scope, the same shape niri
# gives the windows it spawns itself.
app2unit
# Display and wallpaper
wlsunset
- nwg-look
brightnessctl
awww
swww-compat
@@ -403,57 +142,10 @@ in
# Terminal
foot
-
- # Audio. No pulseaudio for pactl: Steam probes wpctl and pactl both and
- # takes whichever is on PATH (nixpkgs#525793), and wireplumber puts wpctl
- # there. Its "pactl: command not found" is noise from Steam, not a gap.
- pavucontrol
- alsa-utils
-
- # Network and bluetooth. openvpn is the binary the NetworkManager plugin
- # below drives; it also stands on its own for a config file.
- networkmanagerapplet
- blueman
- openvpn
-
# Media
vlc
viewnior
- # File manager stack. thunar and tumbler come from their modules above;
- # ffmpegthumbnailer is what tumbler uses for video thumbnails.
- gvfs
- udisks2
- ntfs3g
- ffmpegthumbnailer
- # Backend for thunar-archive-plugin above; without it its menu entries do
- # nothing.
- xarchiver
- zip
- unzip
-
- # Theming. Same derivations the greeter uses, see flake.nix.
- moonarchThemes.gtk
- moonarchThemes.icons
- libsForQt5.qtstyleplugin-kvantum
- qt6Packages.qtstyleplugin-kvantum
- kvantumTheme
- qt6Packages.qt6ct
- qt6.qt5compat
-
- # CLI tools referenced by zshrc and the helper scripts
- bat
- btop
- eza
- fastfetch
- fd
- fzf
- jq
- lazygit
- ripgrep
- neovim
- git
-
# System
upower
polkit_gnome
@@ -465,14 +157,12 @@ in
galculator
];
- # Without this gtklock takes the session lock and then rejects every
- # password: there would be no /etc/pam.d/gtklock, and /etc/pam.d/other denies
- # auth outright. The generated stack is `auth sufficient pam_unix.so`, the
- # same one swaylock had.
- security.pam.services.gtklock = {};
-
# Polkit agent for privilege prompts inside the session.
security.polkit.enable = true;
+ # The setuid pkexec wrapper is opt-in and off by default. Without it pkexec
+ # refuses to run at all, which takes the camera and battery toggles with it —
+ # both reach their privileged half through pkexec.
+ security.polkit.enablePkexecWrapper = true;
# nixpkgs.config.allowUnfree covers the system build only. `nix profile` and
# friends evaluate a flake purely, so they see neither that setting nor
@@ -482,26 +172,6 @@ in
# Set by PAM at session start, so it takes a re-login, not just a rebuild.
environment.sessionVariables.NIXPKGS_ALLOW_UNFREE = "1";
- # niri sets both in its own environment block, but that only reaches what niri
- # itself spawns. Anything under the systemd user manager — quickshell, and so
- # everything started from its launcher — never sees them, and Qt applications
- # come up unstyled. Set here they travel through /etc/profile into
- # niri-session's import-environment, and from there into the user manager.
- environment.sessionVariables.QT_QPA_PLATFORMTHEME = "qt6ct";
- environment.sessionVariables.QT_STYLE_OVERRIDE = "kvantum";
-
- # Same gap, cursors: niri's cursor block reaches xwayland-satellite, so X11
- # clients have a themed pointer available, but everything under the user
- # manager arrives with XCURSOR_PATH alone and has no theme name to look up.
- # The name matches modules/greetd.nix, so greeter and session agree.
- environment.sessionVariables.XCURSOR_THEME = "Sweet-cursors";
- environment.sessionVariables.XCURSOR_SIZE = "24";
-
- # systemPackages alone does not put the theme on XDG_DATA_DIRS: the system
- # path links only the /share subdirectories named here, and Kvantum's is not
- # among the defaults.
- environment.pathsToLink = [ "/share/Kvantum" ];
-
# Seed configs for programs that read only from the home directory and have
# no system-wide fallback. 'C' copies once and never overwrites, so user
# edits survive. This is what moonarch's post-install.sh does imperatively.
@@ -520,14 +190,5 @@ in
# 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}"
- # Steam gets XCURSOR_THEME from the block above — verified in its own
- # environment — and still drew the core pointer, so parts of it resolve the
- # theme literally named 'default' rather than the variable. Xcursor looks
- # that name up along XCURSOR_PATH like any other, and ~/.icons is first,
- # so pointing it at the theme covers those lookups too. 'L+' replaces the
- # link on every activation, unlike the 'C' rules above, which keeps it
- # following the theme instead of freezing at today's one.
- "L+ %h/.icons/default - - - - /run/current-system/sw/share/icons/Sweet-cursors"
];
}
diff --git a/modules/files.nix b/modules/files.nix
new file mode 100644
index 0000000..3f4d37e
--- /dev/null
+++ b/modules/files.nix
@@ -0,0 +1,33 @@
+# ABOUTME: The file manager stack: thunar, its plugins, thumbnails and mounting.
+# ABOUTME: The services half used to live in modules/services.nix.
+
+{ pkgs, ... }:
+
+{
+ # Counterpart to thunar, thunar-volman, thunar-archive-plugin, tumbler and
+ # ffmpegthumbnailer in packages/official.txt.
+ programs.thunar = {
+ enable = true;
+ plugins = with pkgs; [ thunar-volman thunar-archive-plugin ];
+ };
+ services.tumbler.enable = true;
+
+ # Mounting and device discovery for thunar's side pane.
+ services.udisks2.enable = true;
+ services.gvfs.enable = true;
+
+ environment.systemPackages = with pkgs; [
+ # ffmpegthumbnailer is what tumbler uses for video thumbnails.
+ gvfs
+ udisks2
+ ntfs3g
+ ffmpegthumbnailer
+ # Backend for thunar-archive-plugin above; without it its menu entries do
+ # nothing. The plugin looks up a .tap helper by the handler's desktop id
+ # inside its own store path, and it ships one for file-roller. xarchiver
+ # carries its .tap in its own package, where the plugin never looks.
+ file-roller
+ zip
+ unzip
+ ];
+}
diff --git a/modules/lock.nix b/modules/lock.nix
new file mode 100644
index 0000000..6bb8bad
--- /dev/null
+++ b/modules/lock.nix
@@ -0,0 +1,65 @@
+# ABOUTME: The session lock: gtklock, its two modules and the PAM stack it needs.
+# ABOUTME: Its config is generated because moonarch's copy names FHS paths.
+
+{ pkgs, lib, moonarchSrc, ... }:
+
+let
+ wallpaperBlur = "${moonarchSrc}/defaults/backgrounds/wallpaper-blur.jpg";
+
+ # gtklock reads $XDG_CONFIG_HOME/gtklock/config.ini and gtklock/config.ini
+ # under every $XDG_CONFIG_DIRS entry, so /etc/xdg is enough and nothing has
+ # to be seeded into the home directory — the one thing swaylock could not do,
+ # its sysconfdir being inside the store.
+ #
+ # The background stays the pre-blurred image. gtklock cannot blur either:
+ # GTK3 has no `filter` CSS property at all (checked against
+ # libgtk-3.so.0.2420.32 — it knows `background-image` and `box-shadow`, and
+ # neither `filter` nor `backdrop-filter`), and it takes no screenshot of the
+ # session, so there is nothing to blur at lock time in the first place.
+ #
+ # No gtk-theme key: gtklock is GTK3 and reads the session theme out of
+ # /etc/xdg/gtk-3.0/settings.ini, so the theme name keeps living in one place.
+ #
+ # Modules are named by absolute store path. `-m` resolves a bare name against
+ # the directory compiled into gtklock, which under Nix is gtklock's own store
+ # path and contains no module at all. powerbar needs no configuration: its
+ # built-in commands are `systemctl reboot|-i poweroff|suspend`, and systemd
+ # is on the PATH the stasis unit sets in modules/services.nix.
+ gtklockConfig = pkgs.writeText "gtklock-config.ini" ''
+ [main]
+ background=${wallpaperBlur}
+ modules=${moduleList}
+ '';
+
+ moduleList = lib.concatStringsSep ";" [
+ "${pkgs.gtklock-userinfo-module}/lib/gtklock/userinfo-module.so"
+ "${pkgs.gtklock-powerbar-module}/lib/gtklock/powerbar-module.so"
+ ];
+in
+{
+ # moonarch ships defaults/xdg/gtklock/config.ini, but it is not deployed
+ # here: it names /usr/share/moonarch for the background and resolves the
+ # modules by bare name against /usr/lib/gtklock. Both are store paths under
+ # Nix, so the file is generated above rather than copied — the same reason
+ # PipeWire's config takes its own route.
+ environment.etc."xdg/gtklock/config.ini".source = gtklockConfig;
+
+ # Session lock. The power menu is a Quickshell popout, not a package.
+ # gtklock speaks the same ext-session-lock-v1 as swaylock, through
+ # gtk-session-lock, so a crash still leaves the screen locked. The modules
+ # are the reason for it: avatar and clock, and the power buttons. They are
+ # installed for the sake of `nix-store --query --roots`; gtklock itself
+ # loads them from the store paths named in the config above. playerctl is
+ # deliberately absent — it needs playerctld, which nothing here runs.
+ environment.systemPackages = with pkgs; [
+ gtklock
+ gtklock-userinfo-module
+ gtklock-powerbar-module
+ ];
+
+ # Without this gtklock takes the session lock and then rejects every
+ # password: there would be no /etc/pam.d/gtklock, and /etc/pam.d/other denies
+ # auth outright. The generated stack is `auth sufficient pam_unix.so`, the
+ # same one swaylock had.
+ security.pam.services.gtklock = { };
+}
diff --git a/modules/network.nix b/modules/network.nix
new file mode 100644
index 0000000..f8c0e5e
--- /dev/null
+++ b/modules/network.nix
@@ -0,0 +1,27 @@
+# ABOUTME: NetworkManager's OpenVPN half, Bluetooth, and the firewall.
+# ABOUTME: The service declarations used to sit in modules/services.nix.
+
+{ pkgs, ... }:
+
+{
+ # networking.networkmanager.plugins defaults to an empty list, and without the
+ # OpenVPN plugin NetworkManager cannot create such a connection at all —
+ # neither nmcli, nmtui nor nm-applet offer the type. Counterpart to
+ # networkmanager-openvpn in packages/official.txt. nm-applet, installed below,
+ # is what hands the keyring password to the connection.
+ networking.networkmanager.plugins = [ pkgs.networkmanager-openvpn ];
+
+ hardware.bluetooth.enable = true;
+ services.blueman.enable = true;
+
+ # Replaces the ufw calls in post-install.sh.
+ networking.firewall.enable = true;
+
+ environment.systemPackages = with pkgs; [
+ # openvpn is the binary the NetworkManager plugin above drives; it also
+ # stands on its own for a config file.
+ networkmanagerapplet
+ blueman
+ openvpn
+ ];
+}
diff --git a/modules/printing.nix b/modules/printing.nix
new file mode 100644
index 0000000..c0241ed
--- /dev/null
+++ b/modules/printing.nix
@@ -0,0 +1,24 @@
+# ABOUTME: Driverless printing over IPP, and the Avahi half that discovers it.
+# ABOUTME: No printer and no driver package is declared here.
+
+{ ... }:
+
+{
+ # Driverless only, and no printer is declared here. Asked over IPP, the
+ # Samsung M267x 287x answers with image/urf and application/PCLm among its
+ # document formats, so CUPS builds the queue from the capabilities the device
+ # reports about itself — no PPD, no driver package, nothing about anyone's
+ # network in this repository. Any AirPrint-capable printer works the same way,
+ # including ones this machine has never seen.
+ #
+ # Avahi is the discovery half: without it the printer is not found and its
+ # .local name does not resolve. openFirewall defaults to true and opens
+ # UDP 5353 for mDNS.
+ services.printing.enable = true;
+
+ services.avahi = {
+ enable = true;
+ nssmdns4 = true;
+ openFirewall = true;
+ };
+}
diff --git a/modules/services.nix b/modules/services.nix
index 34c854d..7e67834 100644
--- a/modules/services.nix
+++ b/modules/services.nix
@@ -157,21 +157,8 @@ let
};
in
{
- # --- Audio ---
- services.pipewire = {
- enable = true;
- alsa.enable = true;
- alsa.support32Bit = true;
- pulse.enable = true;
- jack.enable = true;
- };
-
# --- Hardware ---
- hardware.bluetooth.enable = true;
- services.blueman.enable = true;
- services.udisks2.enable = true;
services.upower.enable = true;
- services.gvfs.enable = true;
services.fwupd.enable = true;
# power-profiles-daemon over auto-cpufreq: on amd-pstate in active mode the
# platform decides the frequency from the min/max and EPP registers alone, so
@@ -179,9 +166,6 @@ in
# where neither exists it falls back to a driver that touches no hardware.
services.power-profiles-daemon.enable = true;
- # --- Firewall, replaces the ufw calls in post-install.sh ---
- networking.firewall.enable = true;
-
# --- System services from defaults/etc/systemd/system/ ---
#
# Rebuilt rather than copied, same reason as the user units: the originals
diff --git a/modules/shell.nix b/modules/shell.nix
new file mode 100644
index 0000000..5861741
--- /dev/null
+++ b/modules/shell.nix
@@ -0,0 +1,80 @@
+# ABOUTME: zsh as the interactive shell, sourcing moonarch's shared zshrc.
+# ABOUTME: Carries the command line tools that zshrc and the helper scripts call.
+
+{ pkgs, moonarchSrc, ... }:
+
+let
+ xdg = "${moonarchSrc}/defaults/xdg";
+
+ # zsh/newuser runs its setup wizard on every interactive login for as long as
+ # the user has none of .zshenv, .zprofile, .zshrc or .zlogin — the whole
+ # configuration living in /etc/zshrc does not count. Seeded below to stop
+ # that, and deliberately without configuration: moonarch's own copy of this
+ # file sources the shared zshrc, which here would run it a second time, on
+ # top of the one /etc/zshrc already did.
+ userZshrc = pkgs.writeText "zshrc" ''
+ # The moonarch zsh configuration is system-wide, in /etc/zshrc.
+ #
+ # This file keeps zsh from running zsh-newuser-install and is the place for
+ # your own additions: zsh reads it after /etc/zshrc, so it wins over it.
+ '';
+in
+{
+ environment.etc."xdg/fastfetch/config.jsonc".source = "${xdg}/fastfetch/config.jsonc";
+
+ programs.zsh.enable = true;
+
+ # moonarch's shared zshrc, which it installs as /etc/zsh/zshrc.moonarch. zsh reads
+ # /etc/zshrc before ~/.zshrc, so a user file still wins over it.
+ programs.zsh.interactiveShellInit = ''
+ source ${moonarchSrc}/defaults/shell/zshrc
+ '';
+
+ # /etc/zshrc places promptInit after interactiveShellInit, and its default
+ # ends in `prompt suse`, which assigns PS1 — the same parameter as PROMPT.
+ # Left at the default it overwrites the prompt the sourced zshrc just set.
+ # The zshrc owns the prompt, so nothing else has to run here.
+ programs.zsh.promptInit = "";
+
+ # The zshrc sources both plugins from /usr/share/zsh/plugins; the lookups are
+ # guarded and silently find nothing here. These modules source the same
+ # plugins from the store instead, with the strategy the zshrc sets.
+ programs.zsh.syntaxHighlighting.enable = true;
+ programs.zsh.autosuggestions = {
+ enable = true;
+ strategy = [ "history" "completion" ];
+ };
+
+ # environment.shellAliases carries ls, ll and l as defaults, and /etc/zshrc
+ # renders them after interactiveShellInit, so they shadow the eza aliases of
+ # the sourced zshrc. null drops an alias. Scoped to zsh: bash keeps the
+ # coreutils ls, which is the only thing it has.
+ programs.zsh.shellAliases = {
+ ls = null;
+ ll = null;
+ l = null;
+ };
+
+ # CLI tools referenced by zshrc and the helper scripts
+ environment.systemPackages = with pkgs; [
+ bat
+ btop
+ eza
+ fastfetch
+ fd
+ fzf
+ jq
+ lazygit
+ ripgrep
+ neovim
+ git
+ ];
+
+ # zsh/newuser runs its setup wizard on every interactive login for as long as
+ # the user has none of .zshenv, .zprofile, .zshrc or .zlogin, so the seed
+ # above has to reach the home directory. 'C' copies once and never
+ # overwrites, so user edits survive.
+ systemd.user.tmpfiles.rules = [
+ "C %h/.zshrc 0644 - - - ${userZshrc}"
+ ];
+}
diff --git a/modules/theme.nix b/modules/theme.nix
new file mode 100644
index 0000000..d9d9d27
--- /dev/null
+++ b/modules/theme.nix
@@ -0,0 +1,152 @@
+# ABOUTME: GTK, Qt, Kvantum, cursors and fonts — the look, in one place.
+# ABOUTME: Same theme derivations the greeter uses, so the two cannot drift apart.
+
+{ pkgs, moonarchSrc, moonarchPkgs, moonarchThemes, ... }:
+
+let
+ xdg = "${moonarchSrc}/defaults/xdg";
+
+ # The GTK theme name, in the one place both consumers below read it from.
+ gtkTheme = "catppuccin-mocha-lavender-standard";
+
+ # Both distributions ship the same Catppuccin GTK theme, 1.0.3, and still
+ # disagree on its name — permanently, not as a migration. Arch installs the
+ # upstream release zips through catppuccin-gtk-theme-mocha, whose directories
+ # carry the full build name with the `+default` tweak marker; nixpkgs builds
+ # the same source with no tweaks and so names it without that suffix. Neither
+ # side can adopt the other's name without going out of its way.
+ #
+ # The rewrite therefore has to stay. It matters because GTK3 reads
+ # settings.ini directly — the dconf entry further down only reaches
+ # applications that ask GSettings, which on Wayland the GTK3 ones do not — so
+ # a name that does not resolve puts every GTK3 application on Adwaita, with
+ # no error anywhere.
+ gtkSettings = version: pkgs.runCommand "gtk${version}-settings.ini" { } ''
+ substitute ${xdg}/gtk-${version}/settings.ini "$out" \
+ --replace-fail catppuccin-mocha-lavender-standard+default ${gtkTheme}
+ '';
+
+ # The theme deployed Kvantum/kvantum.kvconfig selects. It goes into
+ # systemPackages: nixpkgs patches Kvantum to search XDG_DATA_DIRS for themes
+ # (tsujan/Kvantum 01989083, in the package's patches list), and
+ # /run/current-system/sw/share is on that path, so a theme package is found
+ # system-wide with nothing seeded into the home directory.
+ #
+ # Verified under strace: a Qt6 client opens the theme's .kvconfig straight
+ # out of the package's store path. The patch only takes in the Qt6 build
+ # (NixOS/nixpkgs#355277) — a Qt5 client, here only VLC, falls back to the
+ # default style.
+ kvantumTheme = pkgs.catppuccin-kvantum.override {
+ accent = "lavender";
+ variant = "mocha";
+ };
+in
+{
+ environment.etc = {
+ "xdg/Kvantum/kvantum.kvconfig".source = "${xdg}/Kvantum/kvantum.kvconfig";
+ "xdg/qt6ct/qt6ct.conf".source = "${xdg}/qt6ct/qt6ct.conf";
+ "xdg/gtk-3.0/settings.ini".source = gtkSettings "3.0";
+ "xdg/gtk-4.0/settings.ini".source = gtkSettings "4.0";
+ };
+
+ # Generic-family defaults. Same content as
+ # defaults/etc/fonts/conf.d/65-moonarch-fonts.conf, but routed through the
+ # fontconfig module: /etc/fonts/conf.d/ is owned by NixOS and rejects
+ # environment.etc entries.
+ # localConf is written to /etc/fonts/local.conf verbatim, so it has to be a
+ # complete XML document — not just the inner elements.
+ fonts.fontconfig.localConf = ''
+
+
+
+ Moonarch generic-family defaults
+
+
+ sans-serif
+
+ UbuntuSans Nerd Font
+
+
+
+ monospace
+
+ UbuntuSansMono Nerd Font
+
+
+
+ '';
+
+ # Set systemwide instead of through the gsettings calls in post-install.sh.
+ programs.dconf = {
+ enable = true;
+ profiles.user.databases = [{
+ settings."org/gnome/desktop/interface" = {
+ gtk-theme = gtkTheme;
+ icon-theme = "Colloid-Grey-Catppuccin-Dark";
+ color-scheme = "prefer-dark";
+ font-name = "UbuntuSans Nerd Font 11";
+ };
+ }];
+ };
+
+ # Qt applications are wrapped, so each one looks for plugins in its own
+ # closure and never finds a style installed alongside it — which is why VLC
+ # kept coming up in the stock Qt style. Enabling this puts the profile's Qt5
+ # and Qt6 plugin directories on QT_PLUGIN_PATH, which the wrappers append to.
+ # platformTheme and style stay null: it would add packages that are already
+ # listed below, and niri's environment block sets both variables already.
+ qt.enable = true;
+
+ fonts.packages = with pkgs; [
+ nerd-fonts.ubuntu-sans
+ openmoji-color
+ ];
+
+ # niri sets both in its own environment block, but that only reaches what niri
+ # itself spawns. Anything under the systemd user manager — quickshell, and so
+ # everything started from its launcher — never sees them, and Qt applications
+ # come up unstyled. Set here they travel through /etc/profile into
+ # niri-session's import-environment, and from there into the user manager.
+ environment.sessionVariables.QT_QPA_PLATFORMTHEME = "qt6ct";
+ environment.sessionVariables.QT_STYLE_OVERRIDE = "kvantum";
+
+ # Same gap, cursors: niri's cursor block reaches xwayland-satellite, so X11
+ # clients have a themed pointer available, but everything under the user
+ # manager arrives with XCURSOR_PATH alone and has no theme name to look up.
+ # The name matches modules/greetd.nix, so greeter and session agree.
+ environment.sessionVariables.XCURSOR_THEME = "Sweet-cursors";
+ environment.sessionVariables.XCURSOR_SIZE = "24";
+
+ # systemPackages alone does not put the theme on XDG_DATA_DIRS: the system
+ # path links only the /share subdirectories named here, and Kvantum's is not
+ # among the defaults.
+ environment.pathsToLink = [ "/share/Kvantum" ];
+
+ systemd.user.tmpfiles.rules = [
+ # Steam gets XCURSOR_THEME from the block above — verified in its own
+ # environment — and still drew the core pointer, so parts of it resolve the
+ # theme literally named 'default' rather than the variable. Xcursor looks
+ # that name up along XCURSOR_PATH like any other, and ~/.icons is first,
+ # so pointing it at the theme covers those lookups too. 'L+' replaces the
+ # link on every activation, unlike the 'C' rules elsewhere, which keeps it
+ # following the theme instead of freezing at today's one.
+ "L+ %h/.icons/default - - - - /run/current-system/sw/share/icons/Sweet-cursors"
+ ];
+
+ environment.systemPackages = with pkgs; [
+ moonarchPkgs.sweet-cursors
+
+ # Theming. Same derivations the greeter uses, see flake.nix.
+ moonarchThemes.gtk
+ moonarchThemes.icons
+ libsForQt5.qtstyleplugin-kvantum
+ qt6Packages.qtstyleplugin-kvantum
+ kvantumTheme
+ qt6Packages.qt6ct
+ qt6.qt5compat
+
+ # GTK settings editor; it writes the same settings.ini this module deploys.
+ nwg-look
+ ];
+}
diff --git a/pkgs/betterfox.nix b/pkgs/betterfox.nix
new file mode 100644
index 0000000..d625406
--- /dev/null
+++ b/pkgs/betterfox.nix
@@ -0,0 +1,32 @@
+# ABOUTME: Nix build recipe for the Betterfox preference set.
+# ABOUTME: Ships the curated user.js, plus an autoconfig copy for the Firefox wrapper.
+
+{ lib, stdenvNoCC, src, version }:
+
+stdenvNoCC.mkDerivation {
+ pname = "betterfox";
+ inherit version src;
+
+ dontBuild = true;
+ dontConfigure = true;
+
+ # The same list twice. user.js is the upstream file, for dropping into a
+ # profile by hand. user.cfg is what programs.firefox.autoConfigFiles takes:
+ # the wrapper concatenates it into mozilla.cfg, where user_pref does not
+ # exist — defaultPref is its counterpart there and leaves every value
+ # changeable in about:config. Same shape as nixpkgs' arkenfox-userjs.
+ installPhase = ''
+ runHook preInstall
+ install -Dm644 user.js "$out/user.js"
+ install -Dm644 user.js "$out/user.cfg"
+ substituteInPlace "$out/user.cfg" --replace-fail user_pref defaultPref
+ runHook postInstall
+ '';
+
+ meta = {
+ description = "Curated Firefox preference set";
+ homepage = "https://github.com/yokoffing/Betterfox";
+ license = lib.licenses.mit;
+ platforms = lib.platforms.all;
+ };
+}