dotfiles/flake.nix
Mads Fagerlund 64a399597a
plain: one x86_64 target, no host branching
Drops the mbp (Asahi) target and everything that existed only to serve
two machines at once:

  - apple-silicon input, firmware/ (87M of Mac Wi-Fi/BT blobs), hosts/mbp/
  - the mkHost helper and the common-vs-host-module split, merged into one
    `pc` module
  - the osConfig.networking.hostName conditional that generated
    hypr/monitors.conf per host -- the monitor layout, workspace placement
    and panel float rule are now stated once, inline in hyprland.conf
  - the aarch64 nixbuild.net builder and the aarch64 devShell
  - Mac-only hyprland bits: the apple-spi-keyboard device block and the
    touchpad input block

hosts/pc/hardware-configuration.nix moves to the repo root: with one
machine there is no host to namespace it under. The flake output stays
named `pc` so it matches networking.hostName, which means plain
`nixos-rebuild switch --flake ~/nixos` resolves it with no #target.

eww goes too. It only ever drew a battery popup (SUPER+R), and a battery
readout on a desktop with no battery is nothing.

Also fixes the services.logind.suspendKey/hibernateKey/lidSwitch rename
that eval has been warning about; the rendered logind.conf keeps the same
three Handle*=ignore directives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-10 19:34:53 +02:00

548 lines
22 KiB
Nix

