Two menu improvements. Back everywhere: every list menu now ends with a "↩ Back" entry that returns one level up — power, theme, power-profile, clipboard, files, capture, not just Tools/System — so you no longer have to Esc out and reopen to back up. A `back` helper + a shared `BACK` label keep it uniform; matched exactly so it can't collide with clipboard/filename content. Esc still quits instantly. Snapshot fallback: btrfs-assistant 2.2 segfaults on 26.05 (libbtrfsutil ABI), so `nomarchy-menu snapshot` now launches nomarchy-snapshots instead — a keyboard-driven snapper browser/restore. snapper is root-only here and rofi can't run as root under Wayland, so it runs in a terminal via sudo (one password prompt) and uses fzf to pick: browse/diff (read-only), restore files (undochange), or roll back (root config only), each behind a typed-`yes` confirmation — safer than a one-click rofi rollback. btrfs-assistant stays installed for when nixpkgs fixes it. Verified: flake check clean; home generation builds and the menu passes bash -n with all Back + snapshot wiring; nomarchy-snapshots passes shellcheck (writeShellApplication) in a snapper-enabled system build. Pending an on-machine check of the restore/rollback paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
419 lines
20 KiB
Nix
419 lines
20 KiB
Nix
# Nomarchy — reusable system layer (NixOS 26.05).
|
||
#
|
||
# This module is the distro: import it from any host (see
|
||
# nixosModules.nomarchy in flake.nix) and layer your machine specifics
|
||
# (bootloader, hostname, users, hardware) on top. Host concerns are
|
||
# deliberately NOT set here. Everything user-facing (Hyprland config,
|
||
# Waybar, Ghostty, theming) lives in modules/home.
|
||
{ config, lib, pkgs, ... }:
|
||
|
||
let
|
||
cfg = config.nomarchy.system;
|
||
# Reused by the greeter banner and MOTD so the brand name lives in one
|
||
# place (system.nixos.distroName, set below).
|
||
distroName = config.system.nixos.distroName;
|
||
|
||
# The Nomarchy glyph font: a single-glyph face (the logo at U+F000) used
|
||
# by the Waybar menu button in the summer themes. Pinned via
|
||
# `font-family: Nomarchy` in their CSS, since Nerd Fonts also occupy
|
||
# U+F000 (a glass icon) and would otherwise win.
|
||
nomarchyLogoFont = pkgs.runCommandLocal "nomarchy-logo-font" { } ''
|
||
install -Dm444 ${./branding/Nomarchy.ttf} $out/share/fonts/truetype/Nomarchy.ttf
|
||
'';
|
||
in
|
||
{
|
||
imports = [ ./options.nix ./plymouth.nix ./file-manager.nix ./power.nix ./services.nix ./hardware.nix ];
|
||
|
||
config = {
|
||
# The safe half of distro branding: distroName flows into
|
||
# /etc/os-release PRETTY_NAME, systemd-boot entry titles and the
|
||
# ISO boot-menu label. distroId stays "nixos" on purpose — it feeds
|
||
# DEFAULT_HOSTNAME and upstream isNixos checks (see roadmap).
|
||
system.nixos.distroName = lib.mkDefault "Nomarchy";
|
||
|
||
# MOTD on TTY/SSH login (the desktop auto-logs into Hyprland, so this
|
||
# is mostly seen over SSH or on a bare console). Branded, and doubles
|
||
# as a cheat sheet for the distro's own helpers.
|
||
users.motd = lib.mkDefault ''
|
||
|
||
${distroName} — a NixOS desktop, themed from one JSON.
|
||
|
||
sys-update update inputs + rebuild the system
|
||
home-update apply home/theme changes (no sudo)
|
||
nomarchy-theme-sync apply <theme> switch the whole palette
|
||
SUPER+? keybindings cheatsheet
|
||
'';
|
||
|
||
# Unfree allowed distro-wide: pragmatic-desktop territory (vendor
|
||
# GPU/wifi drivers, firmware, fonts, …). The custom nixpkgs-config
|
||
# type can't carry a nested mkDefault; disagree with
|
||
# `nixpkgs.config = lib.mkForce { allowUnfree = false; }`.
|
||
nixpkgs.config.allowUnfree = true;
|
||
|
||
# ── One keyboard layout everywhere, incl. the LUKS prompt ────────
|
||
# services.xserver.xkb.layout is the single source of truth for the
|
||
# layout (the installer writes it; downstream sets it in system.nix).
|
||
# These bridge that one knob through to the encrypted-disk prompt —
|
||
# without them, setting only xkb.layout moves the graphical session
|
||
# but leaves the console keymap unset, so the initrd LUKS passphrase
|
||
# prompt falls back to US:
|
||
# useXkbConfig the virtual-console keymap follows xkb (TTYs)
|
||
# earlySetup bakes that keymap into the initrd
|
||
# systemd initrd loads it before cryptsetup asks for the passphrase
|
||
# (the legacy scripted initrd cannot; also what
|
||
# Plymouth needs — defaulted here so it no longer
|
||
# hinges on the splash being enabled).
|
||
# All mkDefault, so an explicit console.keyMap or a scripted initrd
|
||
# still wins.
|
||
console.useXkbConfig = lib.mkDefault true;
|
||
console.earlySetup = lib.mkDefault true;
|
||
boot.initrd.systemd.enable = lib.mkDefault true;
|
||
|
||
# Nomarchy roots are BTRFS, not ZFS, so adopt the 26.11 default early and
|
||
# silence the eval warning the old `true` default emits. mkDefault, so a
|
||
# genuine ZFS-root downstream can still force it back on.
|
||
boot.zfs.forceImportRoot = lib.mkDefault false;
|
||
|
||
# ── Wayland session: Hyprland ────────────────────────────────────
|
||
# Installs the binary, registers the session, wires up
|
||
# xdg-desktop-portal-hyprland. Configuration is Home Manager's job.
|
||
programs.hyprland.enable = lib.mkDefault true;
|
||
|
||
xdg.portal = {
|
||
enable = lib.mkDefault true;
|
||
extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file pickers, etc.
|
||
};
|
||
|
||
services.greetd = lib.mkIf cfg.greeter.enable {
|
||
enable = lib.mkDefault true;
|
||
settings = {
|
||
default_session = {
|
||
# start-hyprland is Hyprland 0.55's watchdog launcher; running
|
||
# the bare binary makes every session print a warning.
|
||
command = lib.mkDefault "${pkgs.tuigreet}/bin/tuigreet --time --remember --greeting 'Welcome to ${distroName}' --cmd start-hyprland";
|
||
user = "greeter";
|
||
};
|
||
# Boot straight into the session once; logout → normal greeter.
|
||
initial_session = lib.mkIf (cfg.greeter.autoLogin != null) {
|
||
command = "start-hyprland";
|
||
user = cfg.greeter.autoLogin;
|
||
};
|
||
};
|
||
};
|
||
|
||
# ── Audio: Pipewire ──────────────────────────────────────────────
|
||
security.rtkit.enable = lib.mkDefault cfg.audio.enable;
|
||
services.pulseaudio.enable = lib.mkDefault false;
|
||
services.pipewire = lib.mkIf cfg.audio.enable {
|
||
enable = lib.mkDefault true;
|
||
alsa.enable = true;
|
||
alsa.support32Bit = true;
|
||
pulse.enable = true;
|
||
wireplumber.enable = true;
|
||
};
|
||
|
||
# ── Desktop services ─────────────────────────────────────────────
|
||
security.polkit.enable = lib.mkDefault true;
|
||
services.gnome.gnome-keyring.enable = lib.mkDefault true;
|
||
services.dbus.enable = lib.mkDefault true;
|
||
services.upower.enable = lib.mkDefault true;
|
||
|
||
# Firmware updates via LVFS. Ships the daemon + its metadata-refresh
|
||
# timer only — it never flashes anything on its own; applying an update
|
||
# is an explicit `fwupdmgr update`. On by default as desktop security
|
||
# hygiene (BIOS/UEFI/Thunderbolt/SSD fixes land here); a clean no-op
|
||
# where LVFS sees no devices (VMs/headless). Disable natively with
|
||
# `services.fwupd.enable = false`.
|
||
services.fwupd.enable = lib.mkDefault true;
|
||
|
||
networking.networkmanager.enable = lib.mkDefault true;
|
||
|
||
# No double-unlock on hibernate. Locking the session before sleep is
|
||
# right for suspend (resumes from RAM, no other gate), but an encrypted
|
||
# hibernate already resumes through the LUKS passphrase — a hyprlock on
|
||
# top is a second password. And we can't just drop the lock after the
|
||
# resume: a Wayland session-lock whose client dies without releasing
|
||
# trips hyprlock's "go to a tty" crash failsafe (the compositor keeps the
|
||
# screen locked for safety). So don't lock before an encrypted hibernate
|
||
# in the first place. hypridle can't tell suspend from hibernate, hence a
|
||
# system unit hooked to the sleep targets: it locks on the RAM-resume
|
||
# sleeps always, and on hibernate only when the disk is unencrypted (no
|
||
# LUKS gate to rely on). Replaces hypridle's old before_sleep_cmd.
|
||
systemd.services.nomarchy-lock-before-sleep =
|
||
let
|
||
encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
|
||
# Sleeps whose normal resume is from RAM — always lock these.
|
||
ramTargets = [ "suspend.target" "hybrid-sleep.target" "suspend-then-hibernate.target" ];
|
||
ramServices = [ "systemd-suspend.service" "systemd-hybrid-sleep.service" "systemd-suspend-then-hibernate.service" ];
|
||
in
|
||
{
|
||
description = "Lock the session before sleep (skipped for an encrypted hibernate)";
|
||
before = ramServices ++ lib.optional (!encrypted) "systemd-hibernate.service";
|
||
wantedBy = ramTargets ++ lib.optional (!encrypted) "hibernate.target";
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
# Plural: a system unit has no session of its own to lock.
|
||
ExecStart = "${config.systemd.package}/bin/loginctl lock-sessions";
|
||
};
|
||
};
|
||
|
||
# zsh as the default login shell (the desktop's shell experience —
|
||
# starship/bat/eza/zoxide — is configured home-side in shell.nix).
|
||
# programs.zsh.enable wires /etc/zshrc, completion and /etc/shells;
|
||
# defaultUserShell applies to normal users that don't set their own.
|
||
programs.zsh.enable = lib.mkDefault true;
|
||
# mkOverride 500: beats nixpkgs' bash.nix `mkDefault bashInteractive`
|
||
# (equal mkDefault priority would conflict) while staying below a
|
||
# plain downstream assignment. A per-user `users.users.<n>.shell`
|
||
# overrides this regardless.
|
||
users.defaultUserShell = lib.mkOverride 500 pkgs.zsh;
|
||
|
||
hardware.bluetooth.enable = lib.mkDefault cfg.bluetooth.enable;
|
||
services.blueman.enable = lib.mkDefault cfg.bluetooth.enable;
|
||
|
||
# ── Foreign binaries: nix-ld ─────────────────────────────────────
|
||
# An ld.so shim so dynamically-linked binaries not built for NixOS
|
||
# (downloaded tools, language servers, pip/npm-installed ELFs, the
|
||
# npx-fetched claude-code) run without manual patchelf. On by default —
|
||
# a pragmatic-desktop expectation.
|
||
programs.nix-ld.enable = lib.mkDefault true;
|
||
|
||
# ── Firmware ─────────────────────────────────────────────────────
|
||
# Blobs for in-kernel drivers: wifi (iwlwifi/ath/rtw/brcm), SOF
|
||
# audio, Bluetooth. Drivers ship with the kernel, but without these
|
||
# a real machine can boot with no wifi — QEMU never catches it.
|
||
# nixos-generate-config also keys CPU microcode off this flag.
|
||
hardware.enableRedistributableFirmware = lib.mkDefault true;
|
||
|
||
# ── BTRFS timeline snapshots (ported from the previous iteration) ─
|
||
# Guarded on the actual filesystem so enabling it on an ext4 machine
|
||
# is a clean no-op rather than a failing timer.
|
||
services.snapper.configs = lib.mkIf
|
||
(cfg.snapper.enable && (config.fileSystems."/".fsType or "") == "btrfs")
|
||
({
|
||
root = {
|
||
SUBVOLUME = "/";
|
||
TIMELINE_CREATE = true;
|
||
TIMELINE_CLEANUP = true;
|
||
TIMELINE_LIMIT_HOURLY = "5";
|
||
TIMELINE_LIMIT_DAILY = "7";
|
||
TIMELINE_LIMIT_WEEKLY = "0";
|
||
TIMELINE_LIMIT_MONTHLY = "0";
|
||
TIMELINE_LIMIT_YEARLY = "0";
|
||
};
|
||
}
|
||
# /home is user data — snapshot it too when it's its own BTRFS subvolume
|
||
# (the installer's @home). Keep the recent hourlies for oops-recovery
|
||
# plus dailies, and a month of weeklies; "at least daily" by default.
|
||
// lib.optionalAttrs ((config.fileSystems."/home".fsType or "") == "btrfs") {
|
||
home = {
|
||
SUBVOLUME = "/home";
|
||
TIMELINE_CREATE = true;
|
||
TIMELINE_CLEANUP = true;
|
||
TIMELINE_LIMIT_HOURLY = "5";
|
||
TIMELINE_LIMIT_DAILY = "7";
|
||
TIMELINE_LIMIT_WEEKLY = "4";
|
||
TIMELINE_LIMIT_MONTHLY = "0";
|
||
TIMELINE_LIMIT_YEARLY = "0";
|
||
};
|
||
});
|
||
|
||
# snapper needs a `.snapshots` subvolume inside each tracked subvolume.
|
||
# The installer made one for / (@snapshots → /.snapshots) but not /home,
|
||
# so create a nested one there if missing (idempotent via the condition;
|
||
# excluded from /home snapshots since BTRFS snapshots are non-recursive).
|
||
# Runs at boot so it also fixes already-installed machines — no disko
|
||
# change needed.
|
||
systemd.services.nomarchy-home-snapshots-subvol = lib.mkIf
|
||
(cfg.snapper.enable && (config.fileSystems."/home".fsType or "") == "btrfs") {
|
||
description = "Create /home/.snapshots for snapper if missing";
|
||
wantedBy = [ "multi-user.target" ];
|
||
before = [ "snapper-timeline.service" "snapper-cleanup.service" ];
|
||
unitConfig.ConditionPathExists = "!/home/.snapshots";
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
ExecStart = "${pkgs.btrfs-progs}/bin/btrfs subvolume create /home/.snapshots";
|
||
};
|
||
};
|
||
|
||
# ── Fonts ────────────────────────────────────────────────────────
|
||
# The ten most popular Nerd Fonts ship by default, so any of them
|
||
# can be named in the theme state's fonts.mono and actually resolve
|
||
# (nomarchy-theme-sync warns when a configured font is missing).
|
||
fonts = {
|
||
packages = with pkgs; [
|
||
nerd-fonts.jetbrains-mono
|
||
nerd-fonts.fira-code
|
||
nerd-fonts.caskaydia-cove
|
||
nerd-fonts.hack
|
||
nerd-fonts.sauce-code-pro
|
||
nerd-fonts.meslo-lg
|
||
nerd-fonts.roboto-mono
|
||
nerd-fonts.ubuntu-mono
|
||
# iosevka would be next by popularity, but its package is 1.1 GB
|
||
# (every weight × variant) — too heavy for the ISO and closures.
|
||
nerd-fonts.mononoki
|
||
nerd-fonts.inconsolata
|
||
inter
|
||
noto-fonts
|
||
noto-fonts-color-emoji
|
||
# The legacy-ported waybar identities (summer-day/night) use
|
||
# FontAwesome codepoints in their layouts and list it in css.
|
||
font-awesome
|
||
# The Nomarchy logo glyph (U+F000) for the summer menu button.
|
||
nomarchyLogoFont
|
||
];
|
||
fontconfig.defaultFonts = {
|
||
monospace = lib.mkDefault [ "JetBrainsMono Nerd Font" ];
|
||
sansSerif = lib.mkDefault [ "Inter" ];
|
||
emoji = lib.mkDefault [ "Noto Color Emoji" ];
|
||
};
|
||
};
|
||
|
||
# ── Essential packages ───────────────────────────────────────────
|
||
environment.systemPackages = with pkgs; [
|
||
nomarchy-theme-sync # provided by overlays.default
|
||
|
||
# Friendly wrappers for the two rebuild paths (README §3). Run as
|
||
# your user: `nix flake update` must NOT run as root (libgit2
|
||
# refuses the user-owned flake repo) — sudo happens inside, only
|
||
# for the system switch.
|
||
(pkgs.writeShellScriptBin "sys-update" ''
|
||
set -e
|
||
if [ "$(id -u)" -eq 0 ]; then
|
||
echo "sys-update: run as your normal user (it sudos the rebuild itself)" >&2
|
||
exit 1
|
||
fi
|
||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||
echo "sys-update: updating flake inputs in $flake"
|
||
nix flake update --flake "$flake"
|
||
if command -v nixos-rebuild-snap >/dev/null 2>&1; then
|
||
sudo nixos-rebuild-snap "$@" # BTRFS snapshot first
|
||
else
|
||
sudo nixos-rebuild switch --flake "$flake#default" "$@"
|
||
fi
|
||
'')
|
||
(pkgs.writeShellScriptBin "home-update" ''
|
||
set -e
|
||
exec home-manager switch --flake "''${NOMARCHY_PATH:-$HOME/.nomarchy}" "$@"
|
||
'')
|
||
|
||
git
|
||
vim
|
||
wget
|
||
curl
|
||
jq
|
||
brightnessctl
|
||
playerctl
|
||
pamixer
|
||
wl-clipboard
|
||
grim
|
||
slurp
|
||
] ++ lib.optional (cfg.snapper.enable && (config.fileSystems."/".fsType or "") == "btrfs")
|
||
# Snapshot, then rebuild — rollback material for system changes
|
||
# (theme changes don't need it; HM generations already roll back).
|
||
(pkgs.writeShellScriptBin "nixos-rebuild-snap" ''
|
||
if [ "$(id -u)" -ne 0 ]; then
|
||
echo "This script must be run as root (use sudo)" >&2
|
||
exit 1
|
||
fi
|
||
echo "Creating pre-rebuild snapshot..."
|
||
${pkgs.snapper}/bin/snapper -c root create \
|
||
-d "Pre-rebuild $(date +'%Y-%m-%d %H:%M:%S')" \
|
||
--cleanup-algorithm number
|
||
echo "Rebuilding..."
|
||
nixos-rebuild switch --flake /etc/nixos#default "$@"
|
||
'')
|
||
# The desktop snapshot manager (browse / diff / restore / rollback over
|
||
# snapper, elevating via polkit). Kept for when nixpkgs fixes its crash.
|
||
++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant
|
||
# Keyboard-driven snapper browser/restore — the fallback `nomarchy-menu
|
||
# snapshot` actually launches while btrfs-assistant 2.2 segfaults on 26.05
|
||
# (libbtrfsutil ABI). Runs as root (snapper is root-only here; the menu
|
||
# opens it in a terminal via sudo, one password prompt), fzf to pick, with
|
||
# browse/diff (read-only) and typed-`yes` confirmation before any write.
|
||
++ lib.optional cfg.snapper.enable (pkgs.writeShellApplication {
|
||
name = "nomarchy-snapshots";
|
||
runtimeInputs = with pkgs; [ snapper fzf gawk gnugrep less coreutils systemd ];
|
||
text = ''
|
||
if [ "$(id -u)" -ne 0 ]; then
|
||
echo "nomarchy-snapshots must run as root (snapper needs it) — use sudo." >&2
|
||
exit 1
|
||
fi
|
||
|
||
mapfile -t configs < <(snapper list-configs | awk 'NR>2 {print $1}' | grep .)
|
||
if [ "''${#configs[@]}" -eq 0 ]; then
|
||
echo "No snapper configs found." >&2; exit 1
|
||
elif [ "''${#configs[@]}" -eq 1 ]; then
|
||
config="''${configs[0]}"
|
||
else
|
||
config=$(printf '%s\n' "''${configs[@]}" | fzf --prompt="snapper config> ") || exit 0
|
||
fi
|
||
|
||
while :; do
|
||
snap=$(snapper -c "$config" list \
|
||
| fzf --header-lines=2 --prompt="[$config] pick a snapshot (Esc quits)> ") || exit 0
|
||
num=$(awk '{print $1}' <<<"$snap")
|
||
case "$num" in ""|*[!0-9]*) continue ;; esac
|
||
|
||
action=$(printf '%s\n' \
|
||
"Browse changes since #$num (read-only)" \
|
||
"Restore changed files to #$num (undochange)" \
|
||
"Roll the system back to #$num (reboot)" \
|
||
"↩ Back to the snapshot list" \
|
||
| fzf --prompt="snapshot #$num> ") || exit 0
|
||
|
||
case "$action" in
|
||
Browse*)
|
||
snapper -c "$config" status "$num..0" | less -R || true ;;
|
||
Restore*)
|
||
read -rp "Revert files in '$config' to snapshot #$num? Type yes to confirm: " ans || continue
|
||
if [ "$ans" = yes ]; then
|
||
snapper -c "$config" undochange "$num..0"
|
||
echo "Files restored. Press enter."; read -r _ || true
|
||
fi ;;
|
||
Roll*)
|
||
if [ "$config" != root ]; then
|
||
echo "Rollback applies to the 'root' config only; use Restore for '$config'. Press enter."
|
||
read -r _ || true
|
||
else
|
||
read -rp "Roll the SYSTEM back to #$num and REBOOT now? Type yes to confirm: " ans || continue
|
||
if [ "$ans" = yes ]; then
|
||
snapper -c root rollback "$num"
|
||
echo "Rolled back — rebooting…"; systemctl reboot
|
||
fi
|
||
fi ;;
|
||
*) continue ;;
|
||
esac
|
||
done
|
||
'';
|
||
});
|
||
|
||
# Don't let boot entries fill the ESP over the years.
|
||
boot.loader.systemd-boot.configurationLimit = lib.mkDefault 10;
|
||
|
||
# Backlight udev rule (chgrp video + g+w on /sys/class/backlight) so
|
||
# the desktop's swayosd brightness control works for video-group
|
||
# users without root. Harmless baseline even if the OSD is disabled.
|
||
services.udev.packages = [ pkgs.swayosd ];
|
||
|
||
# ── Nix itself ───────────────────────────────────────────────────
|
||
nix = {
|
||
settings = {
|
||
experimental-features = [ "nix-command" "flakes" ];
|
||
auto-optimise-store = lib.mkDefault true;
|
||
# The downstream flake (~/.nomarchy) is meant to be a live working
|
||
# tree: nomarchy-theme-sync rewrites theme-state.json on every
|
||
# switch (and you needn't commit each tweak), so the "Git tree is
|
||
# dirty" warning fires on every rebuild and is pure noise here.
|
||
warn-dirty = lib.mkDefault false;
|
||
};
|
||
gc = {
|
||
automatic = lib.mkDefault true;
|
||
dates = lib.mkDefault "weekly";
|
||
options = lib.mkDefault "--delete-older-than 14d";
|
||
};
|
||
};
|
||
};
|
||
}
|