All checks were successful
Check / eval (push) Successful in 3m57s
Bernardo: "opening claude-code is too disruptive". SUPER+CTRL+A spent a terminal, an npm fetch and a REPL on "what's the syntax for…" — the prompt was the right surface, the destination was not. The query is now URL-encoded into the provider's web chat and handed to xdg-open. settings.ask.provider (claude|chatgpt|gemini|grok, default claude) with a Preferences row, read from state at RUN TIME so switching needs no rebuild, and any unknown/absent value falls back rather than opening nothing. He asked for chatgpt + grok alongside claude/gemini, and for the CLI to go: claude-code is deleted and pkgs.nodejs leaves with it (carried only for that npx). Keybind is "Ask AI" now the destination is the user's. The failure mode is the design point: prefill is undocumented on all four vendors, each can drop `?q=` unilaterally, and it fails SILENTLY — you land in an empty chat having already typed the question. So the query is also copied to the clipboard (a vendor breaking change costs one paste, not the question), and the URLs sit in one attrset so the repair is one line. Verified against the generated script: bash -n clean; every bad state value (unset/null/garbage) falls back to claude; a hostile query encodes correctly (& → %26, ? → %3F, # → %23, $(whoami) neutralised, café → caf%C3%A9). NOT verified on purpose: whether each vendor fills the box or auto-submits — that means posting into Bernardo's own accounts. Sweep: ROADMAP's shipped-modules line described the npx REPL; nix-ld's comment cited claude-code as its example; both fixed. Menu row + keybind renamed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
545 lines
27 KiB
Nix
545 lines
27 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, Kitty, 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
|
||
'';
|
||
|
||
# Menu Preferences › Bluetooth package writes settings.bluetooth.enable; read
|
||
# it from the state file, the only place it exists on the NixOS side (the
|
||
# hardware.nix/timezone.nix bridge). Missing/invalid JSON fails closed via
|
||
# state-read.nix rather than a raw stack. null = key absent, which
|
||
# leaves the option's own default (true) alone.
|
||
sysState =
|
||
if cfg.stateFile != null
|
||
then import ../state-read.nix { inherit lib; } cfg.stateFile
|
||
else { };
|
||
stateBluetooth =
|
||
let v = (sysState.settings or { }).bluetooth.enable or null;
|
||
in if builtins.isBool v then v else null;
|
||
in
|
||
{
|
||
imports = [
|
||
./options.nix ./plymouth.nix ./greeter.nix ./file-manager.nix
|
||
./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.nix
|
||
./gen-prune.nix # #128 system+HM generation prune (14d, keep ≥3 past)
|
||
];
|
||
|
||
config = {
|
||
# Distro branding. distroName flows into /etc/os-release PRETTY_NAME,
|
||
# systemd-boot entry titles and the ISO boot-menu label; distroId is the
|
||
# machine-readable ID (DEFAULT_HOSTNAME, lsb DISTRIB_ID, CPE name).
|
||
# distroId = "nomarchy" makes os-release honest — ID=nomarchy with
|
||
# ID_LIKE=nixos, the standard derivative-distro lineage marker (cf.
|
||
# Ubuntu→debian) — and is safe: switch-to-configuration builds its
|
||
# "is this NixOS?" guard from the *configured* distroId (and /etc/NIXOS
|
||
# still exists as the fallback), so rebuilds keep working; the nixos-*
|
||
# CLI tools are package names, untouched. The one side effect is that
|
||
# isNixos goes false and blanks the upstream nixos.org URLs, so we
|
||
# restore them pointing at the project instead.
|
||
system.nixos.distroName = lib.mkDefault "Nomarchy";
|
||
system.nixos.distroId = lib.mkDefault "nomarchy";
|
||
system.nixos.extraOSReleaseArgs = lib.mkDefault {
|
||
HOME_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||
DOCUMENTATION_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||
SUPPORT_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||
BUG_REPORT_URL = "https://git.bemagri.xyz/bernardo/Nomarchy/issues";
|
||
};
|
||
|
||
# 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. Fingerprint line only
|
||
# when fprintd is enabled (no permanent nag on machines without a
|
||
# reader); doctor is always on PATH via systemPackages.
|
||
users.motd = lib.mkDefault (''
|
||
|
||
${distroName} — a NixOS desktop, themed from one JSON.
|
||
|
||
nomarchy-pull update flake inputs (nomarchy, nixpkgs, …)
|
||
nomarchy-rebuild rebuild the system (current lock)
|
||
nomarchy-home rebuild the desktop / Home Manager
|
||
nomarchy-state-sync apply <theme> switch the whole palette
|
||
nomarchy-doctor read-only health check
|
||
SUPER+? keybindings cheatsheet
|
||
SUPER+M → System › Firmware check LVFS firmware updates (fwupd)
|
||
'' + lib.optionalString config.nomarchy.hardware.fingerprint.enable ''
|
||
SUPER+M → System › Fingerprint enroll a finger (fprintd)
|
||
'');
|
||
|
||
# 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_ALLOW_UNFREE` covers CLI `nix-shell` / `nix shell` /
|
||
# `nix run` (those ignore the system `nixpkgs.config`); pure flake
|
||
# eval still needs `--impure` when the env var is the only gate.
|
||
nixpkgs.config.allowUnfree = true;
|
||
environment.variables.NIXPKGS_ALLOW_UNFREE = "1";
|
||
|
||
# ── 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;
|
||
|
||
# Magic SysRq Keys: safety net for Wayland lockups. Alt+SysRq+REISUB allows
|
||
# safe reboot without data loss when the compositor hangs.
|
||
boot.kernel.sysctl."kernel.sysrq" = lib.mkDefault 1;
|
||
|
||
# ── 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.
|
||
};
|
||
|
||
# The greetd/tuigreet login screen lives in ./greeter.nix — themed
|
||
# from the state JSON (console.colors + --theme) at system rebuild.
|
||
|
||
# ── 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;
|
||
# Prefer dock/HDMI/USB sinks when present; fall back to built-in when
|
||
# they disappear (BACKLOG #87). Rules: ./dock-audio-rules.nix.
|
||
wireplumber.extraConfig."90-nomarchy-dock-audio" =
|
||
import ./dock-audio-rules.nix;
|
||
};
|
||
|
||
# ── 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;
|
||
|
||
# Hardware security key (FIDO2/U2F/GPG) support. Without pcscd, tokens like
|
||
# YubiKeys often silently fail in WebAuthn (browsers), SSH, and GPG.
|
||
services.pcscd.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;
|
||
|
||
# Drive health monitoring (SMART).
|
||
services.smartd = {
|
||
enable = lib.mkDefault true;
|
||
notifications.x11.enable = lib.mkDefault true;
|
||
notifications.wall.enable = lib.mkDefault true;
|
||
};
|
||
|
||
# Self-gate on the hardware, like every other conditional bit of the
|
||
# distro. smartd's config is DEVICESCAN, and where no drive answers SMART
|
||
# it exits **17** ("Unable to monitor any SMART enabled devices") — which
|
||
# systemd records as a FAILED unit, nomarchy-doctor faithfully reports as
|
||
# a failed system unit, and Waybar renders as a red health icon. That is
|
||
# every QEMU guest (virtio exposes no SMART), most live USB sticks, and
|
||
# some eMMC — so a plain VM install greeted the user with a health warning
|
||
# about a daemon that had nothing to do (Bernardo, live ISO 2026-07-14).
|
||
#
|
||
# ExecCondition is the right lever, not SuccessExitStatus = 17: a failed
|
||
# *condition* leaves the unit **inactive** and unfailed, while exit 17
|
||
# from a machine that DOES have drives still fails loudly — which is the
|
||
# entire reason the daemon is here. `smartctl --scan` prints nothing
|
||
# exactly when smartd would find nothing, so it is the same question
|
||
# asked before the daemon can fail it.
|
||
systemd.services.smartd.serviceConfig.ExecCondition =
|
||
lib.mkIf config.services.smartd.enable
|
||
(lib.mkDefault "${pkgs.writeShellScript "smartd-any-smart-device" ''
|
||
[ -n "$(${pkgs.smartmontools}/bin/smartctl --scan)" ]
|
||
''}");
|
||
# Core security: enable AppArmor to confine desktop apps and services.
|
||
security.apparmor.enable = true;
|
||
security.apparmor.killUnconfinedConfinables = false;
|
||
|
||
# Core stability: reboot automatically after 10s on a kernel panic
|
||
# (prevents the system from hanging indefinitely on a black screen).
|
||
boot.kernelParams = [ "panic=10" "oops=panic" ];
|
||
|
||
# Explicitly enable systembus-notify to resolve a mkDefault conflict
|
||
# between earlyoom (true) and smartd (false).
|
||
services.systembus-notify.enable = true;
|
||
|
||
# Enable I2C and DDC/CI by default for external monitor brightness control.
|
||
nomarchy.hardware.i2c.ddcci = lib.mkDefault true;
|
||
|
||
networking.networkmanager.enable = lib.mkDefault true;
|
||
# OpenVPN support for the VPN menu's import/connect flow (nomarchy-vpn).
|
||
# WireGuard needs no plugin (NetworkManager imports wg .conf natively);
|
||
# this adds the openvpn type so `nmcli connection import type openvpn` works.
|
||
# mkDefault so a downstream can drop it to slim the closure.
|
||
networking.networkmanager.plugins = lib.mkDefault [ pkgs.networkmanager-openvpn ];
|
||
|
||
# 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.
|
||
#
|
||
# #115 suspend-then-hibernate: the first phase is RAM-resume (lock),
|
||
# then after HibernateDelaySec systemd enters pure hibernate. Without
|
||
# an unlock before that second phase, an encrypted resume would demand
|
||
# LUKS *and* still-locked hyprlock. Drop the session lock immediately
|
||
# before encrypted hibernate — LUKS is the gate; unencrypted still
|
||
# locks via the unit below on hibernate.target.
|
||
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";
|
||
};
|
||
};
|
||
|
||
systemd.services.nomarchy-unlock-before-encrypted-hibernate =
|
||
let encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
|
||
in lib.mkIf encrypted {
|
||
description = "Unlock session before encrypted hibernate (LUKS is the resume gate)";
|
||
before = [ "systemd-hibernate.service" ];
|
||
wantedBy = [ "hibernate.target" ];
|
||
# After lock-before-sleep on the s2h RAM phase; before the image.
|
||
after = [ "nomarchy-lock-before-sleep.service" ];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
ExecStart = "${config.systemd.package}/bin/loginctl unlock-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;
|
||
|
||
# The in-flake state drives the toggle; mkDefault so a hand-set
|
||
# nomarchy.system.bluetooth.enable in system.nix still pins it (the
|
||
# greeter.autoLogin shape). mkIf, not a fallback expression, so an absent
|
||
# key leaves the option default as the single source of `true`.
|
||
nomarchy.system.bluetooth.enable =
|
||
lib.mkIf (stateBluetooth != null) (lib.mkDefault stateBluetooth);
|
||
|
||
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) 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";
|
||
};
|
||
};
|
||
|
||
# Background filesystem health checks. Scans all BTRFS filesystems to detect
|
||
# and (if RAID/dup) correct bitrot before it propagates.
|
||
services.btrfs.autoScrub = lib.mkIf ((config.fileSystems."/".fsType or "") == "btrfs") {
|
||
enable = lib.mkDefault true;
|
||
interval = lib.mkDefault "monthly";
|
||
};
|
||
|
||
# ── 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-state-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
|
||
# GeistMono (57 MB) — the Boreal theme's mono face; modern and
|
||
# geometric, unlike the ten defaults. Cheap enough for the ISO
|
||
# (Iosevka above was rejected at 1.1 GB; this is a compact family).
|
||
nerd-fonts.geist-mono
|
||
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-state-sync # provided by overlays.default
|
||
nomarchy-doctor # read-only health check (System › Doctor)
|
||
nomarchy-detect-hw # post-install hardware re-probe (HARDWARE.md §8)
|
||
|
||
# Day-to-day lifecycle (README §3): nomarchy-pull / -rebuild / -home
|
||
# (+ legacy sys-update / sys-rebuild / home-update). Defined once in
|
||
# pkgs/nomarchy-lifecycle; also installed via HM so a home switch can
|
||
# refresh a stale system-package pull script.
|
||
pkgs.nomarchy-lifecycle
|
||
|
||
git
|
||
vim
|
||
wget
|
||
curl
|
||
jq
|
||
brightnessctl
|
||
playerctl
|
||
pamixer
|
||
wl-clipboard
|
||
grim
|
||
slurp
|
||
hyprpicker
|
||
] ++ 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
|
||
# Prefer the path nomarchy-rebuild passes through sudo; fall back
|
||
# for hand invocations.
|
||
flake="''${NOMARCHY_PATH:-/etc/nixos}"
|
||
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 $flake#default ..."
|
||
nixos-rebuild switch --flake "$flake#default" "$@"
|
||
'')
|
||
# The desktop snapshot manager (browse / diff / restore / rollback over
|
||
# snapper, elevating via polkit) — the primary `nomarchy-menu snapshot`
|
||
# target. The "2.2 segfault" is unprivileged-only (libbtrfsutil
|
||
# unprivileged subvolume iteration, btrfs-progs 6.17.1, fixed upstream
|
||
# after); the pkexec launcher runs it as root, where it works —
|
||
# VM-proven, guarded by checks.snapshot-gui.
|
||
++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant
|
||
# Keyboard-driven snapper browser/restore — the menu's fallback when the
|
||
# GUI is absent, and handy over SSH. 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 ];
|
||
|
||
# Mic-mute LED sysfs nodes (ThinkPad platform::micmute, HDA *::micmute)
|
||
# default to root-only. PipeWire mute does not drive the kernel's
|
||
# audio-micmute trigger, so nomarchy-mic-mute writes brightness itself.
|
||
# Group `video` matches the backlight udev pattern (swayosd) and the
|
||
# template login user's extraGroups.
|
||
services.udev.extraRules = lib.mkAfter ''
|
||
ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*micmute*", \
|
||
RUN+="${pkgs.coreutils}/bin/chgrp video /sys/class/leds/%k/brightness /sys/class/leds/%k/trigger", \
|
||
RUN+="${pkgs.coreutils}/bin/chmod g+w /sys/class/leds/%k/brightness /sys/class/leds/%k/trigger"
|
||
'';
|
||
|
||
# ── 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-state-sync rewrites 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;
|
||
};
|
||
# Generation age+floor policy + store GC: modules/nixos/gen-prune.nix (#128).
|
||
};
|
||
};
|
||
}
|
||
|