acli auth login opened Chromium. buildFHSEnv binds only an allowlist of /etc entries into its bubblewrap sandbox, and /etc/xdg is not on it, so the mimeapps.list from xdg.mime is invisible there and xdg-open takes the first http handler in mimeinfo.cache. The same holds for Steam and AppImages. xdg.portal.xdgOpenUsePortal hands the URL to the OpenURI portal, which resolves it on the host (NixOS/nixpkgs#160923). acli and kubectl on the ThinkPad. Flake inputs updated.
80 KiB
Decisions
2026-09-30 – xdg-open goes through the portal, so FHS sandboxes get Firefox
- Who: Dominik, ClaudeCode
- Why:
acli auth loginopened Chromium. acli is abuildFHSEnvwrapper, which binds only an allowlist of/etcentries into its bubblewrap sandbox;/etc/xdgis not on it, so themimeapps.listfromxdg.mimeis invisible there andxdg-mimefalls back tomimeinfo.cache, which yieldschromium-browser.desktop. Reproduced on the host by querying withXDG_CONFIG_DIRSpointed away from/etc/xdg. Affects every FHS env (Steam, AppImages), not only acli. - Tradeoffs: Considered binding
/etc/xdginto acli viaextraBwrapArgs(fixes one package only), linking a second copy ofmimeapps.listinto/run/current-system/sw/etc/xdg(a self-made workaround), stripping Chromium's http claim (fixes the browser only), andxdg-settings(manual, per user and machine). All rejected in favour of the option nixpkgs added for exactly this (NixOS/nixpkgs#160923). - How:
xdg.portal.xdgOpenUsePortal = trueinmodules/desktop.nix. It setsNIXOS_XDG_OPEN_USE_PORTAL=1; nixpkgs' patchedxdg-openthen hands the URL to the OpenURI portal, which resolves it on the host with the host'smimeapps.list. Verified: the thinkpad system builds and carries the variable. The runtime check (acli auth loginopening Firefox) needs a switch and a new login, since it is a session variable.
2026-09-28 – Nothing waits for a routable address at boot
- Who: Dominik, ClaudeCode
- Why: The ThinkPad took 54.7s to boot. Two of those posts are not the
configuration's to shorten: 14.7s of firmware, and 14.2s inside the initrd
where the machine waits for the LUKS passphrase to be typed — the prompt
stands at 4.97s after kernel start,
systemd-cryptsetupruns at 19.20s. What is left is 14.4s of userspace, andNetworkManager-wait-online.serviceholds 10.6s of it: it keepsnetwork-online.targetuntil a connection is routable, andmulti-user.targetsits behind that target throughdocker.service. Of the two units that pull the target in,docker.serviceis the only active one;fwupd-refresh.serviceis not running. - Tradeoffs: The first approach was to detach
docker.servicefrom the target instead, which leaves the wait in place for anything else that may want it. It needslib.mkForceon bothafterandwants, so the upstream unit's dependency list is copied into this flake and drifts silently on the next nixpkgs bump. Disabling the wait is one line and nothing to keep in sync. What it costs: docker starts without a guarantee that an address is already routable. - How:
systemd.services.NetworkManager-wait-online.enable = falseinmodules/network.nix, with the measurement in the comment beside it. Plymouth was not touched:plymouth-quit-wait.serviceshows 2.4s insystemd-analyze blamebut does not appear in the critical chain.
2026-09-28 – Images open in the viewer, not in the browser
- Who: Dominik, ClaudeCode
- Why: Images opened in Chromium although
viewniorhas been installed since3e40a70. Nomimeapps.listnamedimage/*at all, soxdg-opentook the first candidate frommimeinfo.cache—chromium-browser.desktop, ahead ofviewnior.desktopby name. - Tradeoffs: Declared in
modules/desktop.nixnext to the viewer, not beside the Firefox types inmodules/browsers.nix; both modules add to the same attribute set and the keys do not overlap. - How:
xdg.mime.defaultApplicationsfor gif, jpeg, png and webp onviewnior.desktop, plusaddedAssociationsfor webp, whichviewnior.desktopdoes not declare, andprograms.gdk-pixbuf.modulePackages = [ pkgs.webp-pixbuf-loader ], without which gdk-pixbuf cannot decode it.
2026-09-28 – The splash in our own colours, and what Plymouth would not give
-
Who: Dominik, ClaudeCode
-
Why: The first boot on the new theme showed four things: the keyboard layout label at the password prompt was too large, it should sit further down, the entry field was pixelated, and the palette was the upstream blue rather than the Catppuccin lavender the rest of the system runs on.
-
Tradeoffs: Three of the four are files the theme ships, so they are drawn here and committed — not generated in the package recipe. That keeps the recipe untouched and puts every asset where it can be looked at. The cost is that
keymap-render.pngnow has to match Plymouth's offset table by hand; the README says so, and the band from the original upload had failed exactly that way.The fourth is not a file. Plymouth places the layout indicator at
y = dialog_bottom + …(two-step/plugin.c) and has no alignment key for it, so it cannot be moved without moving the entry field with it. Making the images taller and putting their content at the bottom would work in principle, but reaching the screen's lower edge from the middle needs over a thousand pixels of empty image. Dropped. -
How:
keymap-render.pngredrawn at 2304×24 — one layout,de, at the offset the table gives it (2253, 51 wide) — against 26537×50 upstream;keyboard.pngscaled to match.entry.pngredrawn at its original 305×75 with smooth edges. Both frame series, 81animation-*and 51progress-*, moved to lavender with-modulate 130,125,105, which lands on#b0c1eeagainst the target#b4befe. Background and progress bar in the descriptor follow#1e1e2e,#313244,#b4befe.
2026-09-28 – A stop timeout for stasis
- Who: Dominik, ClaudeCode
- Why: Every reboot hung for a minute and a half. The journal names it:
stasis.service: State 'stop-sigterm' timed out. Killing., 88 seconds after the last system service went down, and niri received its own SIGTERM only once stasis had been killed — the whole user manager was waiting on that one unit. Stopped by hand withsystemctl --user stop stasisit exits in zero seconds, so the unit is not at fault; as part of the session teardown it does not answer. - Tradeoffs: The cause sits in the program. stasis registers SIGTERM
through
signal_hookand honours it in normal operation, so fixing it means changing somebody else's Rust daemon and carrying a patch. A short stop timeout treats the symptom instead — acceptable here because what stasis holds is an idle timer, and nothing is lost when it goes down hard. The alternative, leaving the default 90 seconds, costs that time on every reboot. - How:
TimeoutStopSec = 5in the unit inmodules/services.nix, with the measurement recorded beside it so the number is not mistaken for a guess.
2026-09-28 – A splash theme of our own, and a flake input after all
-
Who: Dominik, ClaudeCode
-
Why: The Catppuccin splash showed the NixOS logo and the loading animation and nothing else, the two of them touching. The cause is not the theme's configuration:
nixos/modules/system/boot/plymouth.nixlinksboot.plymouth.logointo every theme directory namedcatppuccin-<flavour>asheader-image.png, and thetwo-stepmodule that theme runs on has alignment keys forwatermark,title,dialogand the progress bar — none for the header image, whose distance from the animation the module decides on its own. The theme itself contributes only the throbber frames: it setsWatermarkVerticalAlignmentbut ships nowatermark.png, so those lines take no effect. -
Tradeoffs: (1) A padded logo — the same PNG with transparent space below it. One line, but the spacing then lives inside an image file and imagemagick joins the build. (2) A derived theme under another name, with the logo going in as
watermark.png. Uses the slot Plymouth intends for a logo, but builds a package only to sidestep a loop over theme names. (3) Patching that loop in nixpkgs, where the cause sits, which needs an overridden module and a changed theme value on top. (4) A theme of our own, which removes the question entirely: the loop matches only Catppuccin and Spinner names, so every other theme is left alone. Chosen.This reverses the reasoning of the 2026-09-25 entry below, which took nixpkgs'
catppuccin-plymouthprecisely to avoid a new input. The theme wanted here is not in nixpkgs, and carrying it as a source repo is the same shape the flake already uses for Betterfox and the cursors. -
How:
gitea.moonarch.de/nevaforget/moonarch-plymouth-theme, derived from tobilinuxer's Linux Distro Logos - macOS Boot Style (opendesktop.org/p/2106821) under the X11 license, which permits the change; the copyright notice travels with it inLICENSE. Renamed tomoonarchin the descriptor's filename, itsName=entry and itsImageDir, the images untouched. It runs ontwo-stepas well, but brings a progress bar and a 138-frame animation and no watermark, so nothing is drawn that it did not bring itself.pkgs/moonarch-plymouth.nixrewrites the descriptor's FHSImageDirto the store path, the way nixpkgs' own theme packages do.
2026-09-25 – amdgpu back in the ThinkPad's initrd, for Plymouth's sake
- Who: Dominik, ClaudeCode
- Why: With the splash enabled, Plymouth came up on eDP-1 in the mode the
firmware had left behind, stretched over a panel with four times the pixels.
The boot log has both halves:
simpledrmowns the framebuffer while the initrd runs, and amdgpu only takes eDP-1 twenty-five seconds later, on the way into the real root. Plymouth draws in the initrd, so it never saw the panel's own resolution. The cause washardware.amdgpu.initrd.enable = false, set on this host to keep the LUKS passphrase prompt in the firmware's large text mode rather than at 3840x2400, where the 8x16 console font is a fifth as tall. - Tradeoffs: The two wants are opposed — the console text is readable in the
firmware mode, Plymouth is only sharp in the native one. Plymouth draws the
passphrase prompt itself and scales with the panel, so as long as it runs the
text mode buys nothing; with
plymouth.enable=0on the kernel line, or a Plymouth that fails, the tiny prompt is back. That case is rare and reachable, the splash is every boot. - How: The line is gone rather than set to
true: nixos-hardware's amd profile already setslib.mkDefault true, and a build without it comes out at the same store path. A comment in its place records what dropping it costs, so the reasoning is not lost with the line.
2026-09-25 – A boot splash, themed from nixpkgs rather than a new flake input
- Who: Dominik, ClaudeCode
- Why: The boot was the one part of the system still showing stock text, the LUKS passphrase prompt among it. With systemd in the initrd, Plymouth draws that prompt itself, so the splash covers the whole way from firmware to the greeter rather than stopping at the disk.
- Tradeoffs: (1) catppuccin/nix offers
catppuccin.plymouth.enable, but this flake does not have that input, and taking it on for a boot splash means carrying a second theming source next to the packages already used here. (2) nixpkgs'catppuccin-plymouthis the same upstream theme as a plain package, with avariantargument and no new input. Chosen. (3) The cost either way is that boot messages are hidden, so a stalled boot shows a still image first;plymouth.enable=0on the kernel line takes it back without rebuilding, and the previous generation is in the boot menu. - How:
boot.plymouthinmodules/theme.nix, beside the other theme packages, on flavour Mocha like them.quietwas already on the kernel command line and the module addssplashon its own, so the kernel parameters needed no change.
2026-09-25 – Kvantum's theme where Qt5 looks for it, and one platform theme for both Qt versions
-
Who: Dominik, ClaudeCode
-
Why: VLC came up in a style that is not Catppuccin. Two causes, measured at the running process with
straceandQT_DEBUG_PLUGINS.The style plugin was never the problem: Kvantum's Qt5 build loads, and it reads
/etc/xdg/Kvantum/kvantum.kvconfig, so it knows the theme's name. It then asks for the theme in four places —~/.config/Kvantum,~/.themes,~/.local/share/themesand its own store path — and in none of them is it installed./run/current-system/sw/share/Kvantum, where it is, is never looked at: the patch that teaches KvantumXDG_DATA_DIRSno longer reaches the Qt5 build after an upstream change (NixOS/nixpkgs#355277, still open, from tsujan/Kvantum@6a4053a). Not finding its theme, Kvantum draws its built-in default.Behind that, a second one:
QT_QPA_PLATFORMTHEMEwasqt6ct, and Qt5 reads that variable too. A Qt5 client finds only the Qt6 plugin, cannot load it, and ends up with no platform theme at all — no icon theme, no fonts, stock dialogs. This was never specific to VLC; it held for every Qt5 client. -
Tradeoffs: (1) Overriding the Qt5 plugin package so its own
share/Kvantumcarries the theme would keep everything system-wide and leave no state in the home directory, but it is a construction nobody else runs, and it rebuilds the plugin locally. (2) A wrapper around VLC settinggtk3as its platform theme would need no new package and no new config, but it fixes one application where the gap is general. (3) The published solutions for the Qt5 case — the nixpkgs issue's thread and catppuccin/nix — all place the theme under$XDG_CONFIG_HOME/Kvantum, and pair it with the qt5ct/qt6ct combination that nixpkgs itself ships asqt.platformTheme = "qt5ct". Chosen for being the trodden path. -
How: A
systemd.user.tmpfilesrule links~/.config/Kvantum/catppuccin-mocha-lavenderto the theme in the system profile,L+like the cursor rule beside it so it follows the profile instead of freezing at one store path.QT_QPA_PLATFORMTHEMEis nowqt5ct, which each Qt version answers with the plugin built for it;libsForQt5.qt5ctis installed alongsideqt6ct, and/etc/xdg/qt5ct/qt5ct.confcarries the same style, icon theme and dialog setting asqt6ct.conf.
2026-09-25 – One portal config, and the file dialog on the GTK backend
-
Who: Dominik, ClaudeCode
-
Why: Firefox's save dialog was drawn in a style that belongs to nothing else on the system — Adwaita buttons and colours — while thunar, the file manager, was themed. Two causes, one behind the other.
The dialog is not Firefox's own: under Wayland it is requested through
org.freedesktop.impl.portal.FileChooser, and that interface was unassigned, so thedefault=gnome;gtkline handed it toxdg-desktop-portal-gnome. That backend delegates to nautilus, which nixpkgs' niri module registers with D-Bus although the binary is on noPATH. Nautilus is GTK4 and libadwaita, and libadwaita draws its own style without readinggtk-theme-namefromsettings.ini— so no amount of theming inmodules/theme.nixcould have reached it.Behind that: the portal assignment was written in two places.
desktop.nixdeployed moonarch'sniri-portals.confto/etc/xdg-desktop-portal/, andprograms.nirihad NixOS write its own fromxdg.portal.config.nirito/etc/xdg/xdg-desktop-portal/.portals.conf(5)searches every directory in$XDG_CONFIG_DIRS—/etc/xdgamong them — before the build-time sysconfdir/etc, so the module's file outranked the deployed one and the file this repo maintained decided nothing. Both carried the same four keys; moonarch's stated a fifth,Inhibit=none;, whose effect from the lower-ranked file was never established. -
Tradeoffs: (1) Installing nautilus would have made the dialog a deliberate choice rather than an accident, but it stays unthemeable for the reason above, and it is a second file manager next to thunar. (2) Keeping moonarch's file as the source and generating
xdg.portal.config.nirifrom it would hold the two distros together, at the price of parsing an ini file in Nix — more code than the assignment itself, for five keys of which four already match what the module sets. (3) Leaving the deployed copy in place and only fixing the dialog was rejected: it would have left a file in the tree that looks like the place to change the portal assignment and is not. -
How: The
environment.etcentry forniri-portals.confis gone, and with it theetcbinding in theletblock, which had no other reader. The whole assignment now comes fromxdg.portal.config.niri: four keys fromprograms.niri,FileChooser = "gtk"from settingprograms.niri.useNautilus = false, andorg.freedesktop.impl.portal.Inhibit = "none"restated indesktop.nixwith moonarch's reasoning carried over in the comment. The option rather than a config key for the file chooser: only the option also drops the nautilus D-Bus registration, and setting the key in both places would collide.Verified on the thinkpad host: the generated
/etc/xdg/xdg-desktop-portal/niri-portals.confcarries all six lines, no second portal config is deployed anywhere, nautilus is out ofservices.dbus.packages, and the system builds.xdg-desktop-portal-gtkbuilds the dialog with GTK3 and reads/etc/xdg/gtk-3.0/settings.ini, the same file thunar follows. Screencasting keeps the gnome backend: niri servesorg.gnome.Mutter.ScreenCastitself and that backend is the only caller, which is why it stays inextraPortals. Assigning the file chooser to gtk is also what the niri wiki recommends for a setup without nautilus.
2026-09-24 – One topic per module, split out of desktop.nix
-
Who: Dominik, ClaudeCode
-
Why:
desktop.nixhad 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.pipewirelived inservices.nixwhile its RNNoiseconfigPackagessat indesktop.nix;services.gvfsandservices.udisks2there, 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
optionsblock so a host could switch it off.disk.nixanduser.nixhave 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 whatnix-starter-configsreservesmodules/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.nixto join its packages.desktop.nixends at 194 lines with the compositor, the XDG config deployment, portals and the packages that belong to no topic;services.nixat 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 thesystem-pathhash, 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-closuresempty, 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:
policiesandpreferenceson the existingprograms.firefoxblock inmodules/desktop.nix. The theme needs two entries rather than one: ExtensionSettings installs it,extensions.activeThemeIDselects it, and without the second Firefox stays on the default look. The add-on ID sits in aletbinding 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. The language pack turned out to have the very same split, which only showed on the machine:languagePacksinstalls it and the policy reports it as active, but the interface stayed English, and Firefox does not fall back toLANGeither.RequestedLocales = [ "de" ]is what switches to it.The first run on the machine turned up three more, all of them settings the browser presents as switches: address autofill, payment autofill, and the three Firefox Suggest switches. The autofill pair are plain preferences. Suggest needed the
FirefoxSuggestpolicy instead, for a reason worth keeping: its preferences already ship asfalseand the profile sets no user value, yet the switches were on — something turns them on at runtime, and a preference set as a default does not hold against that.ImproveSuggest, the data collection behind Suggest, is stated rather than left to follow from the other two, so it cannot return if Suggest is ever switched back on.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
Lockedleaves 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.desktopwas the only entry in the system path claimingx-scheme-handler/http, soxdg-openhad 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
chromiumcarries 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, stockchromiumwould have been the answer. (2) Which types get the default — the fourfirefox.desktopdeclares, no more:text/html,application/xhtml+xmland the two scheme handlers. A wildcard would claim types neither browser should own. - How:
ungoogled-chromiuminenvironment.systemPackagesandxdg.mime.defaultApplicationsinmodules/desktop.nix, which writes/etc/xdg/mimeapps.list. Verified on the built system: bothchromium-browser.desktopandfirefox.desktopclaim http, and the generatedmimeapps.listpoints 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 hasprograms.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-userjsis in nixpkgs, which would need no input at all, but it sits at144.0while the Firefox from this flake is156.0. Betterfox tracks releases more closely (154.0) and is not in nixpkgs, so it comes in as an input withflake = false, the same shape asmoonarchandsweet-cursors. That waynix flake updatemoves 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 curateduser.jsfrom the repository root, not the modules it is assembled from (Securefox.jsalone 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 theinstallPhase. (3) How the preferences arrive — without home-manager there is no declarative way to place auser.jsin a profile.programs.firefox.autoConfigFilesis the way in: the wrapper concatenates each file intomozilla.cfg, which Firefox reads before a profile exists. AutoConfig has nouser_pref, so the package installs the list twice and rewrites the copy todefaultPref— the shape nixpkgs'arkenfox-userjsuses, and it leaves every value changeable inabout:config. - How:
betterfoxas a flake input,pkgs/betterfox.nixbuilding the two files, andprograms.firefoxinmodules/desktop.nixwithlanguagePacks = [ "de" ]and theuser.cfginautoConfigFiles.librewolfis 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.cfgcarries 115defaultPreflines and nouser_pref, the language pack policy nameslangpack-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
- Why: The LUKS passphrase prompt is unreadable on the 3840x2400 14" panel. The same machine on Arch had no such problem, which located the difference:
hardware.amdgpu.initrd.enabledefaults to true in the nixos-hardware profile, soamdgpusits inboot.initrd.kernelModulesand switches the panel to its native mode before stage 1 asks for the passphrase. The 8x16 console font is then a fifth of the height it has on the firmware framebuffer. Arch does not put the GPU driver in the initramfs by default. - Tradeoffs: Earlier attempts treated the symptom and were wrong about the cause.
console.font = "latarcyrheb-sun32"doubles the glyph height but still draws into a 3840x2400 framebuffer, andvideo=eDP-1:1920x1200fights the driver instead of deferring it — both were dropped once the Arch comparison named the real difference. Keeping the driver out of the initrd costs a late mode switch on the way into the real root; it does not affect the graphical session, which loads amdgpu normally. - How:
hosts/thinkpad/default.nixsetshardware.amdgpu.initrd.enable = false, andboot.loader.systemd-boot.consoleMode = "0"covers the boot menu, which has the same readability problem for a different reason — systemd-boot draws through UEFI text output, so the firmware mode is what it gets. Verified thatboot.initrd.kernelModulesno longer listsamdgpuand that the toplevel builds. The readability itself is only provable by booting the machine.
2026-09-23 – ThinkPad host reviewed before its first install
- Who: Dominik, ClaudeCode
- Why: The ThinkPad moves from Arch to NixOS, and its host file had not been built since the desktop went through the same review. Three things were wrong for a first install: (1) it still carried the snapper configs the desktop dropped in
a04fa91—modules/disk.nixcreates no.snapshotssubvolumes, so snapper-timeline would fail every hour and never take a snapshot; (2) the account is listed in thedockergroup, butvirtualisation.docker.enablewas off, so neither dockerd nor the group existed, and the work projects on this machine run their services in containers; (3)stateVersionwas26.05while nixpkgs is26.11, the release the machine is actually first installed with. - Tradeoffs: snapper was removed rather than repaired, for the reasons in
a04fa91: generations cover rollbacks of/, and/homesnapshots on the same disk do not survive the disk.plugdevstays inextraGroupsalthough no such group exists — it is a leftover of an old corsair udev rule, harmless, and not part of this change. - How:
hosts/thinkpad/default.nixdropsservices.snapper, enablesvirtualisation.docker, setsstateVersion = "26.11". The stale snapper mentions inmodules/user.nix,modules/disk.nixandREADME.mdare rewritten.git-fireflyjoins Zed'sauto_install_extensions, matching the extensions installed on the Arch side. Verified withnix buildof the thinkpad and desktop toplevels.
2026-09-14 – moonarch-lock follows moonarch; the wrapper needs a PATH of its own
- Who: Dominik, ClaudeCode
- Why: moonarch replaced the direct
gtklockcalls withdefaults/bin/moonarch-lock, a wrapper that passes the focused output as-Mso the lock screen's input form lands on the monitor the user is at (rationale:moonarch/DECISIONS.md2026-09-14). Both files carrying those calls —stasis/stasis.runeandPowerPopout.qml— come frommoonarchSrcand are deployed here unchanged, so the new command arrived on NixOS immediately while the script did not:pkgs/moonarch-scripts.nixinstalls an explicit whitelist, andmoonarch-lockwas not on it. The failure would have been silent in exactly the waymodules/services.nixalready warns about — stasis logs the action as started, sees the immediate exit and stops there, the same shape as the swaylock outage. - Tradeoffs: (1) Wrapped, unlike the other seven scripts. They reach their tools through whatever starts them;
moonarch-lockhas two callers (the stasis unit and Quickshell) and must not depend on either one's PATH. Its three dependencies are not equal in weight: a missinggtklockmeans no lock screen at all, a missingniriorjqonly the wrong monitor, since the script falls back to plain gtklock. That asymmetry is what makesmakeWrapperworth breaking the pattern for. (2)gtklockleaves the stasis unit'spath. The wrapper carries it now, and naming it twice would leave two places to keep in step. (3) Nosubstituteonstasis.rune. The 2026-09-11 rewrite ofswaylock→gtklockis gone — moonarch names the same command this repo wants, so the file is copied as it is. That stays true only as long as the wrapper keeps its name on both distributions. - How:
pkgs/moonarch-scripts.nixgainsmoonarch-lockinscripts,makeWrapperinnativeBuildInputs, and awrapProgramprefixinggtklock niri jqonto PATH; its arguments resolve through the existingcallPackage, soflake.nixis unchanged.modules/services.nix:gtklockin the stasis unit'spathgives way tomoonarchPkgs.moonarch-scripts.README.md's deployment table no longer claims a rewrite forstasis.rune.
2026-09-11 – gtklock replaces swaylock; GTK theme moves from Frappé to Mocha
- Who: Dominik, ClaudeCode
- Why: Two findings from looking at whether gtklock was worth it. (1) The GTK layer
was never on Mocha.
colloid-gtk-themewithtweaks = [ "catppuccin" ]produces Frappé with a grey accent — base#292c3c, selection#ccd0da, error#ea999c;#1e1e2e,#b4befeand#cba6f7appear nowhere in itsgtk.css. Kvantum (catppuccin-mocha-lavender), the swaylock ring and Zed were all on Mocha, so GTK was the one layer out of step, and nobody had noticed because the two dark bases are close. (2) The old objection to gtklock is gone: 4.0.0 linkslibgtk-session-lock.so.0, so it speaks ext-session-lock-v1 like swaylock and the compositor still holds the lock if it crashes. The layer-shell versions that made a crash unlock the screen were ≤2.x. - Tradeoffs: (1) Locker — swaylock's case was "one protocol, no toolkit, least
likely to break" (2026-08-14). gtklock adds GTK3 and two
dlopened modules to the path that decides whether the machine unlocks. Accepted for what the modules give: avatar, clock and power buttons, none of which swaylock can do at all. Crash safety is unchanged, which was the part that actually mattered. (2) Blur — no gain. GTK3 3.24.52 has nofilterCSS property (verified againstlibgtk-3.so.0.2420.32: it knowsbackground-imageandbox-shadow, notfilterorbackdrop-filter), and gtklock takes no screenshot, so there is nothing to blur at lock time. The pre-blurred wallpaper stays, exactly as under swaylock. (3) GTK theme —catppuccin-gtkis Colloid: it carriesvinceliuice/Colloid-gtk-themeas a git submodule and only recolors it. Identical widget shapes, so the switch is a recolor with no layout risk. Its icon counterpart does not exist, andColloid-Grey-Catppuccin-Darkstays — an icon set has no background or accent surface to clash. (4) Where the gtklock config lives —/etc/xdg/gtklock/config.ini, generated rather than copied, because both the background and the module paths are store paths. gtklock searches$XDG_CONFIG_DIRS, which swaylock did not, so the$HOMEseed that swaylock needed is gone. (5) Module paths — absolute store paths in the config, not/run/current-system/sw/lib.-mresolves a bare name against the directory compiled into gtklock, which under Nix is gtklock's own store path and holds no module. (6) stasis — its config is moonarch's and namesswaylockin four places. Rewritten here withsubstitute, the same treatment waypaper gets, until moonarch switches too. The Quickshell power popout spawns the locker itself (PowerPopout.qml:56) and needed the same, except that its config is deployed as a whole directory, so the tree is copied and the one file patched. Both take the command from onelockCommandbinding, the popout's argv form derived from it, so the locker is named once in this repo rather than three times. (7) Why both spawn it directly — there is no Wayland protocol for "lock the session": ext-session-lock-v1 is how a client becomes the lock, not a request a third party can send, and no portal covers it either. logind'sSession.Lockonly emits a signal someone has to be subscribed to, and nothing here is — niri ownsorg.freedesktop.ScreenSaverwithInhibit/UnInhibitand noLock, and stasis ignores the signal on purpose (services/dbus.rs: "login1's Lock/Unlock signals are requests, not completed state changes").xdg-screensaver lockis no answer either: it is an X11 script whose generic path runsxautolock -locknow. (8)stasis trigger lock-screenfor the popout — rejected. It would name the locker in one place, but the Lock button would then fail silently whenever stasis is down, which is the worst direction for a button whose only job is security. The reason to want it turned out not to exist: the plan continues after a manual lock regardless, because stasis takes the state from logind'sLockedHint(LockSource::LockedHint,core/events.rs) and does not need to have started the locker. Verified by locking by hand and watching DPMS fire on schedule. (9)-dis not quite-f— swaylock forks once the screen is locked, gtklock forks inmain()before it has read its config (source.c:284), so a returnedgtklock -dmeans "forked", not "locked". Nothing chains off the exit here; it would matter if something ever wanted to suspend only after the screen is covered. - How:
flake.nixthemePackages.gtk→catppuccin-gtk.override { variant = "mocha"; accents = [ "lavender" ]; }; theme namecatppuccin-mocha-lavender-standardin the newgtkThemebinding, inprograms.dconfand inmodules/greetd.nix. NewgtkSettingsrewrites the Colloid name out of moonarch'sgtk-3.0/gtk-4.0settings.ini— necessary because GTK3 reads those files directly and the dconf entry does not reach it on Wayland; without it every GTK3 application would have fallen back to Adwaita.swaylockConfig→gtklockConfig(writeText, deployed to/etc/xdg/gtklock/config.ini) plusstasisConfig;swaylockout ofsystemPackages,security.pam.servicesand the tmpfiles seeds,gtklock+gtklock-userinfo-module+gtklock-powerbar-modulein.modules/services.nix: the stasis unit'spathcarriesgtklock.playerctl-moduledeliberately not installed — it needs playerctld, which nothing here runs. - Remaining exception: the userinfo module reads the avatar through AccountsService,
which resolves to
~/.face. With no such file it warns once and falls back toavatar-default-symbolicfrom the icon theme. There is no option for the path; drop an image at~/.faceto have one. - Open on the moonarch side:
defaults/etc/swaylock/, theswaylockentry inpackages/official.txt,check_swaylock_configinmoonarch-doctorand the fourswaylocklines instasis.rune. Once those move,stasisConfighere can go.
2026-09-08 – nwg-displays replaces wdisplays; kanshi dropped for niri's own outputs
- Who: Dominik, ClaudeCode
- Why: Monitors could not be set persistently at all. moonarch pairs
wdisplays-persistent(AUR forksfs-pra/wdisplays, see moonarchREADME.md:25and itsDECISIONS.md:178) with kanshi: the fork saves straight into the kanshi config. The port took the nixpkgswdisplaysinstead — upstream, with no save function — and never recorded the difference. The result was a GUI that applies a layout and forgets it, an always-empty~/.config/kanshi/config, and akanshi.servicewhoseConditionPathExiststherefore skipped it on every boot. - Tradeoffs: Packaging the fork would match moonarch exactly and keeps kanshi's
per-combination profiles, but it is unmaintained and would be ours to carry.
nwg-displaysis in nixpkgs, actively developed, and supports niri as a first-class target alongside sway and Hyprland; it writes~/.config/niri/monitor.kdland adds theincludefor it. That makes kanshi redundant — niri appliesoutputblocks itself, on hotplug and with live reload — so it goes, together with the hardening set it had just been given. What is given up is kanshi's profiles per attached combination: niri has one setting per output. Fixed monitors do not care; several docks with different arrangements would, and then kanshi has to come back. The seed is the same mechanism moonarch chose for stasis (itsDECISIONS.md, 2026-04-22): niri has no config hierarchy either, so system-default-plus-user-override cannot be modelled — the home file is seeded once and then belongs to the user. - How:
modules/desktop.nix—wdisplays→nwg-displays,kanshidropped fromenvironment.systemPackages, newniriUserConfigin theletblock holdinginclude "/etc/niri/config.kdl", seeded through the existingsystemd.user.tmpfiles.rulesblock withCso later edits survive. The stale comment about the undeployed kanshi config is gone fromenvironment.etc, the paragraph inREADME.mdrewritten.modules/services.nix—systemd.user.services.kanshiremoved andwlsunset'safterreduced tographical-session.target. Verified: niri 26.04 does loadinclude(an include pointing at a deliberately broken file makesniri validatefail on that file, so it is parsed, not ignored);nwg-displays0.4.4 logsRunning on niriand sees both outputs; without~/.config/niri/it exits 0 with no window, and it createsmonitor.kdlbut noconfig.kdl— which is what the seed is for.
2026-09-07 – A timer reports the age of the nixpkgs lock
- Who: Dominik, ClaudeCode
- Why: nixpkgs carries no patch backports, it bumps versions, so the age of
flake.lockis the exposure to known holes. Rebuilding often hides that:nixos-rebuild switchagainst an unchanged lock installs nothing new, so a machine built daily can still run months-old packages. The date in the lock is the signal, not the date of the last build. - Tradeoffs:
system.autoUpgradewould drop the manual step, but nixpkgs tracks unstable and an update can fail to build — the rebuild belongs in front of someone. A CVE scanner (vulnix,vulnxscan) was weighed and dropped: on a single desktop it reports what the lock date already says, and the update, not the report, is what closes a hole. The notification carries an "Update now" action:notify-send -Aimplies--wait, so the process lives while the toast stands, and nothing has to bound that — the notification is on screen for exactly as long, and a second one a day later would repeat itself. Text alone was tried first and discarded: a command in a toast can be neither clicked nor copied, only retyped. The terminal comes fromxdg-terminal-execrather than a named emulator, so switching terminals is one entry inxdg-terminals.listinstead of an edit here. - How:
modules/services.nixbuildsmoonix-flake-agewithwriteShellApplicationand drives it from a user timer,OnStartupSec=5minandOnUnitActiveSec=1d— relative to the user manager rather than the calendar, so a machine that is off at midnight does not report into a session nobody sees. The flake is found through the/etc/nixos/flake.nixsymlink, so a machine built straight from the remote reference has no lock and exits silently..nodes[.nodes.root.inputs.nixpkgs]resolves the input indirection: the node is namednixpkgs_2as soon as a dependency locks a nixpkgs of its own, and reading.nodes.nixpkgswould report the date of a transitive input.
2026-08-24 – power-profiles-daemon replaces auto-cpufreq
- Who: Dominik, ClaudeCode
- Why: auto-cpufreq arrived a day earlier as the backend of the quickshell CpuGov
widget, not because it fits the hardware. Upstream calls it a laptop tool and points
AMD users at
amd_pstate.enable=0, while the kernel documents that amd-pstate in active mode ignores the requested performance level and reads only the minimum, maximum and EPP registers — so the governor auto-cpufreq switches decides nothing. - Tradeoffs: ppd writes those registers (
ppd-driver-amd-pstate.c) and the ACPIplatform_profile, and its polkit policy allows the active session to switch profiles without pkexec. Given up is the load-dependent automatic: ppd has three fixed profiles and leaves reacting to the firmware. The module lives inmodules/services.nix, so the testvm gets it too — there ppd finds no driver and falls back toppd-driver-placeholder.c, which touches no hardware, whereas auto-cpufreq was a daemon polling sysfs that a virtio guest does not have. The NixOS module asserts against auto-cpufreq, so a leftover elsewhere fails evaluation rather than running two daemons at one knob. - How:
services.power-profiles-daemon.enableinmodules/services.nix. The widget itself lives in the moonarch tree this flake deploys from and switched with it.
2026-08-20 – Input denoising was deployed to a path PipeWire never reads
- Who: Dominik, ClaudeCode
- Why: The Arch machine runs RNNoise input denoising, and the config for it
lives in the shared tree as
defaults/xdg/pipewire/pipewire.conf.d/99-input-denoising.conf. It was inmodules/desktop.nixtoo, in the generic sweep to/etc/xdg/— and inert there. PipeWire resolves its config directory from$PIPEWIRE_CONFIG_DIR, then$XDG_CONFIG_HOME/pipewireor$HOME/.config/pipewire, then the compiled-in/etc/pipewire;XDG_CONFIG_DIRSappears nowhere insrc/pipewire/conf.c. On Arch the file works becausepost-install.shseeds it into the home directory. Same class as the swaylock config on 2026-08-18: a file installed to a plausible path that the program does not read. - Tradeoffs: (1) Which option — the module accepts both
services.pipewire.extraConfig.pipewire.<name>(Nix attributes, rendered to JSON) andservices.pipewire.configPackages(a package carryingshare/pipewire/pipewire.conf.d/*.conf); both end up in the same merged directory (pipewire.nix:59-75). The NixOS wiki usesextraConfigfor its examples, which are written by hand and have no file behind them. Taking that route here would mean translating the shared file into Nix and maintaining the VAD values in two places, soconfigPackageswas chosen — which is also what the module's own option documentation shows for a conf.d file (pipewire.nix:258-279). (2) The plugin path — the file names the plugin aslibrnnoise_ladspa, resolved throughLADSPA_PATH, which is/usr/lib/ladspaon Arch and does not exist here. There is no LADSPA counterpart toservices.pipewire.extraLv2Packages, so the absolute store path is substituted into the file, the same treatment the wallpaper paths get. - How:
denoisingConfigviarunCommand+substitute, handed toservices.pipewire.configPackages. Thexdg/pipewire/entry inenvironment.etcis removed rather than kept for layout parity — after the swaylock case, an inert copy is a liability. Verified by building the package: the conf.d file is there, the plugin line points atrnnoise-plugin-1.10/lib/ladspa/librnnoise_ladspa.so, and that file exists. - Note: the filter chain shows up as a separate "Noise Canceling source" input. It does not replace the raw microphone; applications have to select it.
2026-08-20 – Steam, Bitwig and rtkit belong to the desktop alone
- Who: Dominik, ClaudeCode
- Why: Both programs are wanted on the desktop and on no other machine, so
they go into
hosts/desktop/default.nixrather than the shared module every host imports. - Tradeoffs: (1) Steam —
pkgs.steaminsystemPackagesis the wrong handle: the module wraps the binary in an FHS environment and turns onhardware.graphics.enable32Bititself (nixos/modules/programs/steam.nix:215-218), which the bare package would leave off. (2) Bitwig — thebitwig-studioattribute currently resolves tobitwig-studio6-6.0.11;bitwig-studio5(5.3.13) exists as a separate attribute if a downgrade is ever needed. Dominik chose 6. (3) Realtime —security.rtkit.enablewas off. The rlimits the PipeWire module installs (rtprio95,nice-19,memlock) apply to members of thepipewiregroup, and the account is innetworkmanager wheel video input plugdev dockeronly, so a DAW had no path to realtime scheduling at all. Enabled on this host, where the DAW is. - How:
programs.steam.enable,security.rtkit.enableandenvironment.systemPackages = [ pkgs.bitwig-studio ]in the host file. Verified by evaluating both hosts: ondesktopall three are set and 32-bit graphics came along; onthinkpadSteam and rtkit stayfalse.
2026-08-20 – The browser is LibreWolf from nixpkgs, not a self-packaged Waterfox
- Who: Dominik, ClaudeCode
- Why: A rebuilt desktop had no browser at all — nothing in the package set
provided one, while Arch installs
waterfox-bin. nixpkgs has nowaterfoxattribute (checked against the pinned revision, withfirefoxresolving in the same package set as a control), so matching Arch meant packaging it here. - Tradeoffs: (1) Waterfox from elsewhere — the one community flake,
sammypanda/nixos-waterfox, is a single commit whose README says "Currently broken?". Flathub'snet.waterfox.waterfoxtracks releases itself but carries no verified badge (the listing reads "Medium Risk"), needs the Flatpak stack, and a NixOS Discourse thread reports crashes and system freezes with that exact package. Both rejected. (2) Waterfox packaged here — this was built and it worked: the upstream tarball plusautoPatchelfHook,waterfox --versionreporting 6.6.17, no missing library, and a SHA-512 identical to the one the Arch package pins. It was then discarded on the maintenance argument: version and hash are bumped by hand,nix flake updatedoes not move a plain tarball, and nothing signals a missed release. Forstasisorsweet-cursorsthat is irrelevant; for the most exposed program on the machine it means silently running a browser with known holes. A version-check script was offered as a mitigation and not taken. (3) What replaces it —librewolfis in nixpkgs, so its bumps arrive withnix flake update, and it is fully substitutable: 179.6 MiB fetched, nothing built locally (librewolf-binwould build six derivations and was not taken). Firefox was the other candidate; LibreWolf is closer to what Waterfox is for. The price is stricter defaults —resistFingerprinting, clearing on shutdown, restricted WebGL — which have to be relaxed by hand where they get in the way. - How:
librewolfinenvironment.systemPackagesinmodules/desktop.nix.pkgs/waterfox.nixis deleted rather than left lying around unused. The browser therefore differs from Arch, which keepswaterfox-bin.
2026-08-20 – Printing is driverless, VPN gets its plugin, VLC replaces mpv
- Who: Dominik, ClaudeCode
- Why: Comparing the package set against the Arch lists turned up gaps that
were never decided, only missing: no media player, no VPN plugin, no printing.
networking.networkmanager.pluginswas at its default[ ], which means NetworkManager cannot create an OpenVPN connection at all — the type is not offered by nmcli, nmtui or nm-applet — while Arch installsnetworkmanager-openvpn. Printing was absent on both sides. - Tradeoffs: (1) Printing — the Arch host drives the Samsung M267x 287x
the old way: a
socket://192.168.1.165queue with the ULD PPD. Reproducing that here would have meant an unfree driver package plus the printer's address in version control, and Dominik ruled the printer out of the repository. Asked over IPP, the device answers withimage/urfandapplication/PCLmamong its document formats and IPP 2.0 — so driverless printing works and CUPS builds the queue from what the printer reports about itself.hardware.printers. ensurePrintersandsamsung-unified-linux-driver(which does carry a matchingM267x.ppd) are therefore both unused. The price is a dependency on mDNS discovery, and Avahi has to run for it; the gain is that any AirPrint-capable printer works, not just this one. (2) Media player — mpv is what Arch installs, with two AUR scripts; Dominik chose VLC instead, so this is deliberately not an alignment. (3) Where it lives —modules/desktop.nix, which every host imports (flake.nix:95), rather than per host: none of it is machine specific. - How:
services.printing.enable,services.avahiwithnssmdns4(the part that makes.localresolve) andopenFirewall, which opens UDP 5353.networking.networkmanager.plugins = [ pkgs.networkmanager-openvpn ]plus theopenvpnbinary, andvlc, inenvironment.systemPackages. Verified by evaluating bothdesktopandthinkpad: printing and Avahi on, 5353 inallowedUDPPorts, the plugin and both packages present. - Still missing against the Arch lists: no browser at all — Arch installs
waterfox-bin, which nixpkgs does not have. Also absent:viewnior,blueberry,auto-cpufreq,snapper/snap-pac,plocate,nmap,sshfs,smbclientand walker with the elephant providers.ufwis deliberately not missing: NixOS' own firewall is enabled on every host with no port open.
2026-08-20 – The desktop declares its real disks
- Who: Dominik, ClaudeCode
- Why:
hosts/desktopstill carried the twoSET-VIA-disko-install--disk-*placeholders from the entry below, so the file did not say which disks the machine has. The ThinkPad names its disk, and nothing about the desktop's two is secret: read off the machine, the system disk is the 512Gnvme-XPG_GAMMIX_S11_Pro_2K322LAES7JGand/homegoes on the 1Tnvme-CT1000P1SSD8_2030E2BAC109. - Tradeoffs: The invalid paths were a safety net — a forgotten
--diskflag failed the install instead of erasing something. That net only ever caught the case of a missing flag, and it paid for it by making a rebuild of an installed desktop impossible to evaluate honestly and by leaving the hardware undocumented. With correct paths a forgotten flag installs to the intended disk, which is the outcome the flag would have asked for anyway. A wrong flag was never caught by either variant. - How: The paths in
hosts/desktop/default.nix, with the comment now saying where they came from and thatdisko-install --disk main|homestill overrides them. Note that the 1T currently holds an ext4Datapartition — installing erases it. README's "Adding another machine" step 4 no longer advertises the placeholders.
2026-08-20 – A placeholder ~/.zshrc stops the zsh new-user wizard
- Who: Dominik, ClaudeCode
- Why:
zsh-newuser-installcame up on every interactive login on the test VM. Per zsh's own documentation thezsh/newusermodule runs it for as long as the user has none of.zshenv,.zprofile,.zshrcor.zloginin$HOME/$ZDOTDIR(zshall, "New User Installation", and the function description inzshcontrib); it exits at once for root, which is why only the user account saw it. Here the whole configuration is/etc/zshrc, which is not a personal file, so the condition never cleared. Independent of the ordering fixes below — the trigger is the set of files in the home directory, not the content of/etc/zshrc. The NixOSprograms.zshmodule does not cover the case. - Tradeoffs: (1) Which side — disabling the
zsh/newusermodule system-wide would need no home state, but the documented off switch is on the administrator's zsh build, not a NixOS option, and it would also take the wizard away from a user who wants it. Seeding the file uses the mechanism zsh itself names and reuses thesystemd.user.tmpfilesseed already in the module for waypaper and swaylock. (2) What is in the file — on Arch this file readssource /etc/zsh/zshrc.moonarch, and copying that here would source the shared zshrc twice, once from/etc/zshrcand once from the home file: duplicate aliases, a secondcompinit, the plugins loaded twice and$HOME/.local/binprepended toPATHtwice. The seeded file therefore carries comments only. It doubles as the documented place for the user's own additions, which zsh reads after/etc/zshrc. - How:
modules/desktop.nix—userZshrcviapkgs.writeText, seeded asC %h/.zshrc 0644 - - - ${userZshrc}next to the two existing rules.Ccopies once and never overwrites, so later edits to the file survive a rebuild. Verified by evaluatingsystemd.user.tmpfiles.rulesfor thetestvmhost.
2026-08-20 – The sourced zshrc is defended against the /etc/zshrc defaults
- Who: Dominik, ClaudeCode
- Why:
programs.zsh.interactiveShellInitsources the shareddefaults/shell/zshrc, but the shell on the test VM did not look like the one on Arch. The file is not at fault — the pinnedmoonarchinput is the same revision as the working tree and the file is byte-identical. The generated/etc/zshrcis:nixos/modules/programs/zsh/zsh.nixplacesinteractiveShellInitat line 277, thenenvironment.shellAliasesat 285, thenpromptInitat 288. Three defaults therefore land after our file and win. (1)promptInitdefaults toautoload -U promptinit && promptinit && prompt suse, andprompt suseassignsPS1— the same parameter asPROMPT— so the Catppuccin prompt was replaced by the SUSE one. (2)environment.shellAliasescarriesls,llandlasmkDefault(nixos/modules/config/shells-environment.nix:235), shadowing the eza aliases. (3) Unrelated to ordering: the zshrc sources zsh-syntax-highlighting and zsh-autosuggestions from/usr/share/zsh/plugins, the path of the Arch packages. The lookups are[[ -f ]]-guarded, so both plugins were silently absent. - Tradeoffs: (1) Where the prompt is owned — moving the prompt into
promptInitwould place it in the slot NixOS intends for it, at the price of splitting one shared file across two options and diverging from Arch. SettingpromptInit = ""keeps the zshrc the single owner. (2) Alias removal — redefiningls/ll/lin Nix would duplicate the eza commands in a second place, where they can drift from the zshrc.nullremoves the alias (zsh.nix:20filters null values out) and leaves the definition in one file. Scoped toprograms.zsh.shellAliasesrather thanenvironment.shellAliases, so bash keeps the coreutilsls— it has no eza aliases to fall back on. (3) Plugins — a/usr/share/zsh/pluginssymlink farm via tmpfiles would make the existing lookups succeed and need no Nix-side option, but it rebuilds an FHS path for a file that the NixOS modules already know how to source. The modules were chosen, as with the polkit exception the other way around (2026-08-07). - How:
modules/desktop.nix—programs.zsh.promptInit = "",programs.zsh.shellAliaseswithls/ll/lset tonull,programs.zsh.syntaxHighlighting.enableandprograms.zsh.autosuggestions.enablewithstrategy = [ "history" "completion" ], the value the zshrc sets on Arch. Verified by evaluatingenvironment.etc.zshrc.textfor thetestvmhost before and after: the prompt and alias blocks are now empty, and both plugins are sourced from the store. - Remaining exception: the zshrc's
orphansanduninstallaliases callpacman. They are dead on NixOS but harmless, and rewriting them per distro would fork the shared file. - Not changed:
compinitruns twice — once fromenableGlobalCompInit(zsh.nix:266), once in the zshrc. Duplicated work, no defect, left alone.
2026-08-20 – Installing from an own ISO, one command
- Who: Maintainer, ClaudeCode
- Why: The documented install was a five-line
nix runinvocation typed at the guest console, reading the configuration from Gitea — so only pushed states were installable, and a typo in a long command cost the whole run. - Tradeoffs:
nixos-anywhereand an SSH-driven install were ruled out: the machine is installed in front of it, not over the network. A script or flake app in the repository still has to be fetched before it can run, which is the problem it was meant to solve. Baking the target closure into the image would make the install work offline, at four to six gigabytes per rebuild — not taken. - How:
hosts/installerbuilds an ISO (nix build .#installer-iso) that carries this flake as/etc/moonix, disko and one wrapper. The guest runsmoonix-install <host>: partition and format per the host file, install, then clone the repository into the account's~/flakes/moonix, hand it to the account and point/etc/nixos/flake.nixat it. The image is built from the local tree, so unpushed changes are installable. Network is still needed — the packages come from the binary cache. - Also: disko aborts the whole format script when the two passphrase
entries differ, because
exit 1sits inside a function called from theuntilloop meant to repeat the prompt (share/disko/lib/types/luks.nix:151). The installer uses a disko patched toreturn 1there, so a typo asks again instead of restarting the install.
2026-08-20 – The checkout on a machine lives in ~/flakes/moonix
- Who: Maintainer, ClaudeCode
- Why:
disko-installleaves no checkout behind, and the README documented one that was invented rather than derived: a clone in the home root plus a symlink, justified with reasons that did not hold. The configuration has to be editable and committable on the machine it describes, with the account's identity on the commits and the account's key on the push. - Tradeoffs:
/etc/nixosisnixos-rebuild's default path, but a repository there belongs to root: commits would be root's and pushing would need root's key. Handing the directory to the account withinstall -d -ofixes that by making a system directory user-writable, which is a workaround rather than a layout./opt/moonixwould mirrormoonarch, which archinstall clones to/opt/moonarch, but that copy is root-owned and never committed from — these files are.~/Reposis the scratch area for development checkouts on the workstation, not a permanent location.~/.config/flakesis configuration space for programs reading it on this machine; the repository is not read as configuration, it is the source a system is built from. - How: The checkout is
~/flakes/moonix, owned by the account, and/etc/nixos/flake.nixis a symlink to itsflake.nix.man 8 nixos-rebuilddocuments exactly this: the file "may be a symlink to a flake.nix in an actual flake; thus /etc/nixos need not be a flake", and--flake"defaults to the directory containing the target of the symlink".sudo nixos-rebuild switchtherefore needs no arguments and picks the host by hostname. Theflakesdirectory is plural on purpose:moonarchleaving itsflake = falsestate, dev flakes for the compiled projects and devShells would live beside it. A machine that is never edited locally keeps no checkout and builds from the Gitea reference instead, which is whatdisko-installdid.
2026-08-20 – Desktop host, /home on a second disk
- Who: Maintainer, ClaudeCode
- Why: The desktop has a 512G system disk and a 1T disk, and
/homebelongs on the large one.modules/disk.nixonly knew a single disk, so the layout had nowhere to put the second. - Tradeoffs: A single btrfs spanning both disks would be one filesystem and
one LUKS container, but it ties the two together: losing either loses the
pool, and the system disk stops being restorable on its own. Mounting the 1T
somewhere under
/mntand leaving/homesmall keeps the module untouched, at the price of configs and data living apart and snapper needing two configs with different meanings. A second disk carrying/homeas its own btrfs keeps both disks independent and leaves the existing subvolume names alone. - How:
moonarch.disk.home.enableadds a second GPT with one partition, wrapped in LUKScrypted-homewhenencryptis set, holding the/homesubvolume. The main disk drops/homein that case, so the subvolume exists exactly once. No second ESP: the firmware boots from the main disk. Both disks want the same passphrase — systemd's initrd caches the first one and retries it on the second container, so the boot prompts once even though formatting asks four times.hosts/desktopdeclares deliberately invalid device paths, becausedisko-install --disk main|homeoverrides them and a forgotten flag should fail rather than erase the wrong disk. Without anixos-hardwareprofile the host setsenableRedistributableFirmwareandhardware.cpu.amd.updateMicrocodeitself, and forcesservices.fwupdoff — desktop mainboard vendors mostly do not publish to the LVFS.
2026-08-20 – ThinkPad: redistributable firmware and the fingerprint reader
- Who: Maintainer, ClaudeCode
- Why:
hardware.enableRedistributableFirmwarewas never set. Thenixos-hardwareprofile does not set it either —common/cpu/amdreads it (hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware), so with the option off the machine ran on the microcode in its BIOS and without the firmware the WLAN card and amdgpu load. - Tradeoffs: The firmware is redistributable but not free, so enabling it is a deliberate step rather than a default. Declining it means no microcode updates on a CPU whose errata are fixed exactly there.
- How:
hardware.enableRedistributableFirmware = truein the host file, which the profile'smkDefaultthen turns into a microcode update.services.fprintd.enablealongside it — the NixOS module wirespam_fprintdinto the PAM stacks it owns; a finger is enrolled withfprintd-enrollafter installing.
2026-08-19 – Initial password in the host file, account name as an option
- Who: Maintainer, ClaudeCode
- Why: An install that leaves the account without a password produces a
system whose lock screen never opens — swaylock authenticates through
pam_unix. The documented remedy,
nixos-enter --root /mnt -c passwd, is the path that fails afterdisko-installwithchmod: changing permission of '/mnt/dev': Operation not permitted(disko issue #638, closed as not planned). Alongside that, the host files carried a real name in five places. - Tradeoffs:
initialPasswordputs a plaintext password in the repository. Weighed againsthashedPasswordFile, which keeps the secret out of the repo but needs the file on the target disk before the first boot — either throughdisko-install --extra-filesor acpinto the still-mounted/mnt. Both are extra steps in exchange for protecting a value that is meant to be replaced on first login anyway. The 2026-08-11 decision to keep any password out of the repository is therefore reversed for the initial one. - How:
initialPassword = "moonarch"in both hosts,mutableUsersleft at its default sopasswdoverrides it permanently. Newmodules/user.nixdeclaresmoonarch.userwithout a default, because the name belongs to the machine;users.users, the autologininitial_session.userand snapper'sALLOW_USERSall read it, so the login name exists once per host instead of five times. README loses thenixos-enterstep and the stale "Known gaps" section —Super+Creaches the Quickshell clipboard popout, which is wired up and whosecliphistbackend is installed.
2026-08-14 – moonset and moonlock dropped; the power menu is a Quickshell popout
- Who: Maintainer, ClaudeCode
- Why: The power menu became
PowerPopout.qmlin the moonarch Quickshell config (seemoonarch/DECISIONS.md, same date), and moonset was the last thing invoking moonlock. Both packages therefore leave this flake with nothing to replace them: the popout ships inside the config tree that is already deployed. - Tradeoffs: The flake keeps the ability to package a moonarch program —
stasisandsweet-cursorsstill use that path — so nothing was generalised away. What is lost is the pinning discipline for two programs that no longer exist here; the tags inflake.nixwere the only record of which version was deployed, and that record now lives in the retired repos' own history. - How: Inputs
moonlockandmoonsetremoved,moonarchPackagesentries with them,pkgs/moonlock.nixandpkgs/moonset.nixdeleted,flake.lockregenerated.modules/desktop.nixdrops both packages fromsystemPackages, the generatedenvironment.etc."moonlock/moonlock.toml"and"moonset/moonset.toml", and the moonlock PAM stack — swaylock's comes fromprograms.niri.enable.
2026-08-14 – regreet and swaylock from nixpkgs, autologin after LUKS
- Who: Maintainer, ClaudeCode
- Why: moongreet and moonlock were dropped as self-maintained projects; the
reasoning is in
moonarch/DECISIONS.mdfor the same date. Here the change is mostly subtraction: two flake inputs, two build recipes and a generated config disappear in favour of a nixpkgs module and a nixpkgs package. Since LUKS already asks for a passphrase at boot, the greeter was also asking for a second one for no gain, so autologin replaces it on both hosts. - Tradeoffs: (1) Greeter host — the regreet module sets
services.greetd.settings.default_session.commandonly asmkDefault(dbus-run-session cage -- regreet), so the existing unconditional niri line keeps winning. Taking cage would have meant one Wayland compositor fewer in the greeter path, butniri-greeter.kdlis wherelayout "de"andnumlocklive, and nothing in nixpkgs 26.11 exportsXKB_DEFAULT_LAYOUTfromservices.xserver.xkb.layout— verified. A greeter on a US layout rejects every password with an affected character, so niri stays and Arch and NixOS keep sharing that one file. (2) regreet.toml — the module writes/etc/greetd/regreet.tomlitself, so the committed file fromdefaults/etc/greetd/is deliberately not deployed verbatim; declaring the same path inenvironment.etcwould collide. The values are duplicated into module options, which is the price for the wallpaper store path. (3) swaylock config — nixpkgs passes only--prefixto meson, so swaylock'sSYSCONFDIRsits inside the store and/etc/swaylock/configis never read. The config is therefore seeded into$HOMEwith the sameCtmpfiles rule waypaper uses, which also means later user edits survive but updates do not propagate. (4) Colloid overrides — desktop and greeter need the same derivation, and two module files cannot share alet. Moved intoflake.nixand passed as_module.args.moonarchThemesrather than duplicating the override, which would drift. - How:
moongreetinput,moonarchPackages.moongreetandpkgs/moongreet.nixremoved.modules/greetd.nixnow configuresservices.displayManager.regreet(background, GTK, commands, theme, icon theme, cursor theme, font,extraCsspointing at the CSS in the moonarch input) and keeps the niridefault_session.modules/desktop.nixgainsswaylock, the seeded~/.config/swaylock/configand — moved out ofgreetd.nix, next to the package it belongs to — the moonlock PAM stack.initial_sessionis set per host, because the accounts are named differently. - Remaining exception:
moonlockstays packaged and installed.moonset/src/power.rsspawns it by name and has nolock_commandoption, so its Lock action would otherwise fail. Idle and lid-close already go through swaylock. Two lockers coexist until moonset is released with that call changed; then the input, the recipe, the PAM stack andenvironment.etc."moonlock/moonlock.toml"all go. - Consequence: With autologin, the greeter no longer forces an account to
have a password, but swaylock still needs one —
passwdafter installing stays mandatory, andhosts/testvmhas no password declared at all.
2026-08-11 – Disk layout via disko after all, as a shared module
- Who: Maintainer, ClaudeCode
- Why: The Calamares route decided below turned out to be six manual steps,
one of which — copying
hardware-configuration.nixinto the repo — fails silently unless the file is alsogit added, because Nix only sees tracked files in a git repository. It also does not scale: there will be more machines than this one, and each would repeat the whole procedure. - Tradeoffs: The argument against disko was that no target machine existed and device paths would have to be guessed. That has expired. Keeping Calamares would avoid writing a layout module, but leaves partitioning, encryption and subvolumes outside version control, where they cannot be reviewed or repeated.
- How:
modules/disk.nixdefinesmoonarch.disk.{enable,device,encrypt, espSize,swapSize}and buildsdisko.devicesfrom it, so a new machine is a device path rather than a new layout. Installation is a singledisko-install --flake … --disk main <device>from the ISO.hardware-configuration.nixis not needed:nvme,ahci,sd_modand the USB HID modules are in the initrd by default viaboot.initrd.includeDefaultModules, andnixpkgs.hostPlatformcomes fromnixosSystem.hosts/testvmuses the same module against/dev/vda; its generated file was deleted, having described the UUIDs of a qcow2 that no longer exists. - Consequence: The user password no longer arrives with the installer.
users.users.<name>declares none, so it is set once vianixos-enterafter installing rather than committing a hash to the repository.
2026-08-11 – Swapfile without hibernation
- Who: ClaudeCode, on the maintainer's request for a recommendation
- Why: The layout module needs a swap default.
- Tradeoffs: Hibernation needs swap at least the size of RAM plus a
resume_offsetkernel parameter. On btrfs that offset changes whenever the swapfile is recreated, which is a standing source of breakage. Avoiding it means LUKS → LVM → btrfs LV plus a swap LV, one layer more. A swap partition cannot be resized later without repartitioning; a swapfile can. - How: 8G swapfile in a
/.swapvolsubvolume, no hibernation, no LVM. Sized as a safety valve against memory pressure, not as a replacement for the 30 GiB of RAM. Revisit by growing the swapfile and addingresume_offset— neither needs a reinstall.
2026-08-07 – Separate repo instead of Nix files in the application repos
- Who: Maintainer, ClaudeCode
- Why: The Nix build recipes had to live somewhere without disturbing the existing repos.
- Tradeoffs: Files inside each project repo would keep version, code and
build recipe in one commit — no drift between PKGBUILD
pkgverand flake input. A separate repo keeps the Arch repos untouched, at the cost of one extra commit per version bump (tag inflake.nix, thennix flake update). - How: New repo
moonix, consuming the application repos as flake inputs pinned to release tags.
2026-08-07 – Configs deployed as files, not translated to Nix options
- Who: Maintainer
- Why: One set of config files should stay valid on NixOS and Arch alike.
- Tradeoffs: Native Nix options (
programs.niri.settings) are idiomatic and type checked, but every config would exist twice and drift on each change. - How:
environment.etc.<path>.sourcepointing into the moonarch flake input. Only files containing FHS paths are generated instead of copied.
2026-08-07 – No Home Manager
- Who: Maintainer
- Why: Initially proposed for the three home-directory tasks in
post-install.sh(GTK4 symlinks,gsettings, seeding the stasis config). - Tradeoffs: None in favour, as it turned out. Every one of those tasks is
equally doable with
ln,gsettingsandinstall; the argument for Home Manager was convention, not need. Two of the three cases disappeared entirely: dconf defaults can be set system-wide viaprograms.dconf.profiles.user.databases, and stasis reads/etc/stasis/stasis.runeas a documented fallback. - How: Rejected. For files that genuinely have no system-wide fallback,
systemd.user.tmpfileswith theCdirective seeds a copy into$HOMEwithout overwriting later edits.
2026-08-07 – FHS paths fixed in the source, not bridged with symlinks
- Who: Maintainer
- Why: moonlock, moongreet and moonset invoked
systemctl,loginctlandmoonlockthrough absolute/usr/binpaths, and moongreet scanned/usr/share/{wayland-,x}sessions. All of it fails on NixOS. - Tradeoffs:
systemd.tmpfilessymlinks would have avoided touching the code, but they recreate an FHS layout under NixOS and only cover the paths known at the time — a new one surfaces at runtime, when the user presses the button. - How: Programs resolve via
PATH, session directories walkXDG_DATA_DIRSwith/usr/shareas fallback. Behaviour on Arch is unchanged. Released as moonlock 0.6.21, moongreet 0.10.2, moonset 0.9.2. - Remaining exception: the polkit agent is spawned by its FHS path from
defaults/xdg/niri/config.kdl, which is shared with Arch, where the binary lives outsidePATH. That one stays a symlink.
2026-08-07 – waybar and swaync not installed
- Who: Maintainer
- Why: Quickshell provides both the bar and the notifications. The config source still carries waybar and swaync, marked "kept as a reserve" — a note left over from an earlier migration.
- Tradeoffs: Installing them would mirror the config source exactly, at the price of carrying two unused implementations.
- How: Neither package, config nor the waybar-only helper scripts are
installed. That also retires
moonarch-cpugov,moonarch-sink-switcherandmoonarch-nightlight: the waybar config was their only caller, and the Quickshell popouts cover the same ground.
2026-08-07 – QEMU/KVM as the test host, with a caveat
- Who: Maintainer, ClaudeCode
- Why: VirtualBox was the first choice, but its 3D acceleration is documented as broken with Wayland compositors.
- Tradeoffs: QEMU needs no DKMS module on
linux-zenand virtio-gpu carries Wayland. However, passing the guest through to the host GPU triggered an amdgpu hard recovery twice, killing the QEMU process. Software rendering is not an alternative: without a 3D capable virtio GPU, niri's TTY backend fails every buffer import withError::DeviceMissingand never creates an output. - How:
virtio-vga-glwithgl=on, and the VM is shut down when not in use. The underlying amdgpu problem is a host issue, unrelated to Moonarch.
2026-08-07 – Disk layout via Calamares, not disko
- Who: Maintainer
- Why: Stage 3 targets bare metal, but no target machine exists yet — neither the number of disks nor their device paths are known.
- Tradeoffs:
diskowould put partitioning and encryption under version control, which pays off when reinstalling often or setting up several machines alike. For a single machine it is extra work without return, and it would require guessing device paths. Calamares already covers partitioning, LUKS and hardware detection, and produceshardware-configuration.nix— the one file that cannot be written in advance. - How:
hosts/moonarch/holds only machine independent settings. Installation runs through Calamares, then the generated hardware file is copied into the host directory. See README.md.