{
description = "mads — NixOS on x86_64, one flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
firefox-addons = {
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
inputs.nixpkgs.follows = "nixpkgs";
};
zen-browser = {
url = "github:0xc000022070/zen-browser-flake";
inputs.nixpkgs.follows = "nixpkgs";
inputs.home-manager.follows = "home-manager";
};
# linear-tui has no flake of its own, so take the source and build it below.
# Pinned to a tag; `nix flake update` moves it and records the hash in
# flake.lock, so there is no sha256 to maintain by hand.
linear-tui-src = {
url = "github:k1-c/linear-tui/v0.13.0";
flake = false;
};
};
outputs = { self, nixpkgs, home-manager, firefox-addons, zen-browser, linear-tui-src, ... }:
let
# linear-tui, built from the pinned source input above. Upstream ships no
# flake and it is not in nixpkgs, but it has a committed Cargo.lock, so
# cargoLock.lockFile covers the dependency hashes with nothing to vendor.
#
# No OpenSSL/pkg-config needed: the crate selects reqwest's rustls
# backend on purpose, and takes its trust roots from the OS store.
linearTui = pkgs: pkgs.rustPlatform.buildRustPackage {
pname = "linear-tui";
version = "0.13.0";
src = linear-tui-src;
cargoLock.lockFile = "${linear-tui-src}/Cargo.lock";
# One test (a_worktree_belongs_to_its_repository) shells out to `git`,
# which the build sandbox lacks -- it panics on NotFound unwrapping
# Command::new("git"). Supplying git lets all 512 tests run.
nativeCheckInputs = [ pkgs.git ];
meta.mainProgram = "linear-tui";
};
# ───────────────────────── home-manager user ─────────────────────────
home = { pkgs, ... }: {
imports = [ zen-browser.homeModules.beta ];
home.stateVersion = "25.11";
# Raw configs that stay files (no good native module).
xdg.configFile = {
"hypr".source = ./config/hypr;
"fuzzel".source = ./config/fuzzel;
};
# Launcher hygiene. These .desktop entries ship with packages we do want,
# but they are noise in fuzzel. A NoDisplay entry in XDG_DATA_HOME
# shadows the package's own copy, which is the only way to hide one
# without patching the package.
#
# Deliberately home.file and NOT xdg.desktopEntries: that option only
# emits anything when xdg.enable = true, which we don't set, so it
# silently produces no files at all.
# nvim - neovim's "Neovim wrapper" entry; we only launch it from a terminal
# uuctl - uwsm's systemd-user-unit picker, pulled in by withUWSM
home.file = {
".local/share/applications/nvim.desktop".text = ''
[Desktop Entry]
Type=Application
Name=Neovim wrapper
NoDisplay=true
'';
".local/share/applications/uuctl.desktop".text = ''
[Desktop Entry]
Type=Application
Name=uuctl
NoDisplay=true
'';
};
# Notification daemon. Nothing owned org.freedesktop.Notifications before
# this, so notify-send failed outright: hyprshot's "Screenshot saved"
# toasts went nowhere, and kitty logged a DBus ServiceUnknown error on
# every launch. Colours match the cyberdream kitty theme below.
services.mako = {
enable = true;
settings = {
font = "JetBrainsMono Nerd Font 11";
background-color = "#16181a";
text-color = "#ffffff";
border-color = "#5ea1ff";
border-size = 2;
border-radius = 8;
default-timeout = 5000;
anchor = "top-right";
margin = "12";
padding = "12";
width = 380;
};
};
programs.kitty = {
enable = true;
settings = {
font_size = "11.5";
# cyberdream theme
background = "#16181a";
foreground = "#ffffff";
cursor = "#ffffff";
cursor_text_color = "#16181a";
selection_background = "#3c4048";
selection_foreground = "#ffffff";
color0 = "#16181a";
color8 = "#3c4048";
color1 = "#ff6e5e";
color9 = "#ff6e5e";
color2 = "#5eff6c";
color10 = "#5eff6c";
color3 = "#f1ff5e";
color11 = "#f1ff5e";
color4 = "#5ea1ff";
color12 = "#5ea1ff";
color5 = "#bd5eff";
color13 = "#bd5eff";
color6 = "#5ef1ff";
color14 = "#5ef1ff";
color7 = "#ffffff";
color15 = "#ffffff";
active_tab_foreground = "#000000";
active_tab_background = "#ffbd5e";
inactive_tab_foreground = "#ffffff";
inactive_tab_background = "#16181a";
};
};
home.pointerCursor = {
enable = true;
package = pkgs.bibata-cursors;
name = "Bibata-Modern-Ice";
size = 24;
gtk.enable = true;
x11.enable = true;
hyprcursor.enable = true;
};
# Dark GTK apps (file dialogs, etc).
gtk = {
enable = true;
theme = { name = "Adwaita-dark"; package = pkgs.gnome-themes-extra; };
gtk3.extraConfig.gtk-application-prefer-dark-theme = 1;
gtk4.extraConfig.gtk-application-prefer-dark-theme = 1;
# Adopt the new default: don't force a theme on GTK4/libadwaita apps
# (dark still applies via prefer-dark-theme above). Silences the
# stateVersion < 26.05 legacy-default warning.
gtk4.theme = null;
};
# Neovim: init.lua verbatim; plugins + LSP pinned by Nix.
programs.neovim = {
enable = true;
withPython3 = false;
withRuby = false;
withNodeJs = false;
initLua = "vim.g.nix_managed = true\n"
+ builtins.readFile ./config/nvim/init.lua;
plugins = with pkgs.vimPlugins; [
nvim-web-devicons cyberdream-nvim oil-nvim nvim-lspconfig
typst-preview-nvim blink-cmp Navigator-nvim
snacks-nvim todo-comments-nvim trouble-nvim
nvim-treesitter.withAllGrammars
fidget-nvim lualine-nvim
];
extraPackages = with pkgs; [
lua-language-server rust-analyzer tinymist nixd zls
yaml-language-server vscode-langservers-extracted
typescript-language-server svelte-language-server
];
};
# Zen browser + Tridactyl, extensions pinned.
programs.zen-browser = {
enable = true;
setAsDefaultBrowser = true;
profiles.default = {
settings = {
"browser.newtabpage.enabled" = false;
"privacy.donottrackheader.enabled" = true;
"signon.rememberSignons" = false;
# Dark mode
"browser.theme.toolbar-theme" = 0;
"browser.theme.content-theme" = 0;
"layout.css.prefers-color-scheme.content-override" = 0;
"ui.systemUsesDarkTheme" = 1;
};
# Re-instantiate pkgs with the firefox-addons overlay + allowUnfree so
# the (unfree) 1Password addon evaluates under *our* config.
extensions.packages = let
addonsPkgs = import pkgs.path {
inherit (pkgs.stdenv.hostPlatform) system;
config.allowUnfree = true;
overlays = [ firefox-addons.overlays.default ];
};
in with addonsPkgs.firefox-addons; [
tridactyl
ublock-origin
onepassword-password-manager
];
};
};
# zsh + starship, no framework.
programs.zsh = {
enable = true;
autosuggestion.enable = true;
syntaxHighlighting.enable = true;
shellAliases = { ll = "ls -alh"; gs = "git status"; };
};
programs.starship.enable = true;
# SSH auth via the 1Password agent — private keys stay in the vault,
# never on disk or in the Nix store.
programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings."*".IdentityAgent = "~/.1password/agent.sock";
};
# git identity + SSH commit signing through 1Password's op-ssh-sign.
programs.git = {
enable = true;
settings = {
user.name = "Mads Fagerlund";
user.email = "mads@deepvis.ai";
user.signingkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAPlpVGZLhfrJqh2j5q3reyGOPQQ9BgW8syQ28tYjlg2";
gpg.format = "ssh";
# NixOS store path — NOT 1Password's suggested /opt/1Password/... which doesn't exist here.
"gpg \"ssh\"".program =
"${pkgs._1password-gui}/share/1password/op-ssh-sign";
commit.gpgsign = true;
# Transparently use SSH (1Password agent) for the deepvis server,
# so existing HTTPS remotes need no manual `git remote set-url`.
url."git@git.deepvis.ai:".insteadOf = "https://git.deepvis.ai/";
};
};
home.packages = with pkgs; [ ripgrep fd tridactyl-native lazygit rustc cargo clippy rustfmt rust-analyzer claude-code gcc bluetui impala postgresql fastfetch (linearTui pkgs) ];
};
# ──────────────────────────── the machine ────────────────────────────
pc = { config, pkgs, ... }: {
networking.hostName = "pc";
nixpkgs.config.allowUnfree = true;
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Trusted so client-side nix config takes effect — substituters and
# remote builders are trusted settings, silently ignored for anyone
# else. A cache that appears to do nothing is the usual symptom.
nix.settings.trusted-users = [ "mads" ];
# The deepvis binary cache: the embedded flakes in models/ort-nix
# (onnxruntime built from source, a CUDA training env) are hours of
# work to build and seconds to substitute.
nix.settings.extra-substituters = [ "https://deepvis.cachix.org" ];
nix.settings.extra-trusted-public-keys = [
"deepvis.cachix.org-1:UE/H6ERLwqhSqubzh8+pzl9eTm0kpMExGe0ym6fENp8="
];
# Private cache, so reads need a token. nix refuses `netrc-file` on
# the command line under the daemon, and a token in the substituter
# URL would land in the process table — a root-owned 0600 netrc is
# the only form the calling process cannot read.
nix.settings.netrc-file = "/etc/nix/netrc";
# Big builds go out to other machines rather than tying this one up.
#
# The host keys are pinned system-wide because the ssh'ing party is
# the root daemon, which has no known_hosts and never gets a tty to
# answer a trust prompt.
programs.ssh.knownHosts."builder" = {
extraHostNames = [ "5.9.71.104" "builder.tailf5eaab.ts.net" ];
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHnKIrxYtlovvYGgkpEZXY8wG/CuSLMGOM/84eIlyeVW";
};
programs.ssh.knownHosts."eu.nixbuild.net" = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPIQCZc54poJ8vqawd8TraNryQeJnvH1eLpIDgbiqymM";
};
nix.distributedBuilds = true;
nix.buildMachines = [
{
hostName = "builder";
sshUser = "builder";
# The daemon (root) does the ssh'ing, so it needs an explicit
# on-disk key — it has no access to a user's agent.
sshKey = "/home/mads/.ssh/id_ed25519";
protocol = "ssh-ng";
system = "x86_64-linux";
maxJobs = 16;
supportedFeatures = [ "big-parallel" "kvm" "nixos-test" ];
}
# nixbuild.net. Listed after `builder` so the machine we already pay
# for is preferred.
#
# Every feature it supports is advertised, not just the two in the
# quickstart: nix only sends a derivation to a builder claiming
# *every* feature that derivation requires, so omitting `kvm` or
# `nixos-test` would silently route those back to local builds.
{
hostName = "eu.nixbuild.net";
sshUser = "root";
sshKey = "/home/mads/.ssh/id_ed25519";
protocol = "ssh-ng";
system = "x86_64-linux";
maxJobs = 100;
supportedFeatures = [ "benchmark" "big-parallel" "ca-derivations" "kvm" "nixos-test" ];
}
];
# iwd owns Wi-Fi (driven from impala, which is iwd-only); networkd does
# DHCP and resolved does DNS. No NetworkManager: running it on top of
# iwd meant two managers with separate saved networks, which ended in
# "Secrets were required, but not provided".
networking.wireless.iwd.enable = true;
networking.useNetworkd = true; # DHCP on any ethernet/wifi by default
systemd.network.wait-online.anyInterface = true; # one link up is enough
# GigE camera link: static, never a route out. The segment also
# carries AAU's IPv6 RAs, and taking them made v6 traffic default
# out this port into a dead end.
networking.interfaces.enp7s0 = {
useDHCP = false;
ipv4.addresses = [ { address = "192.168.12.1"; prefixLength = 24; } ];
};
systemd.network.networks."40-enp7s0" = {
networkConfig.IPv6AcceptRA = false;
networkConfig.ConfigureWithoutCarrier = true; # camera may be off
linkConfig.RequiredForOnline = "no";
};
# iwd only reads /var/lib/iwd. C = copy once, so a password added to
# the file later (or via impala) is never overwritten.
systemd.tmpfiles.rules = [
"C /var/lib/iwd/eduroam.8021x 0600 root root - ${pkgs.writeText "eduroam.8021x" ''
[Security]
EAP-Method=PEAP
EAP-Identity=447087@net.aau.dk
EAP-PEAP-Phase2-Method=MSCHAPV2
EAP-PEAP-Phase2-Identity=447087@net.aau.dk
''}"
];
# Secret Service (org.freedesktop.secrets) provider, D-Bus-activated on
# demand. Without it 1Password can't persist the 2FA device token
# ("unable to save your two-factor token") and re-prompts every unlock.
services.gnome.gnome-keyring.enable = true;
programs._1password.enable = true;
programs._1password-gui = {
enable = true;
polkitPolicyOwners = [ "mads" ];
};
# Zen (a Firefox fork) runs as "zen-beta"; 1Password only trusts an
# allowlist of browser process names, so add it or the extension can't
# unlock via the desktop app. Manifest already lives in ~/.mozilla.
environment.etc."1password/custom_allowed_browsers".text = ''
zen-beta
'';
services.tailscale.enable = true;
# Tailscale SSH: keyless, portless SSH gated by tailnet ACLs.
# Needs a matching "ssh" rule in the admin console to actually permit access.
services.tailscale.extraUpFlags = [ "--ssh" ];
time.timeZone = "Europe/Copenhagen"; # set yours
i18n.defaultLocale = "en_US.UTF-8";
programs.hyprland = { enable = true; withUWSM = true; };
programs.hyprlock.enable = true;
# Every GUI app lives in the one wayland-wm@hyprland.service cgroup, so
# with systemd's default OOMPolicy=stop a kernel OOM-kill of ANY process
# in it stops the whole unit -- compositor included. 1Password was always
# the victim (highest oom_score_adj), but losing it killed the entire
# desktop, and uwsm sets Restart=no, so nothing came back. It read as
# "Hyprland crashed" when the compositor was never at fault.
#
# continue: log the kill, lose that one app, keep the session. Merges
# into the drop-in the uwsm module already generates for this unit.
systemd.user.services."wayland-wm@".serviceConfig.OOMPolicy = "continue";
# Autologin means no password reaches PAM at login, so the gnome-keyring
# login keyring is never created or unlocked -- which is why 1Password
# could not persist its 2FA device token and re-prompted the YubiKey
# every unlock. Putting pam_gnome_keyring in hyprlock's stack instead
# makes the lock screen we already have do that job, so there is still
# exactly one password prompt and no second login screen.
security.pam.services.hyprlock.enableGnomeKeyring = true;
services.hypridle.enable = true;
# ── Amphetamine-style "stay awake" ──
# caffeine.service holds ONE logind block-inhibitor covering all three
# ways this machine can go dark:
# idle -> hypridle stops counting (no blank, no hyprlock)
# sleep -> suspend requests are refused
# handle-lid-switch -> closing the lid does nothing
# systemd owns the process, so "is it on?" is just "is the unit
# active?" -- no pidfile, and it can't be orphaned. Deliberately no
# wantedBy: it only ever starts via SUPER+A or the `caffeine` command.
systemd.user.services.caffeine = {
description = "Caffeine - inhibit idle, sleep and lid-switch";
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.systemd}/bin/systemd-inhibit --what=idle:sleep:handle-lid-switch --who=caffeine --why=stay-awake --mode=block ${pkgs.coreutils}/bin/sleep infinity";
};
};
# Desktop: never suspend or hibernate, even if something asks.
systemd.sleep.settings.Sleep = {
AllowSuspend = "no";
AllowHibernation = "no";
AllowHybridSleep = "no";
AllowSuspendThenHibernate = "no";
};
# Ignore the keys that would normally trigger sleep.
services.logind.settings.Login = {
HandleSuspendKey = "ignore";
HandleHibernateKey = "ignore";
HandleLidSwitch = "ignore";
};
xdg.portal = {
enable = true;
extraPortals = [ pkgs.xdg-desktop-portal-hyprland ];
};
# greetd autologins mads straight into Hyprland (via uwsm).
services.greetd = {
enable = true;
settings = {
initial_session = { user = "mads"; command = "uwsm start hyprland"; };
default_session = { user = "mads"; command = "uwsm start hyprland"; };
};
};
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true; # normal x86 UEFI
# NVIDIA (RTX 20-series or newer → open kernel module).
services.xserver.videoDrivers = [ "nvidia" ];
hardware.graphics.enable = true; # OpenGL/Vulkan userspace (was hardware.opengl)
hardware.nvidia = {
modesetting.enable = true; # required for Wayland/Hyprland
open = true; # open kernel module (Turing+)
nvidiaSettings = true;
package = config.boot.kernelPackages.nvidiaPackages.stable;
};
virtualisation.docker.enable = true;
hardware.bluetooth.enable = true;
programs.zsh.enable = true;
# No NixOS manual: it puts a "NixOS Manual" entry in the launcher and we
# read the docs on the web. man pages are unaffected.
documentation.nixos.enable = false;
fonts.packages = [ pkgs.nerd-fonts.jetbrains-mono ];
environment.systemPackages = with pkgs; [
git kitty fuzzel
wl-clipboard grim slurp swaybg brightnessctl playerctl hyprshot
hyprpolkitagent xdg-utils
# Front-end for caffeine.service (above). Bound to SUPER+A.
(writeShellApplication {
name = "caffeine";
runtimeInputs = [ systemd ];
text = ''
unit=caffeine.service
is_on() { systemctl --user is-active --quiet "$unit"; }
# Guarded so `caffeine` still works over SSH / with no compositor.
toast() {
command -v hyprctl >/dev/null 2>&1 || return 0
hyprctl notify "$1" 2000 "$2" "$3" >/dev/null 2>&1 || true
}
wake_on() {
systemctl --user start "$unit"
toast 5 "rgb(5eff6c)" "Caffeine ON - idle, sleep and lid disabled"
}
wake_off() {
systemctl --user stop "$unit"
toast 1 "rgb(ff6e5e)" "Caffeine OFF - normal sleep restored"
}
case "''${1:-toggle}" in
toggle) if is_on; then wake_off; else wake_on; fi ;;
on) wake_on ;;
off) wake_off ;;
status) if is_on; then echo on; else echo off; fi ;;
*) echo "usage: caffeine [toggle|on|off|status]" >&2; exit 2 ;;
esac
'';
})
];
users.users.mads = {
isNormalUser = true;
extraGroups = [ "wheel" "video" "docker" ];
shell = pkgs.zsh;
initialPassword = "changeme"; # CHANGE after first boot (`passwd`)
};
system.stateVersion = "25.11";
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
# Back up (don't clobber) any pre-existing files HM wants to manage.
home-manager.backupFileExtension = "hm-bak";
home-manager.users.mads = home;
};
in
{
nixosConfigurations.pc = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
home-manager.nixosModules.home-manager
./hardware-configuration.nix
pc
];
};
devShells.x86_64-linux.default =
nixpkgs.legacyPackages.x86_64-linux.mkShell {
packages = [ nixpkgs.legacyPackages.x86_64-linux.nodejs ];
};
};
}