NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

commit 734ff2e5a7c1f490ed00fecf416409c518d6fcbb
parent 2e3a77ad5222762d438ec017f5e7301bd7966182
Author: DAEMON-404 <zer0sec.xp@icloud.com>
Date:   Thu,  8 Oct 2026 02:01:33 +0100

NixOS flake initial commit | SYSTEM://daemonsec@nixos | DATE://Thu Oct  8 03:33:36 AM BST 2026

Diffstat:
M.gitignore | 4++++
A.sops.yaml | 17+++++++++++++++++
MREADME.md | 294++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mflake.lock | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mflake.nix | 13+++++++++++++
Ahome/caelestia/active-window-program-name.patch | 26++++++++++++++++++++++++++
Ahome/caelestia/rose-pine-dawn.txt | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/caelestia/scheme-dawn.json | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhome/caelestia/shell-tokens.json | 16+++++++++++++++-
Ahome/cheats/gpg.md | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/cheats/nix.md | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhome/default.nix | 7+++++++
Mhome/hypr/defaults.lua | 2+-
Ahome/hypr/looknfeel.lua | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/kitty/scrollback.lua | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhome/modules/caelestia.nix | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Ahome/modules/cheats.nix | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/modules/dotfiles.nix | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/modules/fan.nix | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhome/modules/hyprland.nix | 18+++++++++++++++++-
Ahome/modules/neovim.nix | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/modules/prompt.nix | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/modules/shell.nix | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/modules/sops.nix | 31+++++++++++++++++++++++++++++++
Mhome/modules/terminal.nix | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhome/modules/tools.nix | 60+++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mhosts/laptop/default.nix | 21+++++++++++++++++++++
Ahosts/laptop/fan-cli.nix | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhosts/laptop/nvidia.nix | 17++++++++++++++---
Ahosts/laptop/sops.nix | 32++++++++++++++++++++++++++++++++
Asecrets/secrets.yaml | 20++++++++++++++++++++
31 files changed, 2528 insertions(+), 103 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,2 +1,6 @@ result result-* +# never commit decrypted material or the age key +secrets/*.dec +secrets/*.plain* +keys.txt diff --git a/.sops.yaml b/.sops.yaml @@ -0,0 +1,17 @@ +# sops configuration for ~/NixDaemon (sops-nix). One age identity, used by +# the user (~/.config/sops/age/keys.txt, where the sops CLI looks) and by the +# system at activation (/var/lib/sops-nix/key.txt, a root-only copy of it). +# +# sops secrets/secrets.yaml edit (decrypts in $EDITOR, re-encrypts on save) +# sops -d secrets/secrets.yaml print decrypted +# sops updatekeys secrets/secrets.yaml re-encrypt after changing the keys below +# +# To add a second machine or key: generate its age key, add the public key to +# `keys` and the rule, then `sops updatekeys` every file. +keys: + - &daemonsec age1pu5wcvqqh92xr6ces8zmj4xvs52v7yfm4jsfcxm3z82a32u9n97qesldfv +creation_rules: + - path_regex: secrets/.*\.(yaml|json|env|ini)$ + key_groups: + - age: + - *daemonsec diff --git a/README.md b/README.md @@ -1,60 +1,148 @@ -# NixDaemon +<div align="center"> + <h1>☧ NixDaemon</h1> + <p><em>NixOS + home-manager for the PCSpecialist Valeon II 17: Hyprland in Lua, Caelestia Shell and kitty in Rosé Pine, the dead-GPU-fan workaround, and the hand-written toolbox.</em></p> +</div> -NixOS + home-manager for the PCSpecialist Valeon II 17 (TongFang GM7RGxM): -Hyprland (Lua config, uwsm) with Caelestia Shell, the dead-GPU-fan workaround, -and the hand-written toolbox. Built from the vault's -`04Tools/NixDaemon-Migration/` material on 2026-10-07. +<p align="center"> + <a href="https://nixos.org/"><img alt="NixOS unstable" src="https://img.shields.io/badge/NixOS-unstable-9ccfd8?style=for-the-badge&amp;labelColor=191724&amp;logo=nixos&amp;logoColor=e0def4"></a> + <a href="https://github.com/nix-community/home-manager"><img alt="home-manager" src="https://img.shields.io/badge/home--manager-module-c4a7e7?style=for-the-badge&amp;labelColor=191724&amp;logo=nixos&amp;logoColor=e0def4"></a> + <a href="https://hypr.land/"><img alt="Hyprland 0.56, Lua config" src="https://img.shields.io/badge/Hyprland-0.56_·_Lua-31748f?style=for-the-badge&amp;labelColor=191724&amp;logo=hyprland&amp;logoColor=e0def4"></a> + <a href="https://github.com/caelestia-dots/shell"><img alt="Caelestia Shell" src="https://img.shields.io/badge/Caelestia-shell-ebbcba?style=for-the-badge&amp;labelColor=191724"></a> + <a href="https://rosepinetheme.com/"><img alt="Rosé Pine" src="https://img.shields.io/badge/Theme-Ros%C3%A9_Pine-eb6f92?style=for-the-badge&amp;labelColor=191724"></a> +</p> -## Layout +<p align="center"> + <a href="https://www.zsh.org/"><img alt="zsh" src="https://img.shields.io/badge/Shell-zsh-f6c177?style=flat-square&amp;labelColor=26233a&amp;logo=zsh&amp;logoColor=e0def4"></a> + <a href="https://github.com/Mic92/sops-nix"><img alt="sops-nix" src="https://img.shields.io/badge/Secrets-sops--nix_·_age-9ccfd8?style=flat-square&amp;labelColor=26233a"></a> + <a href="https://secretspec.dev/"><img alt="secretspec" src="https://img.shields.io/badge/Runtime_secrets-secretspec-c4a7e7?style=flat-square&amp;labelColor=26233a"></a> + <a href="https://github.com/viperML/nh"><img alt="nh" src="https://img.shields.io/badge/Rebuild-nh-31748f?style=flat-square&amp;labelColor=26233a"></a> + <a href="https://jj-vcs.github.io/jj/"><img alt="jj colocated with git" src="https://img.shields.io/badge/VCS-jj_·_git-908caa?style=flat-square&amp;labelColor=26233a&amp;logo=git&amp;logoColor=e0def4"></a> + <img alt="Chi-Rho" src="https://img.shields.io/badge/%E2%98%A7-daemon--sec-ebbcba?style=flat-square&amp;labelColor=26233a"> +</p> +--- + +One flake, one machine: the TongFang GM7RGxM (Ryzen 9 6900HX, Radeon 680M, +RTX 3070 Ti, 2560×1440@240). Everything the machine is comes from here: +the kernel modules that keep the dead GPU fan from throttling the CPU, the +NVIDIA setup for Hyprland, the greeter, the compositor's Lua config and +keybinds, Caelestia as bar/launcher/lock, kitty with tmux-style keys, zsh +through the dotfiles checkout, the general tools, and the secrets. Built from +the vault's `04Tools/NixDaemon-Migration/` material on 2026-10-07. + +The companion repo is [`daemon-sec-dotfiles`](https://gitlab.com/DAEMON-404): +home-manager links every dotfile from its checkout (`~/git/daemon-sec-dotfiles`) +into `$HOME`, so editing the checkout edits the live config. + +## Structure + +```text +NixDaemon/ +├── flake.nix inputs: nixpkgs (unstable), nixpkgs-stable (26.05, bootstrap only), home-manager, +│ hyprland, caelestia-shell, caelestia-cli, llm-agents, sops-nix, nvf +│ outputs: nixosConfigurations.nixos (the target) and .bootstrap (stage A) +├── .sops.yaml, secrets/ sops-nix: the age recipient and the encrypted secrets file +├── hosts/laptop/ the machine +│ ├── default.nix boot, users (zsh login shell), greetd/tuigreet, Hyprland (uwsm), audio, fonts, +│ │ portals, nix-ld, LocalSend port, session environment +│ ├── fan-throttle-guard.nix vault gpu-fan-fix/, imported unchanged; fanfix + stability_guard.py beside it +│ ├── fan-extras.nix the performance power profile (fanfix install did this by hand on Arch) +│ ├── uniwill-laptop.nix the `uniwill` hwmon the guard reads: kernel 6.19 driver built for this kernel +│ ├── nvidia.nix open kernel module, panel on the dGPU, colon-free DRM device names for Hyprland +│ ├── ssd.nix Samsung 980 crypttab + /mnt/ssd (key restored by hand, or via sops) +│ ├── nix-settings.nix flakes, hyprland.cachix.org, nh (Nix helper) + weekly clean, nvd, nom +│ ├── toolbox.nix envfs (foreign shebangs), ~/.local/bin first on PATH, padx udev rule +│ ├── sops.nix sops-nix for the system: secrets/secrets.yaml → /run/secrets +│ ├── fan-cli.nix fan-ec: EC fan control as a store script, passwordless sudo for wheel +│ ├── fan-reference/ the Arch-era captures (fan-ctl, fan-state, units, confs) and their README +│ └── uniwill-laptop/ the driver sources (uniwill-acpi.c, uniwill-wmi.c) and their package.nix +├── hosts/bootstrap/ stage A: the GNOME install + fan fix + toolbox prerequisites (delete after stage B) +├── home/ home-manager for daemonsec +│ ├── default.nix imports the modules below +│ ├── modules/hyprland.nix Lua config wiring, helper scripts (wallpaper-picker, keybinds-menu, …), polkit, cliphist +│ ├── modules/caelestia.nix programs.caelestia: shell.json, the CLI with both Rosé Pine schemes, Dawn as the state +│ ├── modules/terminal.nix kitty: DMMono Nerd Font, Rosé Pine dark, tmux-style ctrl+a keys, Neovim copy mode +│ ├── modules/shell.nix zsh wiring (ZDOTDIR → dotfiles, fzf bindings, completions), tmux plugins, secretspec +│ ├── modules/dotfiles.nix every dotfile from ~/git/daemon-sec-dotfiles as an out-of-store symlink +│ ├── modules/tools.nix toolbox runtime closure, python env, the general CLI tools, `ns` (package search) +│ ├── modules/cheats.nix the cheat cards: `nix-cheat` (rebuild, nh, secrets) and `gpg-cheat` (home/cheats/*.md) +│ ├── modules/sops.nix sops-nix for the user; sops, age, ssh-to-age +│ ├── modules/neovim.nix nvf: Neovim with a small Nix-built plugin set, Rosé Pine, LSPs for this machine's languages +│ ├── modules/prompt.nix starship (two-line, one Rosé Pine colour per section) and fastfetch (NixOS logo) +│ ├── modules/fan.nix `fan`: status and watch without root; max/auto with the clamp watchdog +│ ├── modules/gtk.nix Yaru-purple icons, cursor, prefer-dark +│ ├── hypr/*.lua omarchy (shim) · core · looknfeel · defaults · bindings · lid · caelestia +│ ├── kitty/scrollback.lua kitty copy mode as a Neovim buffer +│ ├── caelestia/ scheme.json (dark) · scheme-dawn.json · rose-pine-{dark,dawn}.txt · shell-tokens.json +│ └── cheats/*.md the cards: nix.md, gpg.md +└── modules/workstation.nix Claude Code, Claude desktop, Obsidian, gh, glab ``` -flake.nix inputs: nixpkgs (unstable), nixpkgs-stable (26.05, bootstrap only), - home-manager, hyprland, caelestia-shell, caelestia-cli, llm-agents -hosts/laptop/ the machine - default.nix boot, users, greetd/tuigreet, Hyprland (uwsm), audio, fonts, portals - fan-throttle-guard.nix vault gpu-fan-fix/, imported unchanged; fanfix + stability_guard.py beside it - fan-extras.nix the performance power profile (fanfix install did this by hand on Arch) - uniwill-laptop.nix the `uniwill` hwmon the guard reads, built from Linux 6.19 sources (see below) - nvidia.nix open kernel module, panel on the dGPU, device order for Hyprland - ssd.nix Samsung 980 crypttab + /mnt/ssd (key restored by hand, see below) - nix-settings.nix flakes, hyprland.cachix.org, nh (Nix helper) + weekly clean, nvd, nom - toolbox.nix envfs (foreign shebangs), ~/.local/bin first on PATH, padx udev rule - fan-reference/ the Arch-era captures (fan-ctl, fan-state, units, confs) and their README -hosts/bootstrap/ stage A: today's GNOME install + fan fix + toolbox prerequisites -home/ home-manager for daemonsec - modules/hyprland.nix Lua config wiring, helper scripts, polkit agent, cliphist, udiskie - modules/caelestia.nix programs.caelestia, the carried shell.json, static Rosé Pine scheme - modules/terminal.nix kitty, DMMono Nerd Font, Rosé Pine colours - modules/tools.nix toolbox runtime closure, python env, dotfiles symlinks (fonts, cursors) - modules/gtk.nix Yaru-purple, cursor, prefer-dark - hypr/*.lua omarchy (shim) · core · defaults (stock Omarchy binds) · bindings · lid · caelestia - caelestia/ scheme.json, rose-pine-dark.txt (CLI scheme), shell-tokens.json -modules/workstation.nix Claude Code, Claude desktop, Obsidian, gh, glab (from the bootstrap script) -``` -## Applying +## What runs + +| Layer | Choice | Where | +|---|---|---| +| Boot, login | systemd-boot · greetd + tuigreet (remembers user and session) | hosts/laptop/default.nix | +| Compositor | Hyprland 0.56 under uwsm, configured in Lua; springy overshoot animations, square corners, blur | home/hypr/, home/modules/hyprland.nix | +| Shell UI | Caelestia Shell: bar, launcher, dashboard, sidebar, lock, OSD, notifications. Rosé Pine dark, framed bar, 340 px panels | home/modules/caelestia.nix, home/caelestia/ | +| Terminal | kitty, DMMono Nerd Font, Rosé Pine dark, `ctrl+a` tmux-style prefix, copy mode in Neovim | home/modules/terminal.nix | +| Shell | zsh as login shell; config = the dotfiles' `~/.dotfiles` tree (autosuggestions, syntax highlighting, fzf-tab, zoxide, atuin). Prompt and fetch are Nix-managed | home/modules/shell.nix, prompt.nix | +| Dotfiles | symlinked from `~/git/daemon-sec-dotfiles/home` | home/modules/dotfiles.nix | +| Editor | Neovim via nvf: treesitter, LSP, blink completion, telescope, oil, gitsigns, which-key, Rosé Pine | home/modules/neovim.nix | +| Tools | uv, nodejs, btop, yazi, lazygit, tmux, ripgrep/fd/fzf/bat/eza, delta, tldr, LocalSend, … | home/modules/tools.nix | +| Secrets | sops-nix (age) for the repo; secretspec (keyring) for projects | hosts/laptop/sops.nix, home/modules/sops.nix, shell.nix | +| GPU | NVIDIA open module, panel on the RTX; Aquamarine told the GPUs by colon-free udev symlinks | hosts/laptop/nvidia.nix | +| Fan | `fan-throttle-guard`: 3.2 GHz floor, EC auto fan, `uniwill` hwmon from a 6.19 driver. `fan` command: status, watch, max/auto with a clamp watchdog | hosts/laptop/fan-*.nix, uniwill-laptop/, home/modules/fan.nix | +| Nix | flakes, Hyprland cache, `nh` with weekly `clean all --keep 5 --keep-since 14d` | hosts/laptop/nix-settings.nix | + +## Setting it up + +### Day to day (this machine) -Everything below needs `sudo`, so it is left to the owner. +```sh +nh os switch # build, nvd diff, sudo, activate; = sudo nixos-rebuild switch --flake ~/NixDaemon#nixos +nh os boot # same, but activate on next boot (kernel / driver changes) +nix-cheat # the full card: rebuild, remote, nh, home, search, update, rollback, clean, … +nix-cheat nh # one section +ns kitty # fuzzy search nixpkgs + NixOS/home-manager options, with descriptions +``` -`nh` (Nix helper) is installed by both configurations; once Stage A is live it -is the nicer way to run the same steps. It shows a diff of what a generation -changes before asking for sudo, and the weekly `nh clean all` keeps the store -tidy (last 5 generations, 14 days). `NH_FLAKE` already points at this repo. +home-manager is a NixOS module here, so one rebuild does both; there is no +separate `home-manager switch`. New files must be `git add`ed before nix sees +them (the repo is jj, colocated with git; `nix-cheat repo`). + +### From the repo, without a checkout ```sh -nh os switch -H bootstrap # same as the Stage A command below -nh os boot # Stage B (picks nixosConfigurations.nixos by hostname) -nh os build; nvd diff /run/current-system result # dry look at what would change -nh search <package> # nixpkgs search -nh clean all --keep 5 --keep-since 14d +sudo nixos-rebuild switch --flake gitlab:DAEMON-404/NixDaemon#nixos +nh os switch gitlab:DAEMON-404/NixDaemon +nix flake show gitlab:DAEMON-404/NixDaemon ``` +### Fresh install + +1. Boot the NixOS live ISO, partition and mount at `/mnt`; generate + `hardware-configuration.nix` with `nixos-generate-config --root /mnt` and + compare it with `hosts/laptop/hardware-configuration.nix` (UUIDs). +2. `git clone https://gitlab.com/DAEMON-404/NixDaemon && cd NixDaemon`, drop + the new hardware file into `hosts/laptop/`, `git add` it. +3. `sudo nixos-install --flake .#nixos`, reboot, log in at tuigreet, pick + **Hyprland (UWSM)** once. +4. Clone the dotfiles to `~/git/daemon-sec-dotfiles` (the links in + `home/modules/dotfiles.nix` point there) and the toolbox to `~/.local/bin`. +5. Restore the age key to `~/.config/sops/age/keys.txt` and copy it for the + system (see Secrets), then the Samsung SSD key (below). + +### The staged migration this repo was built for (2026-10-07) + +Everything below needs `sudo`, so it was left to the owner. + **Stage A: fan fix now, on the GNOME install.** Small switch (fan module, driver, toolbox prerequisites, Hyprland cache). The new kernel modules only load from the booted system, hence the reboot. ```sh -sudo nixos-rebuild switch --flake ~/NixDaemon#bootstrap && sudo reboot +sudo nixos-rebuild switch --flake ~/NixDaemon#bootstrap && sudo reboot # or: nh os switch -H bootstrap ``` First-boot check (vault README and gpu-fan-fix/README.md): @@ -70,18 +158,18 @@ cat /run/motherboard-stability/status.json # limit_mhz 3200, thermal_stage 0, `fanfix status` will say "cap is not persisted": on NixOS the floor is the `systemd.tmpfiles.rules` line in the module, not `/etc/tmpfiles.d/99-cpu-freq-cap.conf`. Treat `fanfix install` / `uninstall` / `fan setup` as no-ops here; change -`capKhz` in the module instead (gpu-fan-fix README). +`capKhz` in the module instead (gpu-fan-fix README). `fanfix-fan` is expected +inactive: its manual fan mode makes the EC clamp all cores to 399 MHz under +load; EC auto fan with the 3.2 GHz floor passed `fanfix test 30` at 61 °C. -**Stage B: the desktop.** Build, then boot into it (everything heavy is already -in the store if the build below finished; otherwise this downloads Hyprland -from its cache and compiles the NVIDIA modules). +**Stage B: the desktop.** ```sh -sudo nixos-rebuild boot --flake ~/NixDaemon#nixos && sudo reboot +sudo nixos-rebuild boot --flake ~/NixDaemon#nixos && sudo reboot # or: nh os boot ``` tuigreet appears on tty1; pick `Hyprland (UWSM)` once, it is remembered. -Then the second vault check, the keybind diff: +Then the keybind diff against the vault capture: ```sh hyprctl binds -j | python3 -I -c 'import json,sys @@ -93,10 +181,9 @@ cut -d'|' -f1-4 ~/git/NetrunnerVault/04Tools/NixDaemon-Migration/shortcuts/keybi Expected differences: the keycode binds (workspaces, resize, bar panels, group windows) show `code:0` in the capture and an empty key here; the keys -caelestia.lua takes over carry their Caelestia descriptions (launcher, -session menu, panels, notifications, media keys); the stock Obsidian and -YouTube lines are gone because vault-open and bakx own those keys; the two -webcam-overlay binds were not carried (keycodes unknown). +caelestia.lua takes over carry their Caelestia descriptions; the stock +Obsidian and YouTube lines are gone because vault-open and bakx own those +keys; the two webcam-overlay binds were not carried (keycodes unknown). Afterwards delete `hosts/bootstrap/` and the `nixpkgs-stable` input. @@ -110,7 +197,42 @@ sudo chmod 400 /etc/secrets/ssd.key sudo systemctl restart systemd-cryptsetup@ssd.service mnt-ssd.mount # or just reboot ``` -Until then the drive stays locked; both units are `nofail`, so boot is unaffected. +Until then the drive stays locked; both units are `nofail`, so boot is +unaffected. The sops way: `sops set secrets/secrets.yaml '["ssd.key"]' "\"$(gpg -d ssd.key.gpg)\""`, +then `sops.secrets."ssd.key".path = "/etc/secrets/ssd.key";` in hosts/laptop/sops.nix. + +## The shell + +zsh is the login shell (hosts/laptop/default.nix) and its configuration is +the dotfiles checkout's `home/.dotfiles` tree, reached through a Nix-managed +`~/.zshenv` that sets `ZDOTDIR=~/.dotfiles` (home/modules/shell.nix). The +plugins (autosuggestions, syntax highlighting, zsh-completions, fzf-tab, +history-substring-search, you-should-use) are the copies vendored in that +tree; starship, zoxide, atuin and fzf come from nixpkgs. Edit +`~/git/daemon-sec-dotfiles/home/.dotfiles/config/*.zsh` and open a new shell. + +Things the shell modules want that this build provides: `~/.fzf.zsh` (fzf's +key bindings from the store, core.zsh only knows the Arch paths), the tool +configs linked into `~/.config` by dotfiles.nix (bat theme, atuin, crossfetch +for the splash animation, cheats, eza, btop, yazi, lazygit, carapace, and the +rest of the checkout's .config; the starship prompt and the fastfetch card are +Nix-managed in home/modules/prompt.nix), `~/.tmux.conf` with +its plugins from nixpkgs behind a TPM stand-in, and the binaries modern.zsh +aliases (btop, dust, duf, procs, delta, tldr, hyperfine, glow, onefetch, +lazygit, yazi, neovim). Not carried: `mise` (its downloaded runtimes +duplicate nixpkgs; `nix-ld` is on so `uv`'s managed Pythons work), and the +repo's git config (it turns on commit signing with a key that is not on this +machine; `~/.gitconfig` stays). + +## The dotfiles + +`home/modules/dotfiles.nix` mirrors `~/git/daemon-sec-dotfiles/home/` into +`$HOME` with out-of-store symlinks: editing the checkout edits the live +config, and a rebuild is only needed to add or remove a path in the list. +The header of that file names what is deliberately not linked (hypr and +kitty are Nix-managed, the omarchy trees, the systemd units, mimeapps, +git's signing config, the bash rc files, `.claude`/`.codex`, the toolbox +`bin` directories) and why. ## The toolbox @@ -128,7 +250,64 @@ and envfs resolves the `#!/bin/bash` and `#!/usr/bin/python3` shebangs. quietly); `clipboard-backup` and `omarchy-menu-tmux-keybindings` are bound but not in the carried `bin/`. - The cheat popups (`omarchy-menu-kitty`, …) pipe into `omarchy-menu-select`, - provided here as a fuzzel wrapper (home/modules/hyprland.nix). + provided here as a fuzzel wrapper (home/modules/hyprland.nix). `nix-cheat` + and `gpg-cheat` are this repo's own cards, in the same style. + +## Secrets + +Two tools. **sops-nix** keeps secrets *in this repo*, encrypted with age +(`.sops.yaml` names the recipient, `secrets/secrets.yaml` holds the values) +and decrypts them at activation: `/run/secrets/NAME` for the system +(hosts/laptop/sops.nix), `~/.config/sops-nix/secrets/NAME` for the user +(home/modules/sops.nix). The age key is `~/.config/sops/age/keys.txt`, +created on 2026-10-08 and **not in the repo**; back it up (vault) and give +the system its copy once: + +```sh +sudo install -D -m 600 -o root -g root ~/.config/sops/age/keys.txt /var/lib/sops-nix/key.txt +``` + +Edit with `sops secrets/secrets.yaml`, declare with `sops.secrets.NAME = { };`, +rebuild. `nix-cheat secrets` has the commands. + +**secretspec** is for a project's runtime secrets: declared next to the +project in `secretspec.toml`, values in the system keyring +(`~/.config/secretspec/config.toml`: provider `keyring`, profile `default`; +gnome-keyring is unlocked at login by PAM). `secretspec init`, `secretspec +add NAME`, `secretspec check`, `secretspec run -- cmd`. + +## Look and keys + +- **Caelestia in Rosé Pine dark** (main), translucent over blur, with its + framed bar: a 10 px border with 25 px rounded inner corners, clock and + tray in pills, filled occupied workspaces, the Nix snowflake as the logo. + Sidebar, utilities and notification panels are narrower than stock + (`home/caelestia/shell-tokens.json`). A Dawn mapping is registered too: + `caelestia scheme set -n rose-pine -f rose-pine-dawn`. +- **Bar shows the program**, not the window title: a small patch to the + shell's ActiveWindow component (`home/caelestia/active-window-program-name.patch`, + applied in caelestia.nix) makes compact mode use the desktop entry's name + for the window class. The shell compiles locally because of it. +- **More shell**: desktop clock on the wallpaper (bottom right), audio + visualiser along the bottom while something plays, weather on the + dashboard (Douglas), audio and microphone status icons, lock screen over + the wallpaper, a toast on track change, vim keys in the launcher, and + idle: lock after 15 min, screen off after 20 (audio or an inhibitor holds + both off). +- **Springy windows**: `home/hypr/looknfeel.lua` carries the dotfiles' + animation rice (overshoot curves on move/resize/open, shadows, blur + tuning, drag snapping, swallow, 3-finger workspace swipe, 4-finger-up + fullscreen). Loaded after core.lua. +- **kitty, tmux-style** (`ctrl+a` prefix; table in home/modules/terminal.nix): + `c` tab, `-`/`|` splits, `hjkl` panes, `z` zoom, `[` copy mode in Neovim + (`v` select, `y` copy, Enter copy and leave, `q`), `]` paste, `1..9` tabs, + `ctrl+a ctrl+a` sends a real ctrl+a to the shell. +- `CTRL+SUPER+SPACE` opens the Caelestia launcher in its wallpaper grid + (helper `wallpaper-picker`); `>wallpaper name` filters. The directory is + `paths.wallpaperDir` in home/modules/caelestia.nix. +- Keys to try first: SUPER+RETURN terminal, SUPER+SPACE launcher, + SUPER+ESCAPE session menu, SUPER+K keybindings list, SUPER+M window mode, + SUPER+` dropdown terminal, PRINT screenshot. ## Why `uniwill-laptop` is built here @@ -151,7 +330,10 @@ NixOS kernel ships it. RTX 3070 Ti (discrete). The hybrid alternative is a commented block in nvidia.nix; it only applies after changing the MUX in the BIOS. - **Hostname** stays `nixos` (the installer's). The flake output is also `nixos`. -- Programs some stock Omarchy keys expect but which are not in the toolbox - closure (spotify, lazydocker, btop, tmux, yazi, neovim, 1password, - signal): those keys show a notification saying so. Add packages to +- Stock Omarchy keys whose program is still not installed (spotify, + 1password, signal) show a notification saying so. Add packages to home/modules/tools.nix when wanted. + +--- + +<p align="center">☧ · <a href="https://rosepinetheme.com/">Rosé Pine</a> all the way down · built with Claude Code</p> diff --git a/flake.lock b/flake.lock @@ -131,6 +131,22 @@ "type": "github" } }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1777699697, + "narHash": "sha256-Eg9b/rq/ECYwNwEXs5i9wHyhxNI0JrYx2srdI2uZMaQ=", + "ref": "refs/heads/main", + "rev": "382052b74656a369c5408822af3f2501e9b1af81", + "revCount": 94, + "type": "git", + "url": "https://git.lix.systems/lix-project/flake-compat.git" + }, + "original": { + "type": "git", + "url": "https://git.lix.systems/lix-project/flake-compat.git" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": [ @@ -564,6 +580,21 @@ "type": "github" } }, + "mnw": { + "locked": { + "lastModified": 1784154424, + "narHash": "sha256-HRn4O1X2ShYtbq5Egji72/aemyc2cIQicjRZD9hBqM8=", + "owner": "Gerg-L", + "repo": "mnw", + "rev": "0151d9fa87992cbeb67c86616fb9170dcec830b6", + "type": "github" + }, + "original": { + "owner": "Gerg-L", + "repo": "mnw", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1791048980, @@ -628,6 +659,28 @@ "type": "github" } }, + "nvf": { + "inputs": { + "flake-compat": "flake-compat_2", + "mnw": "mnw", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791215772, + "narHash": "sha256-GBC/HfZM1Bh5dM/B0csBr8YoVGk3DTmCF/Qx646kThE=", + "owner": "notashelf", + "repo": "nvf", + "rev": "b5a8011df7e743d5bd992d25983d548f99e9ff83", + "type": "github" + }, + "original": { + "owner": "notashelf", + "repo": "nvf", + "type": "github" + } + }, "pre-commit-hooks": { "inputs": { "flake-compat": "flake-compat", @@ -679,7 +732,29 @@ "hyprland": "hyprland", "llm-agents": "llm-agents", "nixpkgs": "nixpkgs_3", - "nixpkgs-stable": "nixpkgs-stable" + "nixpkgs-stable": "nixpkgs-stable", + "nvf": "nvf", + "sops-nix": "sops-nix" + } + }, + "sops-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791103873, + "narHash": "sha256-nFxM+pKoZ8LJAEnUXARyCaOAloWgaW9kZQOSjWzKcTE=", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "dcd241ba97088c22569d1573286e1b9daad340c0", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "sops-nix", + "type": "github" } }, "systems": { diff --git a/flake.nix b/flake.nix @@ -33,6 +33,19 @@ # Claude Code and the Claude desktop app (modules/workstation.nix). llm-agents.url = "github:numtide/llm-agents.nix"; + + # Secrets: encrypted in secrets/ with age, decrypted at activation + # (hosts/laptop/sops.nix for the system, home/modules/sops.nix for the user). + sops-nix = { + url = "github:Mic92/sops-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + # Neovim, configured in Nix (home/modules/neovim.nix). + nvf = { + url = "github:notashelf/nvf"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = diff --git a/home/caelestia/active-window-program-name.patch b/home/caelestia/active-window-program-name.patch @@ -0,0 +1,26 @@ +--- a/modules/bar/components/ActiveWindow.qml ++++ b/modules/bar/components/ActiveWindow.qml +@@ -1,6 +1,7 @@ + pragma ComponentBehavior: Bound + + import QtQuick ++import Quickshell + import Caelestia.Config + import Caelestia.I18n + import qs.components +@@ -19,6 +20,15 @@ + if (!title) + return Tr.trCtx("Desktop", "shown when no window is focused"); + if (Config.bar.activeWindow.compact) { ++ // NixDaemon: show the program, not the window title. The desktop ++ // entry's name for the window class, else the class itself, else ++ // the last " - " segment of the title as upstream does. ++ const cls = Hypr.activeToplevel?.lastIpcObject.class; ++ const entry = cls ? DesktopEntries.heuristicLookup(cls) : null; ++ if (entry?.name) ++ return entry.name; ++ if (cls) ++ return cls; + // " - " (standard hyphen), " — " (em dash), " – " (en dash) + const parts = title.split(/\s+[\-\u2013\u2014]\s+/); + if (parts.length > 1) diff --git a/home/caelestia/rose-pine-dawn.txt b/home/caelestia/rose-pine-dawn.txt @@ -0,0 +1,74 @@ +background faf4ed +surface faf4ed +surfaceDim f2e9e1 +surfaceBright fffaf3 +surfaceContainerLowest fffaf3 +surfaceContainerLow f4ede8 +surfaceContainer f2e9e1 +surfaceContainerHigh dfdad9 +surfaceContainerHighest cecacd +surfaceVariant f2e9e1 +onBackground 575279 +onSurface 575279 +onSurfaceVariant 797593 +outline 9893a5 +outlineVariant dfdad9 +inverseSurface 575279 +inverseOnSurface faf4ed +inversePrimary eb6f92 +surfaceTint b4637a +shadow 000000 +scrim 000000 +primary_paletteKeyColor b4637a +secondary_paletteKeyColor 907aa9 +tertiary_paletteKeyColor 56949f +neutral_paletteKeyColor 9893a5 +neutral_variant_paletteKeyColor 797593 +term0 f2e9e1 +term1 b4637a +term2 286983 +term3 ea9d34 +term4 56949f +term5 907aa9 +term6 d7827e +term7 575279 +term8 9893a5 +term9 b4637a +term10 286983 +term11 ea9d34 +term12 56949f +term13 907aa9 +term14 d7827e +term15 575279 +primary b4637a +onPrimary fffaf3 +primaryContainer f3dde4 +onPrimaryContainer 7a3650 +primaryFixed f3dde4 +primaryFixedDim b4637a +onPrimaryFixed 7a3650 +onPrimaryFixedVariant 7a3650 +secondary 907aa9 +onSecondary fffaf3 +secondaryContainer e9e1f0 +onSecondaryContainer 5a4a6e +secondaryFixed e9e1f0 +secondaryFixedDim 907aa9 +onSecondaryFixed 5a4a6e +onSecondaryFixedVariant 5a4a6e +tertiary 56949f +onTertiary fffaf3 +tertiaryContainer dbe9ea +onTertiaryContainer 2f5a62 +tertiaryFixed dbe9ea +tertiaryFixedDim 56949f +onTertiaryFixed 2f5a62 +onTertiaryFixedVariant 2f5a62 +error b4637a +onError fffaf3 +errorContainer f3dde4 +onErrorContainer 7a3650 +success 286983 +onSuccess fffaf3 +successContainer dbe6ec +onSuccessContainer 1f4e62 diff --git a/home/caelestia/scheme-dawn.json b/home/caelestia/scheme-dawn.json @@ -0,0 +1,82 @@ +{ + "name": "rose-pine", + "flavour": "rose-pine-dawn", + "mode": "light", + "variant": "tonalspot", + "colours": { + "background": "faf4ed", + "surface": "faf4ed", + "surfaceDim": "f2e9e1", + "surfaceBright": "fffaf3", + "surfaceContainerLowest": "fffaf3", + "surfaceContainerLow": "f4ede8", + "surfaceContainer": "f2e9e1", + "surfaceContainerHigh": "dfdad9", + "surfaceContainerHighest": "cecacd", + "surfaceVariant": "f2e9e1", + "onBackground": "575279", + "onSurface": "575279", + "onSurfaceVariant": "797593", + "outline": "9893a5", + "outlineVariant": "dfdad9", + "inverseSurface": "575279", + "inverseOnSurface": "faf4ed", + "inversePrimary": "eb6f92", + "surfaceTint": "b4637a", + "shadow": "000000", + "scrim": "000000", + "primary_paletteKeyColor": "b4637a", + "secondary_paletteKeyColor": "907aa9", + "tertiary_paletteKeyColor": "56949f", + "neutral_paletteKeyColor": "9893a5", + "neutral_variant_paletteKeyColor": "797593", + "term0": "f2e9e1", + "term1": "b4637a", + "term2": "286983", + "term3": "ea9d34", + "term4": "56949f", + "term5": "907aa9", + "term6": "d7827e", + "term7": "575279", + "term8": "9893a5", + "term9": "b4637a", + "term10": "286983", + "term11": "ea9d34", + "term12": "56949f", + "term13": "907aa9", + "term14": "d7827e", + "term15": "575279", + "primary": "b4637a", + "onPrimary": "fffaf3", + "primaryContainer": "f3dde4", + "onPrimaryContainer": "7a3650", + "primaryFixed": "f3dde4", + "primaryFixedDim": "b4637a", + "onPrimaryFixed": "7a3650", + "onPrimaryFixedVariant": "7a3650", + "secondary": "907aa9", + "onSecondary": "fffaf3", + "secondaryContainer": "e9e1f0", + "onSecondaryContainer": "5a4a6e", + "secondaryFixed": "e9e1f0", + "secondaryFixedDim": "907aa9", + "onSecondaryFixed": "5a4a6e", + "onSecondaryFixedVariant": "5a4a6e", + "tertiary": "56949f", + "onTertiary": "fffaf3", + "tertiaryContainer": "dbe9ea", + "onTertiaryContainer": "2f5a62", + "tertiaryFixed": "dbe9ea", + "tertiaryFixedDim": "56949f", + "onTertiaryFixed": "2f5a62", + "onTertiaryFixedVariant": "2f5a62", + "error": "b4637a", + "onError": "fffaf3", + "errorContainer": "f3dde4", + "onErrorContainer": "7a3650", + "success": "286983", + "onSuccess": "fffaf3", + "successContainer": "dbe6ec", + "onSuccessContainer": "1f4e62" + } +} diff --git a/home/caelestia/shell-tokens.json b/home/caelestia/shell-tokens.json @@ -1,3 +1,17 @@ { - "sizes": { "bar": { "innerWidth": 28 } } + "sizes": { + "bar": { + "innerWidth": 36 + }, + "sidebar": { + "width": 340 + }, + "utilities": { + "width": 340, + "toastWidth": 340 + }, + "notifs": { + "width": 360 + } + } } diff --git a/home/cheats/gpg.md b/home/cheats/gpg.md @@ -0,0 +1,321 @@ +# gpg — the key hierarchy, and every verb + +GnuPG **2.4.9** here. Pinentry is the GNOME dialog (NixOS `programs.gnupg.agent`), `GPG_TTY` is exported by core.zsh, +and **`gpgx`** (`gpgx-cheat`) is the no-flags wrapper for the daily lock/unlock/sign/verify. This card is the real thing underneath. +Terminal card: **`gpg-cheat`** — sections `model setup keygen subkeys backup import sign verify encrypt decrypt edit revoke ssh git keyservers trust inspect offline agent fix mine`. +`FPR` below = a full 40-hex fingerprint. Yours (the main key): `3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4`. + +## model — one primary key, several subkeys + +```text +primary key [C] certify = the identity. Signs your own subkeys and user IDs, and other people's keys. + Keep it OFFLINE once the subkeys exist; it only comes out to add/revoke subkeys or UIDs. +subkey [S] sign = signs files, commits, mail. Rotate yearly without touching the identity. +subkey [E] encrypt = what others encrypt TO. Rotating it does not re-encrypt old mail; keep old ones to read it. +subkey [A] authenticate = SSH login (gpg-agent as the ssh agent). +user ID "Name <mail>" = one per address; the primary one is what people see first. +``` + +- Capabilities show in `gpg -K` as `[SC]`, `[E]`, `[A]`. A primary with `[SC]` signs with the identity key itself, which is fine but not rotatable. +- The **fingerprint** (40 hex) is the key; the "key ID" is its last 16 (long) or 8 (short, collidable — never use). +- `sec` = secret primary present. `sec#` = primary absent (offline), only subkeys here. `ssb` = secret subkey. +- Ownertrust (how much you trust a key to vouch for others) is separate from validity (is this key really theirs). `[ultimate]` is your own. + +## setup — ~/.gnupg and the agent + +```sh +# ~/.gnupg/gpg.conf (create it; sane modern defaults) +keyid-format 0xlong +with-fingerprint +with-subkey-fingerprints +default-key 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4 +default-recipient-self # `gpg -e file` encrypts to you when no -r given +personal-cipher-preferences AES256 AES192 AES +personal-digest-preferences SHA512 SHA384 SHA256 +cert-digest-algo SHA512 +no-emit-version +no-comments +keyserver hkps://keys.openpgp.org +auto-key-locate local,wkd # find a key by mail address (WKD) before asking a keyserver +trust-model tofu+pgp # remember first-seen keys per address, warn on change + +# ~/.gnupg/gpg-agent.conf +default-cache-ttl 3600 # seconds a passphrase stays cached after last use +max-cache-ttl 28800 # hard ceiling +# pinentry-program is set by NixOS (hosts/laptop/default.nix: pinentryPackage = pinentry-gnome3) +``` + +```sh +chmod 700 ~/.gnupg # gpg refuses "unsafe permissions" otherwise +gpg-connect-agent reloadagent /bye # after editing gpg-agent.conf +gpgconf --kill gpg-agent # restart it entirely (it relaunches on demand) +gpg --version # algorithms available +``` + +## keygen — a proper key, the modern way + +Certify-only primary, then three subkeys. Ed25519/Curve25519 throughout (fast, small, the 2.4 default). + +```sh +gpg --quick-generate-key 'DAEMON-SEC <zer0sec.xp@icloud.com>' ed25519 cert 2y # primary: [C] only, 2-year expiry +FPR=$(gpg -K --with-colons 'zer0sec.xp@icloud.com' | awk -F: '$1=="fpr"{print $10; exit}') +gpg --quick-add-key "$FPR" ed25519 sign 1y # [S] +gpg --quick-add-key "$FPR" cv25519 encr 1y # [E] +gpg --quick-add-key "$FPR" ed25519 auth 1y # [A] (for SSH) +gpg -K --with-subkey-fingerprints # see the four, check capabilities and expiries +``` + +- Expiry is a **safety net**, not a deadline: extend it any time with the primary key (see *edit*). A key that expires with its owner gone is self-cleaning. +- Interactive version: `gpg --full-generate-key --expert` → `(11) ECC (set your own capabilities)` → toggle `S` off so only `Certify` remains → `Curve 25519`. Then add subkeys with `--quick-add-key` or in `--edit-key` → `addkey`. +- A passphrase protects the secret key *file*; the agent caches it. Use a long one; the pinentry dialog asks. +- The revocation certificate is written automatically to `~/.gnupg/openpgp-revocs.d/FPR.rev` — back it up with the key (see *backup*). + +## subkeys — add, rotate, drop + +```sh +gpg --quick-add-key FPR ed25519 sign 1y # new signing subkey (old one stays until you revoke it) +gpg --quick-set-expire FPR 1y '*' # extend EVERY subkey a year (`'*'` = all subkeys; a sub-fpr = that one) +gpg --quick-set-expire FPR 2y # extend the primary +gpg --edit-key FPR # interactive: `key 2` selects subkey 2 (the `*` marks it), then + # `expire` / `revkey` / `delkey` / `passwd` / `save` +gpg -K --with-colons | awk -F: '$1=="ssb"{print $12, $5}' # capability letters + key id of each subkey +``` + +Old encryption subkeys: **keep them** (revoked or expired, still in the ring) or you lose the ability to read what was sent to them. + +## backup — export, revocation, paper + +```sh +gpg --armor --export FPR > daemon-sec.pub.asc # public key: share freely +gpg --armor --export-secret-keys FPR > daemon-sec.SECRET.asc # everything: primary + subkeys (passphrase-protected) +gpg --armor --export-secret-subkeys FPR > daemon-sec.subkeys.asc # subkeys only (what a laptop should carry; see *offline*) +cp ~/.gnupg/openpgp-revocs.d/FPR.rev daemon-sec.rev # the revocation certificate +gpg --export-ownertrust > ownertrust.txt # your trust assignments +gpg --gen-revoke FPR > daemon-sec.rev # make a fresh revocation cert by hand +nix shell nixpkgs#paperkey -c sh -c 'gpg --export-secret-keys FPR | paperkey > daemon-sec.paper.txt' # printable secret bits +``` + +Where it goes: the vault (encrypted), never the dotfiles repo. The SECRET export + the .rev are the two files that matter. +The secret export is still encrypted with your passphrase; the passphrase is the third thing to keep. + +## import — keys, trust, restore + +```sh +gpg --import daemon-sec.pub.asc # someone's public key (or your own on a new machine) +gpg --import daemon-sec.SECRET.asc # restore: asks for the passphrase +gpg --import-ownertrust < ownertrust.txt +gpg --edit-key FPR trust # set ownertrust: 5 = ultimate (yours), 4 = full, 3 = marginal +gpg --lsign-key FPR # "I checked this key": local signature, never exported +gpg --sign-key FPR # exportable certification (web of trust) +gpg --show-keys someone.asc # look at a key file WITHOUT importing it +gpg --delete-keys FPR # forget a public key; --delete-secret-keys for the secret part first +``` + +On a fresh machine: import the SECRET file, `gpg --edit-key FPR trust` → `5`, done. Or import only the subkeys file for a laptop. + +## sign — files, text, commits + +```sh +gpg --detach-sign --armor file # file.asc beside it: the usual way to sign a release/file +gpg --detach-sign file # binary file.sig +gpg --clearsign message.txt # message.txt.asc: readable text with a signature block (mail, announcements) +gpg --sign file # file.gpg: compressed file + signature in one (recipient runs gpg -d) +gpg -u FPR --detach-sign file # pick the key (-u / --local-user); `FPR!` = that exact subkey +echo "text" | gpg --clearsign # from a pipe +gpg --sign --encrypt -r mail file # sign AND encrypt (see *encrypt*) +``` + +Signing uses the `[S]` subkey automatically (or the primary if it has S). The agent asks for the passphrase once per cache TTL. + +## verify — did this come from them, unchanged + +```sh +gpg --verify file.asc file # detached signature (.asc/.sig) + the file +gpg --verify file.sig # gpg finds `file` next to it +gpg --verify message.txt.asc # clearsigned text +gpg --decrypt file.gpg > file # inline-signed: extracts the file and prints the verification +gpg --verify --verbose file.asc file # which key, which subkey, when +``` + +Reading the result: **Good signature from "Name <mail>"** = cryptographically valid. The warning +`This key is not certified with a trusted signature` means you have not set ownertrust / signed their key +— the signature is still valid; the question is whether the key is really theirs. Compare the fingerprint +out-of-band once, then `gpg --lsign-key FPR` and the warning goes away. +`BAD signature` = the file changed or the signature is for another file. Exit code 0 only on Good. + +## encrypt — to people, to yourself, with a passphrase + +```sh +gpg --encrypt --recipient mail@example.com --armor file # file.asc, readable only by that key +gpg -e -r mail@example.com -r zer0sec.xp@icloud.com file # several recipients (add yourself to read it later!) +gpg -e file # to yourself (default-recipient-self in gpg.conf) +gpg -se -r mail file # sign + encrypt: they know it is from you +gpg --symmetric --cipher-algo AES256 file # passphrase only, no keys (file.gpg); shares via a channel you trust +gpg -c --armor file # same, armored +gpg -o out.gpg -e -r mail file # choose the output name +tar cz directory | gpg -e -r mail > directory.tgz.gpg # a whole directory, through a pipe +gpg --hidden-recipient mail -e file # do not reveal who it is for (-R) +gpg --encrypt-to FPR # in gpg.conf: ALWAYS add this recipient (yourself) silently +``` + +- `--armor` (`-a`) makes ASCII text you can paste into mail; without it the output is binary (smaller). +- Encrypt uses the recipient's `[E]` subkey. "No public key" = you have not imported theirs; "unusable public key" = theirs expired. +- Symmetric + a strong passphrase is fine for backups and for sending to someone with no key. + +## decrypt — and what to do when it fails + +```sh +gpg --decrypt file.gpg > file # -d: to stdout +gpg -o file -d file.gpg # to a named file +gpg file.gpg # guesses: decrypts (or verifies) and writes `file` +gpg --decrypt-files *.gpg # many at once, each to its name without .gpg +gpg -d file.gpg | tar xz # straight into tar +gpg --list-packets file.gpg # WHO can decrypt this: the key IDs it was encrypted to, the algorithms +``` + +"decryption failed: No secret key" = it was not encrypted to any key you hold (check with `--list-packets`; +compare with `gpg -K`). On a laptop with subkeys only, that is fine as long as the `[E]` subkey is there. + +## edit — identities, passphrase, expiry + +```sh +gpg --quick-add-uid FPR 'DAEMON-SEC <zer0sec.xp@icloud.com>' # add a user ID (fix a name, add an address) +gpg --quick-set-primary-uid FPR 'DAEMON-SEC <zer0sec.xp@icloud.com>' +gpg --quick-revoke-uid FPR 'DAMEON-NIX <zer0sec.xp@icloud.com>' # retire a UID (keys cannot delete published UIDs; revoke them) +gpg --change-passphrase FPR # new passphrase for the secret key +gpg --quick-set-expire FPR 2y # primary expiry; `0` = never +gpg --quick-set-expire FPR 1y '*' # all subkeys +gpg --edit-key FPR # the interactive editor; `help` lists everything: +# uid N / key N select · adduid deluid revuid primary · addkey delkey revkey expire · passwd · trust · save / quit +``` + +Every change to UIDs or subkeys needs the **primary** secret key present (`sec`, not `sec#`). Then re-export the public key: others must re-import it to see the change. + +## revoke — when a key is lost or compromised + +```sh +gpg --import daemon-sec.rev # the stored revocation certificate: marks YOUR key revoked locally +gpg --keyserver hkps://keys.openpgp.org --send-keys FPR # publish the revocation so others see it +gpg --edit-key FPR → key 2 → revkey → save # revoke ONE subkey (compromised laptop: revoke its subkeys, keep the primary) +gpg --quick-revoke-uid FPR 'uid string' # revoke an identity +``` + +Revoked ≠ deleted: the key stays in the ring so old signatures still verify (as "made with a revoked key") and old mail still decrypts. +If the laptop is lost and you kept the primary offline: revoke its subkeys, make new ones, publish. The identity survives. + +## ssh — the [A] subkey as your SSH key + +```sh +# hosts/laptop/default.nix: programs.gnupg.agent.enableSSHSupport = true; # gpg-agent becomes the ssh agent (SSH_AUTH_SOCK) +gpg -K --with-keygrip # the keygrip of the [A] subkey +echo KEYGRIP >> ~/.gnupg/sshcontrol # tell the agent to serve it +gpg --export-ssh-key FPR # the public key in authorized_keys format +gpg --export-ssh-key FPR >> ~/.ssh/authorized_keys_for_that_host # paste it where you log in +ssh-add -L # the agent now lists it +``` + +Your file key `~/.ssh/id_ed25519` keeps working next to it; ssh tries agent keys first. Use one or the other per host in `~/.ssh/config` (`IdentitiesOnly yes` + `IdentityFile`). + +## git — signed commits and tags + +```sh +git config --global gpg.format openpgp +git config --global user.signingkey 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4 # or a subkey fpr with `!` to force it +git config --global commit.gpgsign true # every commit +git config --global tag.gpgSign true +git commit -S -m "msg" # one-off when gpgsign is off +git tag -s v1.0 -m "release" # signed tag; git tag -v v1.0 verifies +git log --show-signature -3 # see who signed what +git verify-commit HEAD +gpg --armor --export FPR | wl-copy # paste into GitHub/GitLab → Settings → GPG keys, so they show "Verified" +``` + +jj: `jj config set --user signing.behavior own` + `signing.backend "gpg"` + `signing.key FPR` signs the commits jj makes. + +## keyservers — publishing and finding keys + +```sh +gpg --keyserver hkps://keys.openpgp.org --send-keys FPR # publish; keys.openpgp.org mails you to verify the address before it shows the UID +gpg --keyserver hkps://keys.openpgp.org --recv-keys FPR # fetch by fingerprint (the only safe way to fetch) +gpg --locate-keys someone@example.com # WKD: their domain serves the key (auto-key-locate in gpg.conf) +gpg --search-keys 'name' # interactive search; verify the fingerprint with them before trusting +gpg --refresh-keys # pull revocations/expiry updates for every key you hold +``` + +## trust — validity versus ownertrust + +- A signature is **valid** when the key is valid. A key is valid when *you* certified it (`--sign-key` / `--lsign-key`) or enough trusted people did. +- **Ownertrust** (`--edit-key FPR trust`, 1-5) is how much you let that person's certifications count for *other* keys. Give 5 only to yourself. +- `trust-model tofu+pgp`: the first key seen for an address is remembered; a different one later triggers a warning. `gpg --tofu-policy good FPR` to accept a change. +- `gpg --check-signatures FPR` lists who certified a key. `gpg --update-trustdb` recomputes validity after trust changes. + +## inspect — what is this thing + +```sh +gpg -k # public keys (--list-keys); gpg -K = secret keys +gpg -k --with-subkey-fingerprints --with-keygrip FPR +gpg --fingerprint FPR # just the fingerprint, formatted for reading aloud +gpg --list-packets file.gpg # structure of any OpenPGP file: recipients, algorithms, signature dates +gpg --show-keys key.asc # describe a key file without importing +gpg -k --with-colons FPR # machine-readable (pub/sub/uid/fpr records) +gpg --card-status # a YubiKey/OpenPGP card, if one is plugged in +gpg --export FPR | gpg --list-packets | grep -A2 'signature packet' # certifications on your key +``` + +## offline — primary key off the laptop + +The point of subkeys: the laptop carries only `[S] [E] [A]`; the primary (the identity) lives on encrypted offline storage. + +```sh +gpg --armor --export-secret-keys FPR > daemon-sec.SECRET.asc # 1. full backup first (vault, encrypted USB) +gpg --armor --export-secret-subkeys FPR > subkeys.asc # 2. the laptop's share +gpg --delete-secret-keys FPR # 3. remove everything secret here +gpg --import subkeys.asc # 4. put the subkeys back +gpg -K # shows `sec#` = primary absent, `ssb` present: correct +``` + +To add a subkey / extend expiry / sign someone's key later: import the SECRET backup in a temporary `GNUPGHOME`, do the change, export the public key and subkeys again, wipe the temp dir: +```sh +export GNUPGHOME=$(mktemp -d); gpg --import daemon-sec.SECRET.asc; gpg --quick-set-expire FPR 2y '*' +gpg --armor --export FPR > pub.asc; gpg --armor --export-secret-subkeys FPR > subkeys.asc; rm -rf "$GNUPGHOME"; unset GNUPGHOME +gpg --import pub.asc subkeys.asc # back in the normal ring +``` +A YubiKey does the same with the subkeys moved onto the card: `gpg --edit-key FPR` → `key N` → `keytocard`. + +## agent — passphrase caching, pinentry + +```sh +gpg-connect-agent 'KEYINFO --list' /bye # which keys are cached right now (the `1` column) +gpg-connect-agent reloadagent /bye # re-read gpg-agent.conf +gpgconf --kill gpg-agent # forget every cached passphrase now +gpg --pinentry-mode loopback -d file.gpg # type the passphrase in the terminal instead of the dialog (what gpgx does) +echo test | gpg --clearsign >/dev/null # cheap way to pre-unlock the key for a batch of operations +``` + +`export GPG_TTY=$(tty)` must be set for terminal pinentries (core.zsh does it). The dialog here is pinentry-gnome3. + +## fix — the usual errors + +- `Inappropriate ioctl for device` → `export GPG_TTY=$(tty)` (or the session has no tty: use `--pinentry-mode loopback`). +- `No secret key` on decrypt → not encrypted to you: `gpg --list-packets file.gpg` shows the key IDs it was made for. +- `unusable public key` / `unusable secret key` → expired or revoked (sub)key: `gpg -k FPR`, then `--quick-set-expire` (yours) or ask for a fresh key (theirs). +- `There is no assurance this key belongs to the named user` → ownertrust: `gpg --lsign-key FPR` after checking the fingerprint, or `--trust-model always` for one command. +- `gpg: WARNING: unsafe permissions on homedir` → `chmod 700 ~/.gnupg; chmod 600 ~/.gnupg/*`. +- Commands hang → a stale lock or a stuck daemon: `gpgconf --kill all`, `rm -f ~/.gnupg/*.lock ~/.gnupg/public-keys.d/*.lock`. +- `signing failed: No pinentry` → agent cannot reach a pinentry: `gpgconf --kill gpg-agent` and retry from a terminal with `GPG_TTY` set. +- Git: `error: gpg failed to sign the data` → almost always the two above; `GIT_TRACE=1 git commit` shows gpg's own message. +- Key shows `[expired]` on the other side after you extended it → they need your re-exported public key (or `--refresh-keys`). + +## mine — this machine, today + +- **Main key** `3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4`, UID `daemon (Main_Key) <zer0sec.xp@icloud.com>`, + RSA 4096 made 2025-12-30, imported from the vault on 2026-10-08: primary `[SC]` + one `[E]` subkey, **no expiry**, + ownertrust ultimate, `default-key` in gpg.conf, git signs with it (`commit.gpgsign`, `tag.gpgSign` on). + No revocation certificate is stored here yet: `gpg --gen-revoke 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4 > daemon-main.rev` + and put it in the vault. Optional upgrades, all without changing the identity: an expiry (`--quick-set-expire`), + a `[S]` signing subkey and an `[A]` auth subkey (`--quick-add-key … rsa4096 sign|auth`), the primary offline (*offline*). +- **Spare** `E989B40F2382569DD6080336BF684D91DF095E48` (`DAMEON-NIX`, ed25519, made 2026-10-08, never published): + delete it with `gpg --delete-secret-and-public-keys E989B40F2382569DD6080336BF684D91DF095E48`, or keep it as a scratch key. +- Pinentry: GNOME dialog (hosts/laptop/default.nix). `gpgx` uses loopback so the passphrase is typed in the terminal instead. +- Secrets in the NixDaemon repo are **age**, not gpg (`nix-cheat secrets`); the vault's `ssd.key.gpg` is gpg-encrypted, decrypt it with `gpg -d`. +- `gpgx-cheat` is the wrapper's card; `gpg-cheat SECTION` prints one section of this one. diff --git a/home/cheats/nix.md b/home/cheats/nix.md @@ -0,0 +1,236 @@ +# nix — rebuilding this machine from ~/NixDaemon + +NixOS **and** home-manager are one flake: `~/NixDaemon` (`NH_FLAKE` points at it). +home-manager runs as a NixOS module, so **one rebuild does both**; there is no separate `home-manager switch` here. +Terminal card: **`nix-cheat`** — sections `layout rebuild remote nh home search update rollback clean inspect add dotfiles secrets repo shell`. + +## layout — what lives where + +```text +~/NixDaemon/flake.nix inputs (nixpkgs unstable, home-manager, hyprland, caelestia) · output nixosConfigurations.nixos +hosts/laptop/default.nix the machine: boot, users (zsh login shell), greetd, Hyprland/uwsm, audio, fonts +hosts/laptop/*.nix fan fix, nvidia, ssd, nix-settings (nh, caches), toolbox (envfs, PATH) +home/default.nix home-manager entry; imports home/modules/*.nix +home/modules/tools.nix packages: the toolbox closure and the general CLI tools ← add packages here +home/modules/shell.nix zsh wiring (ZDOTDIR, fzf, completions), tmux plugins, secretspec +home/modules/dotfiles.nix every dotfile from ~/git/daemon-sec-dotfiles, as symlinks ← add dotfile paths here +home/modules/hyprland.nix Hyprland Lua config + helper scripts (wallpaper-picker, keybinds-menu …) +home/hypr/*.lua core · looknfeel (animations) · defaults (binds) · bindings · lid · caelestia +home/modules/caelestia.nix the shell (bar, launcher, lock), its CLI, wallpaper dir +hosts/laptop/sops.nix sops-nix (system) · home/modules/sops.nix (user) · secrets/secrets.yaml · .sops.yaml +``` + +## rebuild — nixos-rebuild, the plain way + +```sh +cd ~/NixDaemon +sudo nixos-rebuild switch --flake .#nixos # build, activate now, make it the boot default +sudo nixos-rebuild boot --flake .#nixos # build, activate on next boot only (kernel/driver changes) +sudo nixos-rebuild test --flake .#nixos # activate now, NOT the boot default (try something out) +nixos-rebuild build --flake .#nixos # just build → ./result, no sudo, nothing changes +sudo nixos-rebuild switch --flake ~/NixDaemon#nixos # same, from any directory +``` + +- `#nixos` is the configuration name (= hostname). `#bootstrap` is the old Stage A GNOME config. +- **New files must be known to git** or the flake does not see them: `git add path` first (see *repo*). +- A rebuild activates home-manager too; its log: `journalctl --user -u home-manager-daemonsec -b` or `systemctl status home-manager-daemonsec`. + +## remote — build straight from the GitLab repo + +The flake does not have to be checked out: any `nix` command takes a **flake reference** to the repo. +`gitlab:DAEMON-404/NixDaemon` is the public repo; `?ref=main` pins a branch, `?rev=<sha>` a commit. + +```sh +sudo nixos-rebuild switch --flake gitlab:DAEMON-404/NixDaemon#nixos # this machine, from the pushed main +sudo nixos-rebuild boot --flake 'gitlab:DAEMON-404/NixDaemon?ref=main#nixos' +nh os switch gitlab:DAEMON-404/NixDaemon # nh takes the same reference +nix build gitlab:DAEMON-404/NixDaemon#nixosConfigurations.nixos.config.system.build.toplevel --no-link --print-out-paths +nix flake show gitlab:DAEMON-404/NixDaemon # what the repo exports +nix flake metadata gitlab:DAEMON-404/NixDaemon # which commit nix resolved +git+https://gitlab.com/DAEMON-404/NixDaemon.git # the long form of the same reference +``` + +**Fresh machine, from the live ISO** (disk already partitioned and mounted at /mnt): +```sh +sudo nixos-generate-config --root /mnt --show-hardware-config > hardware-configuration.nix # compare with hosts/laptop/ +git clone https://gitlab.com/DAEMON-404/NixDaemon ~/NixDaemon && cd ~/NixDaemon +# copy the new hardware-configuration.nix into hosts/laptop/, git add it, then: +sudo nixos-install --flake .#nixos +``` +Afterwards: clone `~/git/daemon-sec-dotfiles` (every dotfile links there), restore the age key to +`~/.config/sops/age/keys.txt` and `/var/lib/sops-nix/key.txt` (see *secrets*), and the toolbox to `~/.local/bin`. + +A remote reference is fetched fresh each time (nix caches it briefly, `--refresh` forces it); the local +checkout at `~/NixDaemon` (`NH_FLAKE`) is the default for day-to-day work. + +## nh — the same, with a diff and a progress tree + +`nh` wraps nixos-rebuild: builds with nix-output-monitor, shows an **nvd diff** of what changes, then asks for sudo. + +```sh +nh os switch # = sudo nixos-rebuild switch --flake ~/NixDaemon (NH_FLAKE), config picked by hostname +nh os boot # build + boot default, activate on reboot +nh os test # activate now, not the boot default +nh os build # build only, no activation, no sudo +nh os switch --dry # show what would happen, do nothing +nh os switch --ask # show the diff, then confirm before activating +nh os switch -H bootstrap # another configuration from the same flake (-H = hostname/attr) +nh os switch -u # `nix flake update` first, then switch (updates EVERY input — see *update*) +nh os info # list system generations +nh os rollback # go back one generation (see *rollback*) +nh os switch -- --show-trace # anything after -- goes to nix build (debug an eval error) +``` + +## home — home-manager in this setup + +- home-manager is **inside** the NixOS build (`home-manager.nixosModules.home-manager` in flake.nix, user `daemonsec` → `./home`). + **`nh os switch` rebuilds it**; `nh home switch` / `home-manager switch` do not apply here (no standalone HM profile). +- Did my home change apply? `ls -l ~/.config/<thing>` (links point into /nix/store or the dotfiles checkout), + `systemctl --user status home-manager-daemonsec`, or `journalctl --user -u home-manager-daemonsec -b`. +- HM backs up a file it has to replace as `*.hm-bak` (flake.nix `backupFileExtension`). Delete the backup once happy. +- Only build the home part (fast check, no sudo): + `nix build ~/NixDaemon#nixosConfigurations.nixos.config.home-manager.users.daemonsec.home.activationPackage` + +## search — finding packages and options + +```sh +ns # fuzzy search everything indexed: nixpkgs packages, NixOS and home-manager options +ns kitty # start with a query +``` +Inside `ns` (fzf): type to filter, ↑↓ move, the right pane shows the description, version, homepage and +the option's default. **Enter** prints the attribute name (paste it into `home.packages` in tools.nix), +**ctrl-y** copies it, **ctrl-o** opens the homepage, **ctrl-s** the nixpkgs source, **Esc** quits. +The index is built by `nix-search-tv` on first use (a few seconds) and refreshed by itself; by hand: +```sh +nix-search-tv print | head # the raw list · nix-search-tv preview firefox # one entry +nh search firefox # search.nixos.org from the terminal (needs network) +nix search nixpkgs firefox # nix's own search (slow first run: builds an eval cache) +nix eval ~/NixDaemon#nixosConfigurations.nixos.pkgs.firefox.version # version THIS flake would install +nix shell nixpkgs#firefox # try it without installing · nix run nixpkgs#cowsay -- hi +``` + +## update — moving the inputs + +```sh +cd ~/NixDaemon +nix flake update # all inputs → flake.lock (nixpkgs, home-manager, hyprland, caelestia, llm-agents) +nix flake update nixpkgs home-manager # only these inputs +nix flake update hyprland # Hyprland alone (uses hyprland.cachix.org, so usually no compile) +nix flake lock # (re)write the lock without updating +nix flake metadata # which revisions are locked right now +nh os boot # then build it; boot = safest for kernel/driver bumps +``` + +Hyprland and Caelestia pin each other (flake.nix comments): update `hyprland` and `caelestia-*` together if the shell breaks. + +## rollback — when a generation misbehaves + +```sh +nh os rollback # previous generation, now +sudo nixos-rebuild switch --rollback # the same, plain +nh os info # generation numbers +sudo nix-env --switch-generation 5 -p /nix/var/nix/profiles/system && sudo /nix/var/nix/profiles/system/bin/switch-to-configuration switch +``` + +- At power-on the **systemd-boot menu** lists every generation: pick an older one, it boots as it was. +- A broken Hyprland login: Ctrl+Alt+F2 for a console, then one of the above. + +## clean — store and generations + +```sh +nh clean all --keep 5 --keep-since 14d # what the weekly timer runs (nix-settings.nix): drop old generations, GC +nh clean all --dry # show what it would remove +sudo nix-collect-garbage -d # the blunt version: delete all old generations, then GC +nix store gc # GC only (nothing referenced by a generation is touched) +du -sh /nix/store # how big is it +``` + +## inspect — see before you switch + +```sh +nh os build && nvd diff /run/current-system result # what a switch would change +nix build ~/NixDaemon#nixosConfigurations.nixos.config.system.build.toplevel --no-link --print-out-paths +nix eval ~/NixDaemon#nixosConfigurations.nixos.pkgs.uv.version # version a package would get +nix eval ~/NixDaemon#nixosConfigurations.nixos.config.programs.zsh.enable # read an option value +ns <query> # fuzzy search nixpkgs + NixOS/HM options, with descriptions (nix-search-tv) +nh search <package> # nixpkgs search (search.nixos.org) +nix search nixpkgs <package> # local search (first run builds an index) +nix shell nixpkgs#<package> # try a tool without installing it +nix run nixpkgs#<package> -- --help +nix flake check ~/NixDaemon # evaluate every output +nix flake show ~/NixDaemon +``` + +## add — packages, options, dotfiles + +- **A package for the user**: `home/modules/tools.nix` → `home.packages` list → `nh os switch`. +- **A system package / service**: `hosts/laptop/default.nix` (`environment.systemPackages`, `services.*`). +- **A dotfile from the checkout**: `home/modules/dotfiles.nix` → add its path to the list → `nh os switch`. +- **A Hyprland bind**: `home/hypr/bindings.lua` (user) or `defaults.lua` (stock); a helper script goes in `hyprland.nix`. +- **A Caelestia setting**: `home/modules/caelestia.nix` → `programs.caelestia.settings`. +- Then: `git add` anything new, `nh os switch`, and for Hyprland `hyprctl reload`. + +## dotfiles — the checkout is the source of truth + +- `~/git/daemon-sec-dotfiles/home/` is mirrored into `$HOME` by **symlinks** (dotfiles.nix): edit a file there and the live config changes at once — no rebuild. +- A rebuild is needed only to add/remove a *path* in dotfiles.nix. `ls -l ~/.config/starship.toml` shows where a link goes. +- zsh: `~/.dotfiles/config/*.zsh` → open a new shell (`exec zsh`). Plugins are vendored in `~/.dotfiles/config/plugins`. +- Not linked on purpose (hypr, kitty, git, mimeapps, bash rc, .claude …): the list and reasons are at the top of dotfiles.nix. + +## secrets — sops-nix and secretspec + +Two tools, two jobs. **sops-nix**: secrets that belong in this repo, encrypted with **age**, decrypted at +activation to `/run/secrets/NAME` (system) or `~/.config/sops-nix/secrets/NAME` (user). +**secretspec**: runtime secrets for a *project*, declared in its `secretspec.toml`, stored in the keyring. + +```sh +# sops — the one key: ~/.config/sops/age/keys.txt (user) = /var/lib/sops-nix/key.txt (system, root copy) +sudo install -D -m 600 -o root -g root ~/.config/sops/age/keys.txt /var/lib/sops-nix/key.txt # once per machine +age-keygen -y ~/.config/sops/age/keys.txt # the public key (recipient) in .sops.yaml +sops secrets/secrets.yaml # edit: opens decrypted in $EDITOR, re-encrypts on save +sops set secrets/secrets.yaml '["wifi-psk"]' '"hunter2"' # add/replace one value without the editor +sops -d secrets/secrets.yaml # print decrypted +sops -d --extract '["example"]' secrets/secrets.yaml +sops updatekeys secrets/secrets.yaml # after editing the recipients in .sops.yaml +``` + +Then declare it and rebuild: +```nix +# hosts/laptop/sops.nix (system) # home/modules/sops.nix (user) +sops.secrets.wifi-psk = { }; sops.secrets.my-token = { }; +sops.secrets."ssd.key".path = "/etc/secrets/ssd.key"; # e.g. the LUKS key instead of restoring it by hand +``` +`nh os switch` → `/run/secrets/wifi-psk` (root, 0400; `owner = "daemonsec";` to read it as the user). +Check: `sudo ls -l /run/secrets/`, `systemctl --user status sops-nix` (user side), `journalctl -b | grep sops`. + +```sh +# secretspec — in a project directory (config: ~/.config/secretspec/config.toml → keyring, profile default) +secretspec init # writes secretspec.toml (commit it; it holds names, never values) +secretspec add DATABASE_URL # declare a secret (flags: --required/--default/--description) +secretspec check # prompts for every missing value, stores it in the keyring +secretspec set NAME # (re)store one value +secretspec run -- ./server # run with the secrets in the environment +secretspec export # print them for another tool (shell `eval`) +secretspec claude configure # let Claude Code fetch its API credential through secretspec +``` + +## repo — committing ~/NixDaemon + +The repo is **jj, colocated with git** (`.jj` + `.git`). Nix flakes only see files git knows about, and jj keeps git's index at `@-`, so a *new* file is invisible to nix until it is added: + +```sh +cd ~/NixDaemon +git add home/modules/new.nix # make nix see a new file (modified tracked files are seen as-is) +nh os build # or switch +jj status # jj snapshots the working copy +jj describe -m "what changed" && jj new # seal it (the vault ritual) — or plain: git add -A && git commit +jj log # history +``` + +A dirty tree only prints `warning: Git tree '/home/daemonsec/NixDaemon' is dirty`; it still builds. + +## shell — after a switch + +- New packages are on PATH in a **new** shell (`exec zsh`); the login shell itself changes at the next login. +- Hyprland: `hyprctl reload` re-reads `~/.config/hypr/*.lua`. Caelestia: `caelestia shell -r` restarts the shell. +- Everything in the Nix profile: `ls /etc/profiles/per-user/daemonsec/bin`. diff --git a/home/default.nix b/home/default.nix @@ -6,6 +6,13 @@ ./modules/caelestia.nix # programs.caelestia, shell.json, the Rosé Pine scheme ./modules/terminal.nix # kitty, fonts ./modules/tools.nix # toolbox runtime closure, python env, dotfiles links + ./modules/shell.nix # zsh via the dotfiles ZDOTDIR tree, tmux plugins, secretspec + ./modules/dotfiles.nix # every dotfile from ~/git/daemon-sec-dotfiles, as out-of-store symlinks + ./modules/cheats.nix # nix-cheat: the rebuild / nh / flake card + ./modules/sops.nix # sops-nix for the user (same secrets file, age key in ~/.config/sops/age) + ./modules/neovim.nix # nvf: Neovim with a small, Nix-built plugin set + ./modules/prompt.nix # starship prompt and fastfetch card, Rosé Pine, NixOS logo + ./modules/fan.nix # `fan`: status/watch without root, max/auto with a clamp watchdog ./modules/gtk.nix # Yaru-purple icons, cursor, prefer-dark ]; diff --git a/home/hypr/defaults.lua b/home/hypr/defaults.lua @@ -67,7 +67,7 @@ o.bind("XF86PowerOff", "Power menu", hl.dsp.global("caelestia:session"), locked) o.bind("SUPER + K", "Keybindings", "keybinds-menu") o.bind("SUPER + SHIFT + SPACE", "Toggle top bar", gone("Toggle top bar", "the Caelestia bar has no toggle")) o.bind("SUPER + SHIFT + CTRL + SPACE", "Theme menu", gone("Theme menu", "the theme is static Rosé Pine here")) -o.bind("CTRL + SUPER + SPACE", "Background switcher", "caelestia wallpaper -r") +o.bind("CTRL + SUPER + SPACE", "Background switcher", "wallpaper-picker") -- Caelestia launcher in wallpaper mode (home/modules/hyprland.nix) o.bind("CTRL + SUPER + O", "Toggle menu", gone("Toggle menu")) o.bind("CTRL + SUPER + D", "Display", gone("Display", "omarchy display menu")) o.bind("CTRL + SUPER + H", "Hardware menu", gone("Hardware menu")) diff --git a/home/hypr/looknfeel.lua b/home/hypr/looknfeel.lua @@ -0,0 +1,104 @@ +-- looknfeel.lua — the springy animation rice from the dotfiles' hypr/looknfeel.lua +-- (daemon-sec-dotfiles/home/.config/hypr/looknfeel.lua), carried on 2026-10-08. +-- +-- Loaded after core.lua, which pins the square corners, blur and borders. This +-- file adds what the carried version added on Omarchy: overshoot curves on +-- window motion (the "bouncy" / wobbly feel), shadows, blur tuning, snapping +-- while dragging, manual-drag physics, swallowing, and two touchpad gestures. +-- Left out on purpose: the omarchy-* layer rule (caelestia.lua has the +-- Caelestia one), the `pop` rounding rule (rounding is 0 everywhere already), +-- and the 4-finger-down gesture (bindings.lua binds it to the dropdown terminal). + +hl.config({ + decoration = { + rounding = 0, + active_opacity = 1.0, + inactive_opacity = 1.0, -- kitty keeps its own transparency in its config + dim_inactive = false, + + shadow = { + enabled = true, + range = 24, + render_power = 3, + color = "rgba(00000055)", + }, + + blur = { + enabled = true, + size = 7, + passes = 3, + new_optimizations = true, + xray = true, -- blur the wallpaper, not other tiled windows + popups = true, + noise = 0.015, + contrast = 1.1, + brightness = 1.0, + }, + }, +}) + +-- Hyprland's stock curves live in its own share/hypr/hyprland.lua, which this +-- home-manager config does not load, so the ones used below are defined here +-- (same control points as upstream). +hl.curve("linear", { type = "bezier", points = { { 0, 0 }, { 1, 1 } } }) +hl.curve("almostLinear", { type = "bezier", points = { { 0.5, 0.5 }, { 0.75, 1.0 } } }) +hl.curve("quick", { type = "bezier", points = { { 0.15, 0 }, { 0.1, 1.0 } } }) + +-- Overshoot curves: control points with y > 1 push past the target and settle +-- back, which is what reads as "springy" in a compositor without jelly deformation. +hl.curve("overshoot", { type = "bezier", points = { { 0.34, 1.56 }, { 0.64, 1.0 } } }) +hl.curve("springy", { type = "bezier", points = { { 0.16, 1.36 }, { 0.30, 1.02 } } }) +hl.curve("snappy", { type = "bezier", points = { { 0.05, 0.9 }, { 0.10, 1.05 } } }) +hl.curve("squish", { type = "bezier", points = { { 0.40, -0.20 }, { 0.20, 1.30 } } }) + +-- Window motion: dragging, tiling and resizing all overshoot slightly and settle. +hl.animation({ leaf = "windows", enabled = true, speed = 4.6, bezier = "overshoot" }) +hl.animation({ leaf = "windowsIn", enabled = true, speed = 5.0, bezier = "springy", style = "popin 60%" }) +hl.animation({ leaf = "windowsOut", enabled = true, speed = 3.4, bezier = "squish", style = "popin 70%" }) +hl.animation({ leaf = "windowsMove", enabled = true, speed = 4.4, bezier = "overshoot" }) + +-- Borders track the spring instead of lagging behind it. +hl.animation({ leaf = "border", enabled = true, speed = 7.0, bezier = "snappy" }) + +-- Fades: quick enough to stay out of the way. +hl.animation({ leaf = "fade", enabled = true, speed = 4.5, bezier = "quick" }) +hl.animation({ leaf = "fadeIn", enabled = true, speed = 3.2, bezier = "almostLinear" }) +hl.animation({ leaf = "fadeOut", enabled = true, speed = 2.4, bezier = "almostLinear" }) + +-- Workspace switching: slide with a fade tail; the dropdown terminal rides slidevert. +hl.animation({ leaf = "workspaces", enabled = true, speed = 5.0, bezier = "overshoot", style = "slidefade 15%" }) +hl.animation({ leaf = "specialWorkspace", enabled = true, speed = 4.6, bezier = "springy", style = "slidevert" }) + +-- Layer surfaces (bar, launcher, notifications) pop in with the same character. +hl.animation({ leaf = "layers", enabled = true, speed = 4.5, bezier = "springy" }) +hl.animation({ leaf = "layersIn", enabled = true, speed = 4.5, bezier = "springy", style = "popin 80%" }) +hl.animation({ leaf = "layersOut", enabled = true, speed = 3.0, bezier = "linear", style = "popin 85%" }) + +-- The border gradient angle can be animated; left off so the GPU can idle. +-- hl.animation({ leaf = "borderangle", enabled = true, speed = 40, bezier = "linear", style = "loop" }) + +hl.config({ + general = { + -- Floating windows snap to each other and to screen edges while dragging. + snap = { + enabled = true, + window_gap = 12, + monitor_gap = 12, + respect_gaps = true, + }, + }, + misc = { + -- Manual drags and resizes ride the curves above: the window lags the + -- cursor and settles with overshoot (poor man's wobbly windows). + animate_manual_resizes = true, + animate_mouse_windowdragging = true, + -- Launch a GUI app from a terminal and the terminal hides until it exits. + enable_swallow = true, + swallow_regex = "^(kitty|foot|org\\.codeberg\\.dnkl\\.foot|Alacritty|com\\.mitchellh\\.ghostty)$", + }, +}) + +-- Touchpad: 3-finger horizontal surfs workspaces with the slidefade above; +-- 4-finger up toggles fullscreen (4-finger down is the dropdown terminal, bindings.lua). +hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" }) +hl.gesture({ fingers = 4, direction = "up", action = "fullscreen" }) diff --git a/home/kitty/scrollback.lua b/home/kitty/scrollback.lua @@ -0,0 +1,67 @@ +-- scrollback.lua — kitty's copy mode (ctrl+a>[) as a Neovim buffer. +-- Started by home/modules/terminal.nix as +-- bash -c 'exec nvim 63<&0 0</dev/null -u <this file> -c "let g:kitty_input_line=INPUT_LINE_NUMBER" …' +-- kitty hands the scrollback (with colours) on fd 63; it is rendered through a +-- :terminal so the ANSI colours survive, then the buffer is left in normal +-- mode at the line that was under the cursor. Vi motions and search work as +-- usual; / ? n N, v V ctrl-v select, y copies to the clipboard (wl-copy), +-- Enter copies and leaves, q or Esc leave. Nothing from the AstroNvim config +-- is loaded (-u), so this starts instantly. + +local o = vim.opt +o.number = false +o.relativenumber = false +o.list = false +o.showtabline = 0 +o.foldcolumn = "0" +o.laststatus = 0 +o.signcolumn = "no" +o.ruler = false +o.showmode = false +o.cmdheight = 1 +o.scrollback = 100000 +o.termguicolors = true +o.background = "dark" +o.clipboard = "unnamedplus" -- y → system clipboard +o.ignorecase = true +o.smartcase = true +o.incsearch = true +o.hlsearch = true +o.shell = "bash" + +local function quit() + vim.cmd("qa!") +end +local map = vim.keymap.set +map({ "n", "v" }, "q", quit, { silent = true, desc = "leave copy mode" }) +map("n", "<Esc>", quit, { silent = true, desc = "leave copy mode" }) +map("n", "i", "<Nop>") -- no insert in a dead terminal +map("n", "a", "<Nop>") +map("v", "y", '"+y', { silent = true, desc = "copy selection" }) +map("v", "<CR>", '"+y<cmd>qa!<CR>', { silent = true, desc = "copy selection and leave (tmux)" }) +map("n", "<CR>", "<Nop>") + +vim.schedule(function() + -- the -c flags have run by now, so the kitty positions are in vim.g + local input_line = tonumber(vim.g.kitty_input_line) or 0 + local cursor_line = tonumber(vim.g.kitty_cursor_line) or 1 + local cursor_col = tonumber(vim.g.kitty_cursor_col) or 1 + + vim.api.nvim_create_autocmd("TermEnter", { callback = function() vim.cmd("stopinsert") end }) + vim.api.nvim_create_autocmd("TermClose", { + once = true, + callback = function() + vim.cmd("stopinsert") + local target = math.max(0, input_line - 1) + cursor_line + pcall(vim.fn.cursor, target, cursor_col) + vim.cmd("normal! zz") + if vim.env.KITTY_SCROLLBACK_TEST then + -- smoke test (terminal.nix build check): print the text and leave + io.stdout:write(table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), "\n"), "\n") + quit() + end + end, + }) + -- strip kitty's OSC 8 file:// hyperlinks, which would print as noise + vim.cmd([[terminal sed </dev/fd/63 -e 's/\x1b]8;;file:[^\\]*\\//g' && sleep 0.01 && printf '\x1b]2;\a']]) +end) diff --git a/home/modules/caelestia.nix b/home/modules/caelestia.nix @@ -1,31 +1,42 @@ # home/modules/caelestia.nix — Caelestia Shell as bar, launcher, notifications, -# OSD, lock and session menu; its CLI; the static Rosé Pine (M3) scheme. +# OSD, lock and session menu; its CLI; the static Rosé Pine (M3) schemes. +# +# Look (2026-10-08): Rosé Pine dark (main, not moon) everywhere, translucent +# shell layers over blur, and Caelestia's own framed bar: the screen edge is a +# 10 px frame with 25 px rounded inner corners, the clock and tray sit in pill +# backgrounds, occupied workspaces are filled, the Nix snowflake is the logo. +# The sidebar, utilities and notification panels are narrower than stock +# (../caelestia/shell-tokens.json: 340 / 340 / 360 px instead of 430). +# A Rosé Pine Dawn mapping is registered too: caelestia scheme set -n rose-pine -f rose-pine-dawn +# and back: caelestia scheme set -n rose-pine -f rose-pine-dark # -# Settings are the carried shell.json with the Omarchy-isms swapped for NixOS -# ones (vault caelestia/README.md "On NixOS"): launcher actions and session -# commands, the audio app, the wallpaper directory. `useTwelveHourClock` no -# longer exists in this shell version and was dropped. The shell is started by -# the module's systemd user service under graphical-session.target (uwsm), so -# there is no `caelestia shell -d` exec-once: two starts would mean two shells. { config, pkgs, lib, inputs, ... }: let system = pkgs.stdenv.hostPlatform.system; hyprPkg = inputs.hyprland.packages.${system}.hyprland; # The CLI knows schemes by name and re-reads their colours whenever the - # wallpaper changes, so the carried Rosé Pine mapping is registered as a real - # scheme (name rose-pine, flavour rose-pine-dark, mode dark). Its colours are - # ../caelestia/rose-pine-dark.txt, generated from the same scheme.json. + # wallpaper changes, so the two hand-mapped Rosé Pine → M3 palettes are + # registered as a real scheme (name rose-pine; flavours rose-pine-dark, mode + # dark, and rose-pine-dawn, mode light). The CLI's own `rosepine/dawn` is a + # Material palette generated from a gold seed, not the Rosé Pine colours. cli = (inputs.caelestia-cli.packages.${system}.default).overrideAttrs (old: { patchPhase = (old.patchPhase or "") + '' install -Dm644 ${../caelestia/rose-pine-dark.txt} src/caelestia/data/schemes/rose-pine/rose-pine-dark/dark.txt + install -Dm644 ${../caelestia/rose-pine-dawn.txt} src/caelestia/data/schemes/rose-pine/rose-pine-dawn/light.txt ''; }); - shell = (inputs.caelestia-shell.packages.${system}.with-cli).override { - caelestia-cli = cli; - hyprland = hyprPkg; # hyprctl from the same Hyprland the compositor runs - }; + shell = + ((inputs.caelestia-shell.packages.${system}.with-cli).override { + caelestia-cli = cli; + hyprland = hyprPkg; # hyprctl from the same Hyprland the compositor runs + }).overrideAttrs (old: { + # bar.activeWindow.compact shows the program (desktop-entry name for the + # window class) instead of the window title. Upstream's compact mode only + # trims the title at its last " - ". Rebuilds the shell locally. + patches = (old.patches or [ ]) ++ [ ../caelestia/active-window-program-name.patch ]; + }); wallpaperDir = "${config.home.homeDirectory}/git/daemon-sec-dotfiles/home/.config/omarchy/backgrounds/rose-pine-dark"; in @@ -77,6 +88,8 @@ in }; }; general = { + # The Nix snowflake in the bar, dashboard and lock screen (empty = Caelestia's own logo). + logo = "/run/current-system/sw/share/icons/hicolor/scalable/apps/nix-snowflake.svg"; apps = { terminal = [ "kitty" ]; audio = [ "caelestia" "shell" "drawers" "toggle" "sidebar" ]; # was the Omarchy audio popup @@ -86,14 +99,33 @@ in idle = { lockBeforeSleep = false; inhibitWhenAudio = true; - timeouts = [ ]; + # Lock after 15 min idle, screen off after 20; audio playing or a + # fullscreen app with an idle inhibitor holds both off. + timeouts = [ + { timeout = 900; idleAction = "lock"; respectInhibitors = true; } + { timeout = 1200; idleAction = "dpms off"; returnAction = "dpms on"; } + ]; }; }; # Omarchy drew the wallpaper; here Caelestia does. Colours stay static. background = { enabled = true; wallpaperEnabled = true; - visualiser.enabled = false; + # Big clock on the wallpaper, bottom right, with a soft shadow. + desktopClock = { + enabled = true; + position = "bottom-right"; + scale = 1.0; + shadow.enabled = true; + }; + # Audio visualiser along the bottom of the wallpaper while something plays (cava is built in). + visualiser = { + enabled = true; + autoHide = true; + blur = false; + rounding = 1; + spacing = 1; + }; }; bar = { persistent = true; @@ -101,28 +133,39 @@ in workspaces = { shown = 5; activeIndicator = true; + occupiedBg = true; # filled pills behind workspaces that have windows showWindows = true; activeTrail = true; }; - activeWindow.compact = false; + activeWindow = { + compact = true; # the program's name (patched, see `shell` above), not the title + inverted = true; # on a primary-coloured pill + }; clock = { showDate = true; showIcon = true; + background = true; # clock in its own pill + }; + tray = { + background = true; # tray in its own pill + recolour = true; # tray icons tinted to the scheme }; statusIcons = [ { id = "lockStatus"; enabled = true; } { id = "audio"; enabled = true; } - { id = "microphone"; enabled = false; } + { id = "microphone"; enabled = true; } # shows when something is capturing { id = "kbLayout"; enabled = false; } { id = "network"; enabled = true; } { id = "bluetooth"; enabled = true; } { id = "battery"; enabled = true; } ]; }; + # Caelestia's frame: the bar is one side of a border around the screen + # whose inner corners are rounded. Stock values; the carried 5/0/0 was a flat strip. border = { - thickness = 5; - rounding = 0; - smoothing = 0; + thickness = 10; + rounding = 25; + smoothing = 20; }; dashboard = { enabled = true; @@ -132,6 +175,7 @@ in launcher = { enabled = true; maxShown = 8; + vimKeybinds = true; # ctrl+j/k move, like everywhere else here actions = [ { name = "Calculator"; icon = "calculate"; description = "Do simple math equations (powered by Qalc)"; command = [ "autocomplete" "calc" ]; enabled = true; dangerous = false; } { name = "Wallpaper"; icon = "image"; description = "Pick a wallpaper"; command = [ "caelestia" "wallpaper" ]; enabled = true; dangerous = false; } @@ -143,7 +187,10 @@ in { name = "Shutdown"; icon = "power_settings_new"; description = "Shutdown the system"; command = [ "systemctl" "poweroff" ]; enabled = true; dangerous = true; } ]; }; - lock.enabled = true; # Caelestia is the lock screen here (Omarchy had hyprlock) + lock = { + enabled = true; # Caelestia is the lock screen here (Omarchy had hyprlock) + useWallpaper = true; # the wallpaper behind the lock, not a flat colour + }; notifs = { expire = true; defaultExpireTimeout = 6000; @@ -157,7 +204,7 @@ in gpuType = "Generic"; # must be a string smartScheme = false; # never derive colours from the wallpaper defaultPlayer = "rmpc"; - weatherLocation = ""; + weatherLocation = "54.15,-4.48"; # Douglas, Isle of Man (dashboard weather card) }; session = { enabled = true; @@ -170,18 +217,23 @@ in }; }; sidebar.enabled = true; - utilities.enabled = true; + utilities = { + enabled = true; + toasts.nowPlaying = true; # a toast when the track changes + }; paths.wallpaperDir = wallpaperDir; }; }; - # The scheme the shell reads at start: the carried Rosé Pine → M3 mapping. - # `caelestia scheme set -n rose-pine` rewrites this file with the same - # colours (home-manager backs the symlink up as .hm-bak when that happens). + # The scheme the shell reads at start: Rosé Pine dark (scheme.json; + # scheme-dawn.json is the light mapping). `caelestia scheme set …` rewrites + # this file (home-manager backs the symlink up as .hm-bak when that happens). home.file.".local/state/caelestia/scheme.json".source = ../caelestia/scheme.json; - # Carried for reference: this shell version has no loader for it (the bar - # width token came from the Omarchy-side build overlay). + # Internal size tokens: the shell reads this file (defaults in its source, + # plugin/src/Caelestia/Config/tokens.hpp: sidebar, utilities and + # notification width 430, bar innerWidth 40). Here: sidebar and utilities + # 340, notifications 360, bar 36. Rounding, padding and spacing stay stock. home.file.".config/caelestia/shell-tokens.json".source = ../caelestia/shell-tokens.json; # First wallpaper, only if none was ever chosen (Caelestia keeps the choice in state). diff --git a/home/modules/cheats.nix b/home/modules/cheats.nix @@ -0,0 +1,61 @@ +# home/modules/cheats.nix — the cheat cards this repo ships, in the house +# style (~/.local/bin/*-cheat, rendered with the toolbox's cheat-render). +# Every home/cheats/<name>.md becomes a `<name>-cheat` command: +# +# nix-cheat rebuilding this machine: nixos-rebuild, nh, remote, search, update, rollback, secrets, repo +# gpg-cheat GnuPG from the key hierarchy to signing, encryption, SSH, git, offline primary, fixes +# +# <name>-cheat the whole card <name>-cheat --list the section names +# <name>-cheat SECTION one section <name>-cheat --raw the markdown itself +# +# Rendered with cheat-render when that is on PATH, else glow, else printed +# plain. These cards live here (not in the dotfiles) because they document +# this repo and this machine; xcheats only lists ~/.local/bin cards, so call +# these by name. +{ pkgs, lib, ... }: +let + cards = [ "nix" "gpg" ]; + + mkCheat = name: pkgs.writeShellScriptBin "${name}-cheat" '' + set -uo pipefail + card=${../cheats + "/${name}.md"} + + usage() { + echo "usage: ${name}-cheat [SECTION] [--list] [--raw] [--help]" + echo " sections: $(sections | tr '\n' ' ')" + } + sections() { # first word of each '## ' heading + ${pkgs.gnused}/bin/sed -n 's/^## \([a-z-]*\).*/\1/p' "$card" + } + section() { # the heading whose first word matches $1, up to the next heading + ${pkgs.gawk}/bin/awk -v want="$1" ' + /^## / { on = (tolower($2) == want) } + /^# / { next } + on + ' "$card" + } + render() { + if [ ! -t 1 ]; then cat + elif command -v cheat-render >/dev/null 2>&1; then cheat-render - | ''${PAGER:-less -R} + else ${pkgs.glow}/bin/glow -p - + fi + } + + case "''${1:-}" in + -h|--help) usage; exit 0 ;; + --list) sections; exit 0 ;; + --raw) cat "$card"; exit 0 ;; + "") render < "$card" ;; + *) + key=$(echo "$1" | tr '[:upper:]' '[:lower:]') + out=$(section "$key") + if [ -z "$out" ]; then + echo "${name}-cheat: no section '$1'" >&2; usage >&2; exit 1 + fi + { head -1 "$card"; echo; printf '%s\n' "$out"; } | render ;; + esac + ''; +in +{ + home.packages = map mkCheat cards; +} diff --git a/home/modules/dotfiles.nix b/home/modules/dotfiles.nix @@ -0,0 +1,117 @@ +# home/modules/dotfiles.nix — every dotfile from the dotfiles checkout, placed +# by home-manager. +# +# ~/git/daemon-sec-dotfiles is the restorable snapshot of the Omarchy +# workstation (its README: "files restored relative to $HOME"). home-manager +# puts each of its files where it belongs as an out-of-store symlink +# (mkOutOfStoreSymlink), so there is one source of truth: edit the checkout and +# the live config changes; `nh os switch` is only needed when a path is added +# or removed here. The links dangle harmlessly until the repo is cloned. +# +# Not linked, and why (each is one line to add if wanted): +# .config/hypr, .config/kitty Nix-managed (home/modules/hyprland.nix, terminal.nix) +# .config/nvim the AstroNvim tree; Neovim is nvf now (home/modules/neovim.nix) +# .config/starship.toml, .config/fastfetch the Omarchy-era prompt and fetch; both are +# Nix-managed now (home/modules/prompt.nix) +# .config/omarchy*, Omacom, hyprmoncfg Omarchy's own trees; caelestia.nix reads the +# wallpaper directory straight from the checkout +# .config/systemd/user Omarchy-era units; caelestia-shell.service there would +# fight the home-manager caelestia service +# .config/autostart Omarchy autostarts for apps not installed here +# .config/mimeapps.list defaults point at chromium / HEY, neither installed: +# xdg-open would fail on every link +# .config/git turns on commit signing with a key not on this machine +# .config/fontconfig, dconf, gtk-4.0 home-manager writes these (fonts.fontconfig, gtk.nix) +# .config/gtk-3.0/bookmarks paths under the old /home/daemon-sec +# .config/user-dirs.dirs, floorp XDG defaults already match; a browser profile is state +# .config/tmux holds only a disabled backup; ~/.tmux.conf is the config +# .bashrc, .bash_profile, .bash_logout source Omarchy's bash rc unguarded (errors on NixOS) +# .zshrc, .zprofile the dead decoys; ZDOTDIR=~/.dotfiles bypasses them +# .XCompose includes /usr/share/omarchy/default/xcompose +# .claude, .codex, .agents live agent state on this machine (plugins, sessions) +# bin, .local/bin ~/.local/bin is the live toolbox repo (README) +# .local/share/applications Omarchy web-app launchers (omarchy-launch-webapp) +# .local/share/icons/hicolor apps install into it; the themes are linked one by one +{ config, lib, ... }: +let + repo = "${config.home.homeDirectory}/git/daemon-sec-dotfiles"; + home = "${repo}/home"; + link = path: config.lib.file.mkOutOfStoreSymlink "${home}/${path}"; + + # Same path under $HOME as in the checkout's home/. + same = paths: lib.genAttrs paths (p: { source = link p; }); + # Entries of .config, by name. + config' = names: lib.genAttrs names (n: { source = link ".config/${n}"; }); + + cursorThemes = [ + "modernxp-retro-black" # the active cursor (gtk.nix, core.lua) + "modernxp-retro-black-hyprcursor" + "modernxp-rose-pine" + "modernxp-rose-pine-hyprcursor" + "retrosmart-rose-pine" + "retrosmart-rose-pine-hyprcursor" + "rose-pine-hyprcursor" + "BreezeX-RosePine-Linux" + ]; +in +{ + home.file = + same [ + ".dotfiles" # the zsh ZDOTDIR tree (home/modules/shell.nix sets ZDOTDIR) + ".tmux.conf" + ".ripgreprc" # RIPGREP_CONFIG_PATH, exported by .dotfiles/config/ripgrep.zsh + "Music/AGENTS.md" + ] + // same (map (t: ".local/share/icons/${t}") cursorThemes) + // { + # The patched DMMono TTFs live outside home/ in the checkout. + ".local/share/fonts/nerd-fonts-dm-mono".source = + config.lib.file.mkOutOfStoreSymlink "${repo}/assets/fonts/nerd-fonts-dm-mono"; + }; + + xdg.configFile = config' [ + # shell and prompt + "atuin" + "bat" # the "Rose Pine" theme BAT_THEME names (shell.nix rebuilds bat's cache) + "carapace" + "cheats" # the markdown cheat cards (cheats.zsh, ~/.local/bin/cheat-*) + "crossfetch" # the login splash animation (animations.zsh); the fetch card itself is prompt.nix + "eza" + "mise" + # tools + "btop" + "yazi" + "lazygit" + "jj" + "emacs" + "opencode" + "feroxbuster" + "uncover" + "herdr" + "tensaku" + "ai-usagebar" + "bg-pasticcio" + "libvirt" + # media + "mpd" + "rmpc" + "mpv" + "cava" + "imv" + "zathura" + "xournalpp" + "spicetify" + "vesktop" + "pipewire" # 10-sample-rates.conf + "wireplumber" # bluetooth-a2dp-autoconnect.conf + # terminals and desktop bits + "alacritty" + "foot" + "ghostty" + "fcitx5" + "xdg-terminals.list" # kitty first, for xdg-terminal-exec + "chromium-flags.conf" # read only if a chromium is ever installed + "menus" # the chrome-apps application menu entries + "uwsm" # env.d/50-rose-pine-cursor (same values core.lua sets) + ]; +} diff --git a/home/modules/fan.nix b/home/modules/fan.nix @@ -0,0 +1,112 @@ +# home/modules/fan.nix — `fan`: the laptop's fans from the terminal, safely. +# +# fan one line: CPU °C, fan rpm and %, clock cap, load, clamp yes/no, EC mode +# fan watch [s] the same line every s seconds (default 2), Ctrl-C to stop +# fan max 100 % now, with the watchdog (below) fan 60 fixed 60 % (30-100) +# fan auto back to the EC's own curve; stops the watchdog +# fan log what the watchdog has done +# +# Why a watchdog: this laptop's EC, once in manual fan mode, asserts PROCHOT +# and pins every core at 399 MHz as soon as load starts (fan-throttle-guard.nix). +# `fan max` therefore starts a transient user unit (fan-watchdog) that samples +# /proc/stat and /proc/cpuinfo every second and, the moment CPU busy ≥ 25 % +# while no core is above 600 MHz (fanfix's clamp rule), runs `fan-ec auto`, +# sends a notification and exits. Root access is `sudo -n fan-ec …` +# (hosts/laptop/fan-cli.nix: a store script with a passwordless rule for wheel). +# Reads need no root at all: k10temp and the uniwill hwmon are world-readable. +{ pkgs, lib, ... }: +let + bin = lib.makeBinPath [ pkgs.coreutils pkgs.gawk pkgs.gnugrep pkgs.gnused pkgs.systemd pkgs.libnotify pkgs.sudo ]; + + # shared read-only sampler, sourced by both scripts + lib-sh = pkgs.writeText "fan-lib.sh" '' + TRIP_MHZ=600; BUSY_MIN=25 + STATE=''${XDG_RUNTIME_DIR:-/tmp}/fan.stat + hw() { local n; for h in /sys/class/hwmon/hwmon*; do n=$(cat "$h/name" 2>/dev/null); [ "$n" = "$1" ] && { echo "$h"; return; }; done; } + cpu_temp() { local h; h=$(hw k10temp); [ -n "$h" ] && echo $(( $(cat "$h/temp1_input") / 1000 )) || echo -; } + fan_rpm() { local h; h=$(hw uniwill); [ -n "$h" ] && cat "$h/fan1_input" || echo -; } + fan_pct() { local h; h=$(hw uniwill); [ -n "$h" ] && echo $(( $(cat "$h/pwm1") * 100 / 255 )) || echo -; } + gpu_temp() { local h; h=$(hw uniwill); [ -n "$h" ] && echo $(( $(cat "$h/temp2_input") / 1000 )) || echo -; } + cap_mhz() { echo $(( $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq) / 1000 )); } + clocks() { awk '/^cpu MHz/ {s+=$4; n++; if ($4>m) m=$4} END {printf "%d %d", (n?s/n:0), m}' /proc/cpuinfo; } + # CPU busy % since the previous call (snapshot in $STATE); 0 on the first call + busy() { + local cur prev b=0 + cur=$(head -1 /proc/stat) + [ -r "$STATE" ] && prev=$(cat "$STATE") || prev= + printf '%s' "$cur" > "$STATE" + [ -n "$prev" ] && b=$(awk -v a="$prev" -v b="$cur" 'BEGIN { + na=split(a,x," "); nb=split(b,y," "); ta=0; tb=0 + for (i=2;i<=na;i++) ta+=x[i]; for (i=2;i<=nb;i++) tb+=y[i] + ia=x[5]+x[6]; ib=y[5]+y[6]; d=tb-ta; if (d<=0) {print 0; exit} + printf "%d", 100*(d-(ib-ia))/d }') + echo "$b" + } + clamped() { # 1 when busy yet no core above TRIP_MHZ + local b=$1 mx=$2; [ "$b" -ge "$BUSY_MIN" ] && [ "$mx" -gt 0 ] && [ "$mx" -lt "$TRIP_MHZ" ] && echo 1 || echo 0 + } + ec_mode() { sudo -n /run/current-system/sw/bin/fan-ec mode 2>/dev/null || echo "?"; } + status_line() { + local b mx avg; b=$(busy); read -r avg mx <<< "$(clocks)" + local cl; cl=$(clamped "$b" "$mx") + printf 'CPU %s°C · GPU %s°C · fan %s rpm %s%% · cap %s MHz · clocks avg %s / max %s · load %s%% · clamp %s · fans %s\n' \ + "$(cpu_temp)" "$(gpu_temp)" "$(fan_rpm)" "$(fan_pct)" "$(cap_mhz)" "$avg" "$mx" "$b" \ + "$([ "$cl" = 1 ] && echo YES || echo no)" "$(ec_mode)" + } + ''; + + fan-watchdog = pkgs.writeShellScriptBin "fan-watchdog" '' + set -uo pipefail + PATH=${bin}:$PATH + . ${lib-sh} + LOG=''${XDG_STATE_HOME:-$HOME/.local/state}/fan-watchdog.log; mkdir -p "$(dirname "$LOG")" + log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; } + log "armed: fans manual ($1), watching for the EC clamp" + busy >/dev/null; sleep 1 + while :; do + b=$(busy); read -r _ mx <<< "$(clocks)" + if [ "$(clamped "$b" "$mx")" = 1 ]; then + out=$(sudo -n /run/current-system/sw/bin/fan-ec auto 2>&1) + log "CLAMP: load $b %, max core $mx MHz -> fan-ec auto: $out" + notify-send -a fan -u critical "Fans back to auto" "EC clamp hit (load $b %, cores at $mx MHz). Manual fan mode released." + exit 0 + fi + sleep 1 + done + ''; + + fan = pkgs.writeShellScriptBin "fan" '' + set -uo pipefail + PATH=${bin}:$PATH + . ${lib-sh} + UNIT=fan-watchdog + EC=/run/current-system/sw/bin/fan-ec + LOG=''${XDG_STATE_HOME:-$HOME/.local/state}/fan-watchdog.log + + arm() { # start (or restart) the watchdog as a transient user unit + systemctl --user stop "$UNIT" 2>/dev/null || true + systemd-run --user --unit="$UNIT" --collect --quiet ${fan-watchdog}/bin/fan-watchdog "$1" \ + && echo "watchdog armed: back to auto the moment the EC clamps the CPU (fan log)" + } + disarm() { systemctl --user stop "$UNIT" 2>/dev/null || true; } + + case "''${1:-}" in + ""|status) status_line ;; + watch) + iv=''${2:-2}; busy >/dev/null; sleep "$iv" + while :; do status_line; sleep "$iv"; done ;; + max) sudo -n "$EC" max && arm max ;; + auto) disarm; sudo -n "$EC" auto ;; + [0-9]*) p=''${1%\%}; sudo -n "$EC" "$p" && arm "$p %" ;; + ec) sudo -n "$EC" status ;; + log) [ -r "$LOG" ] && tail -n ''${2:-20} "$LOG" || echo "no watchdog log yet" ;; + -h|--help|help) + echo "usage: fan [status] | watch [sec] | max | <30-100> | auto | ec | log [n]" + echo " max / <pct> put the fans in manual mode WITH the clamp watchdog; auto releases them." ;; + *) echo "fan: unknown command '$1' (try: fan help)" >&2; exit 2 ;; + esac + ''; +in +{ + home.packages = [ fan fan-watchdog ]; +} diff --git a/home/modules/hyprland.nix b/home/modules/hyprland.nix @@ -6,6 +6,7 @@ # config is split like the vault's shortcuts/: # hypr/omarchy.lua the `o` helpers the carried files were written against # hypr/core.lua monitor, env, look, input, Caelestia layer rules +# hypr/looknfeel.lua the springy animations, shadows, snap, swallow (dotfiles looknfeel.lua) # hypr/defaults.lua the stock Omarchy binds as captured in keybinds.txt # hypr/bindings.lua shortcuts/bindings.lua (user overrides, window mode) # hypr/lid.lua shortcuts/lid.lua @@ -14,6 +15,7 @@ let system = pkgs.stdenv.hostPlatform.system; hyprPkg = inputs.hyprland.packages.${system}.hyprland; + shellCli = "${config.programs.caelestia.cli.package}/bin/caelestia"; # Small helpers the stock Omarchy binds relied on. They exist only to serve # this config, so they live here rather than in ~/.local/bin. @@ -56,6 +58,18 @@ let print("\n".join(sorted(rows)))' | ${pkgs.fuzzel}/bin/fuzzel --dmenu --prompt "Keybindings › " --width 120 --lines 30) || exit 0 [ -n "$sel" ] && printf '%s' "''${sel%% *}" | ${pkgs.wl-clipboard}/bin/wl-copy '') + # CTRL+SUPER+SPACE: what Omarchy's background switcher did, with Caelestia's + # own wallpaper grid. The launcher shows it when its search starts with + # ">wallpaper ", and there is no IPC for that, so the prefix is typed in. + (pkgs.writeShellScriptBin "wallpaper-picker" '' + c=${shellCli} + case "$($c shell drawers isOpen launcher 2>/dev/null)" in + 1|true) exec $c shell drawers toggle launcher ;; + esac + $c shell drawers toggle launcher + sleep 0.25 + exec ${pkgs.wtype}/bin/wtype '>wallpaper ' + '') (pkgs.writeShellScriptBin "nightlight-toggle" '' if pgrep -x hyprsunset >/dev/null; then pkill -x hyprsunset; ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 2000 "Night light" "off" @@ -78,19 +92,21 @@ in extraLuaFiles = { omarchy = { content = ../hypr/omarchy.lua; autoLoad = false; }; core = { content = ../hypr/core.lua; autoLoad = false; }; + looknfeel = { content = ../hypr/looknfeel.lua; autoLoad = false; }; defaults = { content = ../hypr/defaults.lua; autoLoad = false; }; bindings = { content = ../hypr/bindings.lua; autoLoad = false; }; lid = { content = ../hypr/lid.lua; autoLoad = false; }; caelestia = { content = ../hypr/caelestia.lua; autoLoad = false; }; }; - # Explicit load order: the stock binds first, then the carried files that + # Explicit load order: look first, then the stock binds, then the carried files that # unbind-and-rebind the keys they take over, Caelestia's keys last. extraConfig = '' local cfg = (os.getenv("XDG_CONFIG_HOME") or (os.getenv("HOME") .. "/.config")) .. "/hypr" package.path = cfg .. "/?.lua;" .. package.path require("omarchy") require("core") + require("looknfeel") require("defaults") require("bindings") require("lid") diff --git a/home/modules/neovim.nix b/home/modules/neovim.nix @@ -0,0 +1,115 @@ +# home/modules/neovim.nix — Neovim through nvf (github:notashelf/nvf): the +# editor and its plugins are one Nix-built package, no plugin manager, no +# ~/.config/nvim, nothing downloaded on first start. Replaces the AstroNvim +# tree the dotfiles carried (2026-10-08): that one pulled ~60 plugins through +# lazy.nvim and compiled treesitter parsers on the machine. +# +# Kept deliberately small. Languages: the ones this machine edits (Nix, Lua +# for Hyprland, Python and Bash for the toolbox, Markdown for the vault, and +# the config formats). Each gets treesitter, an LSP and a formatter; format on +# save is off, `<leader>lf` formats on demand. +# +# <leader>ff / fg / fb telescope: files / live grep / buffers +# - oil: edit the parent directory as a buffer +# <leader>e oil in a floating window +# gd gr K <leader>ca LSP: definition, references, hover, code action (nvf defaults) +# <leader>lf format buffer +# ]c [c <leader>gp gitsigns: next/previous hunk, preview hunk +# gcc gc{motion} comment.nvim +# <Esc> clear search highlight +# <space> leader +# +# kitty's copy mode (home/modules/terminal.nix) starts plain pkgs.neovim with +# -u, so it is unaffected by this configuration and stays instant. +{ inputs, pkgs, ... }: +{ + imports = [ inputs.nvf.homeManagerModules.default ]; + + programs.nvf = { + enable = true; + settings.vim = { + viAlias = true; + vimAlias = true; + + theme = { + enable = true; + name = "rose-pine"; + style = "main"; # main, not moon + transparent = false; + }; + + # Editor behaviour + lineNumberMode = "relNumber"; + searchCase = "smart"; + preventJunkFiles = true; + undoFile.enable = true; + clipboard = { + enable = true; + registers = "unnamedplus"; + providers.wl-copy.enable = true; + }; + options = { + tabstop = 2; + shiftwidth = 2; + softtabstop = 2; + scrolloff = 6; + wrap = false; + signcolumn = "yes"; + cursorline = true; + splitbelow = true; + splitright = true; + updatetime = 250; + timeoutlen = 400; + }; + + # Languages: treesitter + LSP + formatter each, chosen by nvf's defaults + # (nil for Nix, basedpyright/ruff for Python, lua-language-server, + # bash-language-server/shfmt, marksman, yaml/json/taplo). + lsp = { + enable = true; + formatOnSave = false; + inlayHints.enable = false; + }; + languages = { + enableTreesitter = true; + enableFormat = true; + nix = { + enable = true; + format.type = [ "nixfmt" ]; # the style this repo is written in + }; + lua.enable = true; + python.enable = true; + bash.enable = true; + markdown.enable = true; + yaml.enable = true; + json.enable = true; + toml.enable = true; + }; + + autocomplete.blink-cmp.enable = true; + telescope.enable = true; + git.gitsigns.enable = true; + binds.whichKey.enable = true; + statusline.lualine.enable = true; + autopairs.nvim-autopairs.enable = true; + comments.comment-nvim.enable = true; + utility.oil-nvim.enable = true; + visuals.nvim-web-devicons.enable = true; + ui.borders.enable = true; + + keymaps = [ + { key = "-"; mode = "n"; action = "<cmd>Oil<CR>"; desc = "Open parent directory"; silent = true; } + { key = "<leader>e"; mode = "n"; action = "<cmd>Oil --float<CR>"; desc = "Explorer (oil)"; silent = true; } + { key = "<Esc>"; mode = "n"; action = "<cmd>nohlsearch<CR>"; desc = "Clear search highlight"; silent = true; } + { key = "<leader>w"; mode = "n"; action = "<cmd>write<CR>"; desc = "Save"; silent = true; } + { key = "<leader>q"; mode = "n"; action = "<cmd>quit<CR>"; desc = "Quit"; silent = true; } + { key = "<C-h>"; mode = "n"; action = "<C-w>h"; desc = "Window left"; } + { key = "<C-j>"; mode = "n"; action = "<C-w>j"; desc = "Window down"; } + { key = "<C-k>"; mode = "n"; action = "<C-w>k"; desc = "Window up"; } + { key = "<C-l>"; mode = "n"; action = "<C-w>l"; desc = "Window right"; } + { key = "<"; mode = "v"; action = "<gv"; desc = "Dedent, keep selection"; } + { key = ">"; mode = "v"; action = ">gv"; desc = "Indent, keep selection"; } + ]; + }; + }; +} diff --git a/home/modules/prompt.nix b/home/modules/prompt.nix @@ -0,0 +1,245 @@ +# home/modules/prompt.nix — the starship prompt and the fastfetch card, fresh +# (2026-10-08), Rosé Pine, one colour per section, nothing Omarchy. +# +# Prompt (two lines, the dotfiles' "filigree" frame kept, the per-letter +# gradients gone): +# +# ╭╌ ☧ daemonsec@nixos ┄ 󰉋 ~/NixDaemon ┄ main [+2 !1] ⌁⡇⡆· (right: 󰔚 3s · 󰥔 14:02) +# ╰╌ ❯ +# +# glyph iris · user rose · host foam · directory gold · git love (status subtle, +# week heartbeat iris) · languages text · duration/jobs gold · clock rose · +# prompt char foam (love after an error). pine is never ink (3.3:1 on base). +# $CROSS_GLYPH comes from the dotfiles' animations.zsh (☧ + the NixOS glyph). +# +# theme.zsh in the dotfiles runs `starship init zsh` and adds the transient +# prompt, so home-manager's own shell integration stays off here. +# +# fastfetch: the builtin NixOS logo in iris/foam, keys in rotating Rosé Pine +# colours, the modules that matter on this laptop. The login splash +# (animations.zsh) runs plain `fastfetch` when CROSS_FETCH=plain, which +# .dotfiles/.zshrc now sets, so this config draws the whole card. +{ lib, ... }: +let + # Rosé Pine (main) + rp = { + love = "#eb6f92"; + gold = "#f6c177"; + rose = "#ebbcba"; + pine = "#31748f"; + foam = "#9ccfd8"; + iris = "#c4a7e7"; + text = "#e0def4"; + subtle = "#908caa"; + muted = "#6e6a86"; + }; + # the same colours as SGR parameters for fastfetch + sgr = { + love = "38;2;235;111;146"; + gold = "38;2;246;193;119"; + rose = "38;2;235;188;186"; + foam = "38;2;156;207;216"; + iris = "38;2;196;167;231"; + text = "38;2;224;222;244"; + subtle = "38;2;144;140;170"; + muted = "38;2;110;106;134"; + }; + lang = symbol: colour: { + inherit symbol; + format = " [$symbol($version)](fg:${colour})"; + }; +in +{ + programs.starship = { + enable = true; + enableZshIntegration = false; # theme.zsh does it (with the transient prompt) + enableBashIntegration = false; + settings = { + "$schema" = "https://starship.rs/config-schema.json"; + add_newline = true; + palette = "rose_pine"; + palettes.rose_pine = rp; + + format = lib.concatStrings [ + "[╭╌](fg:muted) " + "\${env_var.CROSS_GLYPH}" + "$username" + "$hostname" + "$shlvl" + "$sudo" + "\${custom.root}" + "$directory" + "$git_branch" + "$git_status" + "\${custom.gitweek}" + "$git_state" + "$python" + "$nodejs" + "$rust" + "$golang" + "$lua" + "$docker_context" + "$package" + "$line_break" + "[╰╌](fg:muted) " + "$status" + "$character" + ]; + right_format = lib.concatStrings [ "$cmd_duration" "$jobs" "$battery" "$time" ]; + + # identity + env_var.CROSS_GLYPH = { + variable = "CROSS_GLYPH"; + default = "☧"; + format = "[$env_value](bold fg:iris) "; + }; + username = { + show_always = true; + format = "[$user](bold fg:rose)"; + style_user = "bold fg:rose"; + style_root = "bold fg:love"; + }; + hostname = { + ssh_only = false; + format = "[@](fg:muted)[$hostname](bold fg:foam)"; + }; + shlvl = { + disabled = false; + threshold = 2; + format = " [↕$shlvl](bold fg:gold)"; + }; + sudo = { + disabled = false; + format = " [](bold fg:love)"; + }; + custom.root = { + command = "echo ROOT"; + when = "[ \"$(id -u)\" -eq 0 ]"; + format = " [ $output](bold underline fg:love)"; + }; + + # place + directory = { + format = " [┄](fg:muted) [󰉋 $path](bold fg:gold)[$read_only](fg:love)"; + truncation_length = 4; + truncate_to_repo = true; + truncation_symbol = "…/"; + read_only = " 󰌾"; + }; + + # git + git_branch = { + symbol = " "; + format = " [┄](fg:muted) [$symbol$branch(:$remote_branch)](bold fg:love)"; + }; + git_status = { + format = "( [\\[$all_status$ahead_behind\\]](fg:subtle))"; + ahead = "⇡\${count}"; + behind = "⇣\${count}"; + diverged = "⇕⇡\${ahead_count}⇣\${behind_count}"; + conflicted = "="; + untracked = "?"; + stashed = "≡"; + modified = "!"; + staged = "+"; + renamed = "»"; + deleted = "✘"; + }; + # the last 7 days of commits as a braille pulse (carried from the dotfiles) + custom.gitweek = { + command = ''git log --since=7.days --date=format:%Y%m%d --pretty=%cd 2>/dev/null | sort | uniq -c | awk 'BEGIN{split("· ⡀ ⡄ ⡆ ⡇",b," ")}{c[$2]=$1}END{for(i=6;i>=0;i--){d=strftime("%Y%m%d",systime()-i*86400);v=c[d]+0;idx=(v==0)?1:(v<3)?2:(v<6)?3:(v<10)?4:5;printf "%s",b[idx]}}' ''; + when = "git rev-parse --is-inside-work-tree 2>/dev/null | grep -q true"; + format = " [⌁$output](fg:iris)"; + }; + git_state = { + format = " [\\($state $progress_current/$progress_total\\)](bold fg:love)"; + }; + + # toolchains: only when the directory uses them + python = (lang " " "text") // { format = " [$symbol$version( \\($virtualenv\\))](fg:text)"; }; + nodejs = lang " " "text"; + rust = lang " " "text"; + golang = lang " " "text"; + lua = lang " " "text"; + docker_context = { symbol = " "; format = " [$symbol$context](fg:subtle)"; }; + package = { symbol = "󰏗 "; format = " [$symbol$version](fg:subtle)"; }; + + # right side + cmd_duration = { min_time = 2000; format = "[󰔚 $duration](fg:gold) "; }; + jobs = { symbol = "󰜎 "; format = "[$symbol$number](bold fg:gold) "; }; + battery = { + full_symbol = "󱈑 "; + charging_symbol = "󰂄 "; + discharging_symbol = "󱈏 "; + unknown_symbol = "󰂑 "; + empty_symbol = "󰁺 "; + format = "[$symbol$percentage]($style) "; + display = [ + { threshold = 20; style = "bold fg:love"; } + { threshold = 50; style = "fg:gold"; } + ]; + }; + time = { + disabled = false; + time_format = "%H:%M"; + format = "[󰥔 $time](fg:rose)"; + }; + + # second line + status = { + disabled = false; + symbol = "✗ "; + format = "[$symbol$status](fg:love) "; + }; + character = { + success_symbol = "[❯](bold fg:foam)"; + error_symbol = "[❯](bold fg:love)"; + vimcmd_symbol = "[❮](bold fg:gold)"; + vimcmd_replace_one_symbol = "[❮](bold fg:rose)"; + vimcmd_replace_symbol = "[❮](bold fg:iris)"; + vimcmd_visual_symbol = "[❮](bold fg:gold)"; + }; + line_break.disabled = false; + }; + }; + + programs.fastfetch = { + enable = true; + settings = { + "$schema" = "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json"; + logo = { + type = "builtin"; + source = "nixos"; + color = { "1" = sgr.iris; "2" = sgr.foam; }; + padding = { top = 1; left = 2; right = 5; }; + }; + display = { + separator = " "; + color = { keys = sgr.foam; title = sgr.rose; }; + }; + modules = [ + { type = "title"; color = { user = sgr.rose; at = sgr.muted; host = sgr.foam; }; } + { type = "separator"; string = "┄"; length = 34; outputColor = sgr.muted; } + { type = "os"; key = " os"; keyColor = sgr.iris; } + { type = "kernel"; key = " kernel"; keyColor = sgr.foam; } + { type = "uptime"; key = " uptime"; keyColor = sgr.gold; } + { type = "packages"; key = "󰏗 packages"; keyColor = sgr.rose; } + { type = "shell"; key = " shell"; keyColor = sgr.love; } + "break" + { type = "wm"; key = " wm"; keyColor = sgr.iris; } + { type = "display"; key = "󱄄 display"; keyColor = sgr.foam; compactType = "original-with-refresh-rate"; } + { type = "terminal"; key = " terminal"; keyColor = sgr.gold; } + { type = "terminalfont"; key = " font"; keyColor = sgr.rose; } + "break" + { type = "host"; key = "󰌢 host"; keyColor = sgr.love; } + { type = "cpu"; key = " cpu"; keyColor = sgr.iris; showPeCoreCount = true; } + { type = "gpu"; key = " gpu"; keyColor = sgr.foam; detectionMethod = "pci"; format = "{name}"; } + { type = "memory"; key = " memory"; keyColor = sgr.gold; } + { type = "disk"; key = "󰋊 disk"; keyColor = sgr.rose; folders = "/"; } + { type = "battery"; key = "󰁹 battery"; keyColor = sgr.love; } + "break" + { type = "colors"; symbol = "circle"; paddingLeft = 2; } + ]; + }; + }; +} diff --git a/home/modules/shell.nix b/home/modules/shell.nix @@ -0,0 +1,115 @@ +# home/modules/shell.nix — zsh as the shell, configured by the dotfiles. +# +# The dotfiles checkout (~/git/daemon-sec-dotfiles) carries a complete zsh +# setup in home/.dotfiles: a ZDOTDIR tree with core.zsh (history, cached +# compinit, the plugin loader, zoxide/atuin/fzf hooks), the leaf modules +# (aliases, fzf verbs, ripgrep, cheats, music, jj, …), theme.zsh (starship +# with a transient prompt) and animations.zsh (the login splash). The plugins +# it loads — zsh-autosuggestions, zsh-syntax-highlighting, zsh-completions, +# fzf-tab, history-substring-search, you-should-use — are vendored in +# .dotfiles/config/plugins, so the config is used as-is rather than rewritten +# as home-manager options. This module only supplies what the Omarchy install +# had and NixOS does not: +# +# ~/.zshenv sets ZDOTDIR (home-manager owns this one file) +# ~/.dotfiles → the checkout's .dotfiles (edits follow the repo) +# ~/.fzf.zsh fzf's key bindings from the Nix store (core.zsh looks +# in /usr/share/fzf, which does not exist here) +# ~/.zsh/completions generated completions for tools without shipped ones +# tmux plugins from nixpkgs (~/.tmux.conf expects TPM; a shim loads them) +# ~/.config/secretspec the keyring provider +# +# The dotfiles themselves (~/.dotfiles, ~/.tmux.conf, the tool configs under +# ~/.config: starship, bat theme, atuin, fastfetch, cheats, …) are linked by +# home/modules/dotfiles.nix. NixOS side (hosts/laptop/default.nix): programs.zsh with the global compinit +# and prompt off (core.zsh and theme.zsh do those), users.<user>.shell. +{ config, pkgs, lib, ... }: +let + # Completions for tools that do not ship their own under share/zsh. + # (uv, jj, gh, atuin, zoxide, eza, bat, fd, rg do; /etc/zshrc puts the + # profiles' site-functions on fpath before core.zsh runs compinit.) + generatedCompletions = pkgs.runCommand "nixdaemon-zsh-completions" { } '' + mkdir -p $out + export HOME=$TMPDIR + ${pkgs.secretspec}/bin/secretspec completions zsh > $out/_secretspec + ''; + + # ~/.tmux.conf ends with `run '~/.tmux/plugins/tpm/tpm'` and lists plugins + # for TPM to clone. nixpkgs has the plugins but not TPM, so each plugin is + # linked where TPM would have put it and this stand-in sources them. + tpmShim = '' + #!${pkgs.bash}/bin/bash + # Stand-in for tmux-plugin-manager (NixDaemon home/modules/shell.nix): the + # plugins come from nixpkgs and are linked into ~/.tmux/plugins; this runs + # each plugin's entry script the way TPM would. prefix+I/U do nothing here; + # add plugins in shell.nix instead. + for f in "$HOME"/.tmux/plugins/*/*.tmux; do + case "$f" in */tpm/*) continue ;; esac + [ -x "$f" ] && "$f" + done + exit 0 + ''; + tmuxPlugin = name: pkg: { + name = ".tmux/plugins/${name}"; + value.source = "${pkg}/share/tmux-plugins/${pkg.pluginName}"; + }; +in +{ + home.packages = with pkgs; [ + zsh + tmux + secretspec + ]; + + home.file = { + # zsh: hand over to the dotfiles' ZDOTDIR tree. + ".zshenv".text = '' + # Managed by home-manager (NixDaemon home/modules/shell.nix). The shell + # configuration lives in ~/.dotfiles (→ ~/git/daemon-sec-dotfiles). + export ZDOTDIR="$HOME/.dotfiles" + [[ -r "$HOME/.cargo/env" ]] && . "$HOME/.cargo/env" + ''; + ".fzf.zsh".text = '' + # fzf key bindings (Ctrl-T, Alt-C, Ctrl-R) and completion from the Nix + # store; sourced by ~/.dotfiles/config/core.zsh. Quiet when there is no + # tty (the scripts restore `zle`, which fails outside a terminal). + if [[ -t 0 ]]; then + source ${pkgs.fzf}/share/fzf/key-bindings.zsh + source ${pkgs.fzf}/share/fzf/completion.zsh + else + { source ${pkgs.fzf}/share/fzf/key-bindings.zsh; source ${pkgs.fzf}/share/fzf/completion.zsh; } 2>/dev/null + fi + ''; + ".zsh/completions".source = generatedCompletions; + + ".tmux/plugins/tpm/tpm" = { + text = tpmShim; + executable = true; + }; + } + // builtins.listToAttrs [ + (tmuxPlugin "tmux-resurrect" pkgs.tmuxPlugins.resurrect) + (tmuxPlugin "tmux-continuum" pkgs.tmuxPlugins.continuum) + (tmuxPlugin "tmux-yank" pkgs.tmuxPlugins.yank) + (tmuxPlugin "tmux-open" pkgs.tmuxPlugins.open) + ]; + + xdg.configFile = { + # secretspec (https://secretspec.dev): secrets in the system keyring, which + # gnome-keyring provides and PAM unlocks at login. Per-project + # secretspec.toml files declare what a project needs; `secretspec check` + # prompts for anything missing, `secretspec run -- cmd` injects them. + "secretspec/config.toml".text = '' + [defaults] + provider = "keyring" + profile = "default" + ''; + }; + + # bat only sees a theme after its cache is rebuilt from ~/.config/bat/themes. + home.activation.batCache = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + if [ -d "$HOME/.config/bat/themes/" ]; then + run ${pkgs.bat}/bin/bat cache --build >/dev/null 2>&1 || true + fi + ''; +} diff --git a/home/modules/sops.nix b/home/modules/sops.nix @@ -0,0 +1,31 @@ +# home/modules/sops.nix — sops-nix for the user: the same encrypted file, +# decrypted into $XDG_RUNTIME_DIR/secrets.d (symlinked at ~/.config/sops-nix/secrets) +# by a user service at login, readable only by daemonsec. +# +# Use this for secrets that belong to the user's programs (API tokens an app +# reads from a file, an rclone config, …); use hosts/laptop/sops.nix for +# anything a system service needs. +# +# sops.secrets.example = { }; # → ~/.config/sops-nix/secrets/example +# sops.secrets.rclone = { path = "${config.xdg.configHome}/rclone/rclone.conf"; }; +# +# secretspec (home/modules/shell.nix) is the complement: per-project runtime +# secrets pulled from the keyring at `secretspec run`, declared next to the +# project in secretspec.toml, not in this repo. +{ config, inputs, pkgs, ... }: +{ + imports = [ inputs.sops-nix.homeManagerModules.sops ]; + + sops = { + defaultSopsFile = ../../secrets/secrets.yaml; + age.keyFile = "${config.home.homeDirectory}/.config/sops/age/keys.txt"; + age.sshKeyPaths = [ ]; + gnupg.sshKeyPaths = [ ]; + }; + + home.packages = with pkgs; [ + sops + age + ssh-to-age # turn an ssh-ed25519 key into an age recipient when adding another machine + ]; +} diff --git a/home/modules/terminal.nix b/home/modules/terminal.nix @@ -9,9 +9,46 @@ # puts pine in the green slot; the substitute is PARKED for the owner's # decision. Until then `ansiGreen` below carries foam, the colour the toolbox # itself uses wherever pine would have been read as text (bin/install.sh). +# +# tmux-style keys (2026-10-08): ctrl+a is a prefix, like tmux's, with tabs as +# tmux windows and kitty windows as tmux panes: +# +# ctrl+a c new tab (cwd kept) ctrl+a - split below (pane) +# ctrl+a n / p next / previous tab ctrl+a | split right +# ctrl+a 1..9 tab N ctrl+a h j k l focus pane left/down/up/right +# ctrl+a , rename tab ctrl+a H J K L move pane +# ctrl+a & close tab ctrl+a o next pane +# ctrl+a x close pane ctrl+a z zoom pane (stack layout toggle) +# ctrl+a [ copy mode (scrollback in Neovim: v y, Enter, q) +# ctrl+a ] paste clipboard ctrl+a space next layout +# ctrl+a { } swap pane back / forth ctrl+a r reload kitty.conf +# ctrl+a u pick a URL (hints) ctrl+a f pick a path (hints) +# ctrl+a ctrl+a send a real ctrl+a to the shell (beginning-of-line) +# ctrl+a shift+arrows resize pane ctrl+a = reset pane sizes +# +# Copy mode is home/kitty/scrollback.lua: the scrollback opens in a bare +# Neovim (no AstroNvim config) with colours, vi motions and search; y copies +# to the clipboard, Enter copies and leaves, q or Esc leaves. { pkgs, lib, ... }: let ansiGreen = "#9ccfd8"; # PARKED: ask before changing; see header + + # kitty substitutes INPUT_LINE_NUMBER, CURSOR_LINE and CURSOR_COLUMN in the + # pager command; the Lua reads them from vim.g. + scrollbackPager = lib.concatStringsSep " " [ + "${pkgs.bash}/bin/bash -c" + "'exec ${pkgs.neovim}/bin/nvim 63<&0 0</dev/null" + "-u ${../kitty/scrollback.lua}" + "-c \"let g:kitty_input_line=INPUT_LINE_NUMBER\"" + "-c \"let g:kitty_cursor_line=CURSOR_LINE\"" + "-c \"let g:kitty_cursor_col=CURSOR_COLUMN\"'" + ]; + + prefix = "ctrl+a"; + tabKeys = lib.listToAttrs (map (n: { + name = "${prefix}>${toString n}"; + value = "goto_tab ${toString n}"; + }) (lib.range 1 9)); in { fonts.fontconfig.enable = true; @@ -59,6 +96,64 @@ in color14 = "#ebbcba"; color7 = "#e0def4"; color15 = "#e0def4"; + + # tmux-like layout: panes via the splits layout, stack = zoom, tabs on a + # bottom status line with their index like tmux's window list. + enabled_layouts = "splits,stack"; + window_border_width = "1pt"; + inactive_text_alpha = "0.8"; + tab_bar_edge = "bottom"; + tab_bar_style = "powerline"; + tab_powerline_style = "slanted"; + tab_title_template = "{index}:{title}"; + active_tab_title_template = "{index}:{title}"; + scrollback_lines = 20000; + scrollback_pager = scrollbackPager; + shell_integration = "enabled"; + # Always zsh, whatever $SHELL the session was started with (a session + # begun before the login shell changed still carries SHELL=bash). + shell = "${pkgs.zsh}/bin/zsh"; }; + + keybindings = { + # tabs = tmux windows + "${prefix}>c" = "new_tab_with_cwd"; + "${prefix}>n" = "next_tab"; + "${prefix}>p" = "previous_tab"; + "${prefix}>," = "set_tab_title"; + "${prefix}>&" = "close_tab"; + "${prefix}>w" = "select_tab"; + # panes = kitty windows + "${prefix}>-" = "launch --location=hsplit --cwd=current"; + "${prefix}>|" = "launch --location=vsplit --cwd=current"; + "${prefix}>x" = "close_window"; + "${prefix}>o" = "next_window"; + "${prefix}>z" = "toggle_layout stack"; + "${prefix}>space" = "next_layout"; + "${prefix}>h" = "neighboring_window left"; + "${prefix}>j" = "neighboring_window down"; + "${prefix}>k" = "neighboring_window up"; + "${prefix}>l" = "neighboring_window right"; + "${prefix}>shift+h" = "move_window left"; + "${prefix}>shift+j" = "move_window down"; + "${prefix}>shift+k" = "move_window up"; + "${prefix}>shift+l" = "move_window right"; + "${prefix}>{" = "move_window_backward"; + "${prefix}>}" = "move_window_forward"; + "${prefix}>shift+left" = "resize_window narrower 3"; + "${prefix}>shift+right" = "resize_window wider 3"; + "${prefix}>shift+up" = "resize_window taller 3"; + "${prefix}>shift+down" = "resize_window shorter 3"; + "${prefix}>=" = "resize_window reset"; + # copy mode and paste + "${prefix}>[" = "show_scrollback"; + "${prefix}>]" = "paste_from_clipboard"; + "${prefix}>u" = "open_url_with_hints"; + "${prefix}>f" = "kitten hints --type path --program -"; + # misc + "${prefix}>r" = "load_config_file"; + "${prefix}>?" = "kitten show_key -m kitty"; + "${prefix}>${prefix}" = "send_text all \\x01"; + } // tabKeys; }; } diff --git a/home/modules/tools.nix b/home/modules/tools.nix @@ -1,13 +1,27 @@ # home/modules/tools.nix — runtime closure for the hand-written toolbox in -# ~/.local/bin, plus the files that come from the dotfiles checkout. +# ~/.local/bin, plus the general command-line tools. # # ~/.local/bin itself is not managed here on purpose: it is a flat git repo # (vault README: "git init there afterwards so editing a file edits the live # command"). NixOS puts it first on PATH (hosts/laptop/toolbox.nix). { config, pkgs, lib, ... }: let - dotfiles = "${config.home.homeDirectory}/git/daemon-sec-dotfiles"; - link = path: config.lib.file.mkOutOfStoreSymlink "${dotfiles}/${path}"; + # ns [query]: fuzzy search of nixpkgs (+ NixOS and home-manager options) in + # the terminal, with the package description as the preview. nix-search-tv + # indexes search.nixos.org data locally on first run and refreshes it itself. + # Enter print the attribute name (e.g. to paste into tools.nix) + # ctrl-o open the homepage ctrl-s open the nixpkgs source + # ctrl-y copy the attribute name + ns = pkgs.writeShellScriptBin "ns" '' + nst=${pkgs.nix-search-tv}/bin/nix-search-tv + exec $nst print | ${pkgs.fzf}/bin/fzf \ + --query="$*" --scheme=history --prompt='nix › ' \ + --preview="$nst preview {}" --preview-window='right,60%,border-left,wrap' \ + --bind="ctrl-o:execute-silent(${pkgs.xdg-utils}/bin/xdg-open \$($nst homepage {}))" \ + --bind="ctrl-s:execute-silent(${pkgs.xdg-utils}/bin/xdg-open \$($nst source {}))" \ + --bind="ctrl-y:execute-silent(printf %s {} | ${pkgs.wl-clipboard}/bin/wl-copy)" \ + --header='enter: print · ctrl-y: copy · ctrl-o: homepage · ctrl-s: source' + ''; pythonEnv = pkgs.python3.withPackages (ps: with ps; [ cryptography @@ -25,6 +39,8 @@ in { home.packages = with pkgs; [ pythonEnv + ns + nix-search-tv # `ns`, and `nix-search-tv print|preview` by hand perl git jujutsu @@ -69,13 +85,35 @@ in fastfetch wl-clipboard libnotify - ]; - # From the dotfiles checkout, as symlinks (no copy into the store, and the - # files follow the repo). They dangle harmlessly until the repo is cloned. - home.file = { - ".local/share/fonts/nerd-fonts-dm-mono".source = link "assets/fonts/nerd-fonts-dm-mono"; - ".local/share/icons/modernxp-retro-black".source = link "home/.local/share/icons/modernxp-retro-black"; - ".local/share/icons/modernxp-retro-black-hyprcursor".source = link "home/.local/share/icons/modernxp-retro-black-hyprcursor"; - }; + # General tools (2026-10-08): what the dotfiles' zsh modules look for + # (modern.zsh, core.zsh) and what the stock Omarchy keys expect. + uv # PEP-723 scripts in ~/.local/bin (dcx, dorkforge); managed Pythons work via nix-ld + nodejs + btop + yazi + lazygit + lazydocker + tealdeer # `tldr` + dust + duf + procs + delta + difftastic + hyperfine + glow + onefetch + tokei + xh + ncdu + parallel + unzip + zip + tree + file + cbonsai + cmatrix + localsend # phone ↔ laptop file drops on the LAN; port 53317 is open in hosts/laptop/default.nix + vesktop # Discord client; its config is linked from the dotfiles (dotfiles.nix) + ]; } diff --git a/hosts/laptop/default.nix b/hosts/laptop/default.nix @@ -14,6 +14,8 @@ ./ssd.nix ./nix-settings.nix ./toolbox.nix + ./sops.nix # sops-nix: secrets/secrets.yaml → /run/secrets + ./fan-cli.nix # fan-ec: root side of the `fan` command (home/modules/fan.nix), passwordless for wheel ]; boot.loader.systemd-boot.enable = true; @@ -21,6 +23,9 @@ networking.hostName = "nixos"; networking.networkmanager.enable = true; + # LocalSend (home/modules/tools.nix) discovers peers and receives on 53317. + networking.firewall.allowedTCPPorts = [ 53317 ]; + networking.firewall.allowedUDPPorts = [ 53317 ]; time.timeZone = "Europe/Isle_of_Man"; i18n.defaultLocale = "en_US.UTF-8"; @@ -35,8 +40,24 @@ isNormalUser = true; description = "daemon-sec"; extraGroups = [ "networkmanager" "wheel" ]; + shell = pkgs.zsh; }; + ##### Shell ################################################################## + # zsh is the login shell. Its configuration is the dotfiles' ZDOTDIR tree + # (home/modules/shell.nix): core.zsh runs a cached compinit and theme.zsh + # starts starship, so the global compinit and the default prompt stay off. + # /etc/zshrc still puts every profile's share/zsh/site-functions on fpath. + programs.zsh = { + enable = true; + enableGlobalCompInit = false; + promptInit = ""; + }; + + # Binaries that are not built by Nix (uv-managed Pythons and their wheels, + # anything mise or npm downloads) expect /lib64/ld-linux-x86-64.so.2. + programs.nix-ld.enable = true; + ##### Desktop ################################################################ # Hyprland package and portal come from inputs.hyprland.nixosModules.default. programs.hyprland = { diff --git a/hosts/laptop/fan-cli.nix b/hosts/laptop/fan-cli.nix @@ -0,0 +1,122 @@ +# hosts/laptop/fan-cli.nix — root side of the `fan` command (home/modules/fan.nix). +# +# `fan-ec` talks to the embedded controller the way fanfix does (same +# acpi_call recipe, same registers; see ~/.local/bin/fanfix and the TUXEDO +# driver), but it is a fixed script in the Nix store, so the wheel group may +# run it through sudo without a password. That is what lets the watchdog in +# fan.nix put the fans back to EC-automatic from a background unit, where +# sudo could not ask for one. fanfix itself lives in the user-writable +# ~/.local/bin and must never get such a rule. +# +# fan-ec status mode, duty %, EC flags fan-ec max 100 % (manual) +# fan-ec auto hand control back to the EC fan-ec <30-100> fixed % (manual) +# fan-ec mode one word: auto | manual | curve-daemon +# +# Manual mode on this laptop makes the EC assert PROCHOT (all cores 399 MHz) +# under load (fan-throttle-guard.nix, line ~97). fan-ec does not try to +# prevent that; the user-side watchdog detects the clamp and calls `fan-ec auto`. +{ pkgs, lib, ... }: +let + fan-ec = pkgs.writeShellScriptBin "fan-ec" '' + set -uo pipefail + [ "$(id -u)" = 0 ] || { echo "fan-ec: run as root (sudo fan-ec …)" >&2; exit 1; } + PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.kmod pkgs.systemd ]}:$PATH + + ACPI_CALL=/proc/acpi/call + EC_DEV='\_SB.INOU' + FAN_UNIT=fanfix-fan.service + FAN_MIN_PCT=30 + + ec_ready() { [ -w "$ACPI_CALL" ] || modprobe acpi_call 2>/dev/null; [ -w "$ACPI_CALL" ]; } + ec_raw() { printf '%s' "$1" > "$ACPI_CALL" || return 1; local o; o=$(tr -d '\0\n\r ' < "$ACPI_CALL"); printf '%s' "$o"; } + ec_read() { local out; out=$(ec_raw "$(printf '%s.ECRR 0x%04x' "$EC_DEV" "$1")") || return 1 + [[ "$out" =~ ^0x[0-9a-fA-F]+$ ]] || { echo "EC read $(printf '0x%04x' "$1") failed: $out" >&2; return 1; } + echo $(( out )); } + ec_write() { local out; out=$(ec_raw "$(printf '%s.ECRW 0x%04x 0x%02x' "$EC_DEV" "$1" "$2")") || return 1 + case "$out" in Error*|"not called"|"") echo "EC write $(printf '0x%04x' "$1") failed: ''${out:-no result}" >&2; return 1;; esac + sleep 0.005; } + ec_set_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v | $2 )); } + ec_clear_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v & ~$2 & 0xff )); } + ec_bit() { local v; v=$(ec_read "$1") || return 1; echo $(( (v >> $2) & 1 )); } + + R_AP_OEM=0x0741; R_FAN_MODE=0x0751; R_PWM1=0x075B; R_PWM2=0x075C + R_FAN_CTRL=0x078E; R_TBL_SPLIT=0x07C5; R_TBL_ENABLE=0x07C6 + R_CPU_TBL_END=0x0F00; R_CPU_TBL_START=0x0F10; R_CPU_TBL_SPEED=0x0F20 + R_GPU_TBL_END=0x0F30; R_GPU_TBL_START=0x0F40; R_GPU_TBL_SPEED=0x0F50 + R_PWM1_W=0x1804; R_PWM2_W=0x1809 + + universal_ctrl() { ec_bit $R_FAN_CTRL 6; } + tables_enabled() { ec_bit $R_TBL_ENABLE 2; } + pct_to_duty() { echo $(( $1 * 200 / 100 )); } + duty_to_pct() { echo $(( $1 * 100 / 200 )); } + + fan_init_tables() { + local i + ec_clear_bits $R_FAN_MODE 0x40 + [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] || ec_set_bits $R_TBL_SPLIT 0x80 + ec_write $R_CPU_TBL_END 115; ec_write $R_CPU_TBL_START 0; ec_write $R_CPU_TBL_SPEED 1 + ec_write $R_GPU_TBL_END 120; ec_write $R_GPU_TBL_START 0; ec_write $R_GPU_TBL_SPEED 1 + for i in $(seq 1 15); do + ec_write $(( R_CPU_TBL_END + i )) $(( 115 + i + 1 )); ec_write $(( R_CPU_TBL_START + i )) $(( 115 + i )); ec_write $(( R_CPU_TBL_SPEED + i )) 200 + ec_write $(( R_GPU_TBL_END + i )) $(( 115 + i + 1 )); ec_write $(( R_GPU_TBL_START + i )) $(( 115 + i )); ec_write $(( R_GPU_TBL_SPEED + i )) 200 + done + [ "$(tables_enabled)" = 1 ] || ec_set_bits $R_TBL_ENABLE 0x04 + } + fan_apply_duty() { + local d=$1 + if [ "$(universal_ctrl)" = 1 ]; then + [ "$(tables_enabled)" = 1 ] && [ "$(ec_bit $R_FAN_MODE 6)" = 0 ] || fan_init_tables + ec_write $R_CPU_TBL_SPEED "$d"; ec_write $R_GPU_TBL_SPEED "$d" + ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d" + else + local i; [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] || ec_set_bits $R_FAN_MODE 0x40 + for i in 1 2 3 4 5 6 7 8 9 10; do ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d"; sleep 0.01; done + fi + } + fan_set_auto() { + if [ "$(universal_ctrl)" = 1 ]; then + [ "$(tables_enabled)" = 1 ] && ec_clear_bits $R_TBL_ENABLE 0x04 + [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] && ec_clear_bits $R_TBL_SPLIT 0x80 + fi + [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] && ec_clear_bits $R_FAN_MODE 0x40 + return 0 + } + mode_word() { + if systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null; then echo curve-daemon; return; fi + if [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] || [ "$(tables_enabled)" = 1 ]; then echo manual; else echo auto; fi + } + stop_daemon() { systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null && systemctl stop "$FAN_UNIT"; return 0; } + guard() { ec_ready || { echo "fan-ec: acpi_call not available" >&2; exit 1; } + ec_read $R_AP_OEM >/dev/null || { echo "fan-ec: EC not reachable via $EC_DEV.ECRR" >&2; exit 1; }; } + + case "''${1:-status}" in + mode) guard; mode_word ;; + status) guard + printf 'mode=%s duty=%s%% target=%s%% fan-abnormal=%s full-fan-bit=%s tables=%s\n' \ + "$(mode_word)" "$(duty_to_pct "$(ec_read $R_PWM1)")" \ + "$([ "$(tables_enabled)" = 1 ] && duty_to_pct "$(ec_read $R_CPU_TBL_SPEED)" || echo -)" \ + "$(ec_bit $R_AP_OEM 5)" "$(ec_bit $R_FAN_MODE 6)" "$(tables_enabled)" ;; + auto) guard; stop_daemon; fan_set_auto && echo "fans: auto (EC curve)" ;; + max) guard; stop_daemon; fan_apply_duty 200 && echo "fans: 100 % (manual)" ;; + [0-9]*) p=''${1%\%}; [[ "$p" =~ ^[0-9]+$ ]] && [ "$p" -le 100 ] && [ "$p" -ge "$FAN_MIN_PCT" ] \ + || { echo "fan-ec: percent must be $FAN_MIN_PCT-100 (below that the EC trips at 79 °C)" >&2; exit 2; } + guard; stop_daemon; fan_apply_duty "$(pct_to_duty "$p")" && echo "fans: $p % (manual)" ;; + *) echo "usage: fan-ec status|mode|auto|max|<30-100>" >&2; exit 2 ;; + esac + ''; +in +{ + environment.systemPackages = [ fan-ec ]; + + # wheel may run fan-ec without a password: it is immutable store content + # (via the system profile symlink, which is root-owned), does one thing, + # and the watchdog has no terminal to type into. + security.sudo.extraRules = [ + { + groups = [ "wheel" ]; + commands = [ + { command = "/run/current-system/sw/bin/fan-ec"; options = [ "NOPASSWD" ]; } + ]; + } + ]; +} diff --git a/hosts/laptop/nvidia.nix b/hosts/laptop/nvidia.nix @@ -5,8 +5,8 @@ # the MUX, so this configures what the hardware presents: NVIDIA open kernel # module with modesetting, the Radeon 680M left as a secondary DRM device. # -# PARKED: if the MUX is switched to hybrid in the BIOS, replace the AQ_DRM_DEVICES -# order with the amdgpu card first and add the prime offload block at the bottom. +# PARKED: if the MUX is switched to hybrid in the BIOS, swap the AQ_DRM_DEVICES +# order (igpu-card first) and add the prime offload block at the bottom. { config, pkgs, lib, ... }: { services.xserver.videoDrivers = [ "nvidia" ]; @@ -24,9 +24,20 @@ # powerManagement.enable = true; # suspend/resume helpers; untested on this laptop, left at default }; + # Stable, colon-free names for the two DRM cards. Aquamarine splits + # AQ_DRM_DEVICES on ':', so the /dev/dri/by-path names cannot go in it (the PCI + # address has colons): it chopped them into "pci-0000", "01", "00.0-card", found + # no GPU and Hyprland aborted at startup with "CBackend::create() failed!". + # ID_PATH is matched exactly so the boot-time simpledrm card (whose ID_PATH is + # pci-0000:01:00.0-platform-simple-framebuffer.0) does not take the dGPU name. + services.udev.extraRules = '' + SUBSYSTEM=="drm", KERNEL=="card[0-9]*", ENV{ID_PATH}=="pci-0000:01:00.0", SYMLINK+="dri/dgpu-card" + SUBSYSTEM=="drm", KERNEL=="card[0-9]*", ENV{ID_PATH}=="pci-0000:06:00.0", SYMLINK+="dri/igpu-card" + ''; + environment.sessionVariables = { # Stable device order for Hyprland/aquamarine: dGPU (panel) first, iGPU second. - AQ_DRM_DEVICES = "/dev/dri/by-path/pci-0000:01:00.0-card:/dev/dri/by-path/pci-0000:06:00.0-card"; + AQ_DRM_DEVICES = "/dev/dri/dgpu-card:/dev/dri/igpu-card"; LIBVA_DRIVER_NAME = "nvidia"; __GLX_VENDOR_LIBRARY_NAME = "nvidia"; NVD_BACKEND = "direct"; diff --git a/hosts/laptop/sops.nix b/hosts/laptop/sops.nix @@ -0,0 +1,32 @@ +# hosts/laptop/sops.nix — sops-nix: secrets encrypted in this repo, decrypted +# at activation into /run/secrets (root-only tmpfs; per-secret owner/mode). +# +# One age identity does everything (.sops.yaml): the user edits with the sops +# CLI (reads ~/.config/sops/age/keys.txt), the system decrypts at boot with a +# root-only copy at /var/lib/sops-nix/key.txt. Put it there once: +# +# sudo install -D -m 600 -o root -g root ~/.config/sops/age/keys.txt /var/lib/sops-nix/key.txt +# +# No SSH host key is used: sshd is not enabled on this machine, so there is +# none to derive an age key from (sshKeyPaths is emptied below for that reason). +# +# Declaring a secret: +# sops.secrets.my-token = { }; # → /run/secrets/my-token, root:root 0400 +# sops.secrets."ssd.key" = { path = "/etc/secrets/ssd.key"; }; # the LUKS key, once it is +# # added to secrets/secrets.yaml (sops set …) +# sops.secrets.wifi-psk = { owner = user; }; # readable by the user +# then `sops secrets/secrets.yaml` to add the value, and `nh os switch`. +{ inputs, user, ... }: +{ + imports = [ inputs.sops-nix.nixosModules.sops ]; + + sops = { + defaultSopsFile = ../../secrets/secrets.yaml; + age = { + keyFile = "/var/lib/sops-nix/key.txt"; + sshKeyPaths = [ ]; + generateKey = false; # the key is the user's (see header), never a fresh one + }; + gnupg.sshKeyPaths = [ ]; + }; +} diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml @@ -0,0 +1,20 @@ +#ENC[AES256_GCM,data:+DgIj7B9b9rqP2Y1h5x6Nb4vDONzAd4hB/kTF0LStamzaLZ5DMjUFnrmNXlMn+r/c1MFFGYoKlPdEaUVS+8GQ/BWTFWa4RtUc3x3N0/jJUy8VqP3jA==,iv:6BosyZToJSeQIjo+MgW9vRNW1xs1aOlzh134HG+6ONM=,tag:QODtSWiv4va/7uJrwhtpSg==,type:comment] +#ENC[AES256_GCM,data:f8KyKY4EEPz5kexEO3BaKdsYLW8/3nwNXjZ7IRpdLHeN49phiSqQXZ1J9v+TSo2BUmhY2+LTKZ6+XpsMxmQS8T3OWXw5ubXQgi/uh+CNyDDc,iv:luukOqefrSrN4EdOjo1kBbzx0WgJhD8j/qk+62DHBd4=,tag:uMTlGty8KaQmsVUnjZabQw==,type:comment] +#ENC[AES256_GCM,data:tM+6SNM68Ic6u4+prSUMzOZHnBzcffTZdlDyopfXDEuIaBxUofzyL1BhYsojlFHAZ34GXVZ3feBT+INw72d1jH1y/HJX7aJ3NnRKfBhV,iv:pfuzt7HNngh72wSmuqY23l1rrKBYY1A7sHa318ITBsA=,tag:PZXXy8nmNMtsH36zsD+09A==,type:comment] +#ENC[AES256_GCM,data:a2p5hr+IkHu5tV8yGp/BrT6Lo8NGpafVVv6AXuVVHqQxGjz1F1JCm2udrOTNYUjfq5ktUALQZxtvFjRJuFNoKcGgaDf8luu5cA==,iv:PWN3vdbdD7fSuaKs7o47c/Ynxgxdr8ZlIwRDCRiz720=,tag:/rJ8dymNnO7Hnjc1uIow3w==,type:comment] +example: ENC[AES256_GCM,data:EgrtVBB9Lt+pu0u3g9DP7Xwzr5PktqBr4vLw50rn/YlPVdMUzTgO/JmVgyVmjBlFXZySHDk=,iv:lFC1wvWwTdvFFbnXVNeXZGV5nGNCWFHq9kvBNn9bA2U=,tag:ta6b1cPg5MUimd7rRdT1Xw==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwZXQyMjZUZ2hWV0pNVi9N + ZzF0MEI0VUUrWlJTdjhSZU5pZnN0bW12SEZJCndrUnBmaE1qVlZIbGNJdCtwN2dH + bURHdjRibWloY1lqWVdsMktaTFFrd1UKLS0tIGlxY2ZwR3g2MVZlN2JmTm40UG1S + bnBFamxSMm5uRzdNMCs4RFVaOHczR28KAqpWcBSuTIoFjrm6BiXDuP4kM/Sxbie9 + NV86EcQtCT8AQqgtugSBUOjmZU6D45/rhEXAM98yP01b8Iw2HhEAuw== + -----END AGE ENCRYPTED FILE----- + recipient: age1pu5wcvqqh92xr6ces8zmj4xvs52v7yfm4jsfcxm3z82a32u9n97qesldfv + lastmodified: "2026-10-08T01:04:15Z" + mac: ENC[AES256_GCM,data:glJkL/drRDrChgo/69OXHe/nwQ99DCZXeWs92EDZn8EcqJbgKU+QoSEeU4THB6e0OYjRRbUvXtMM6vUbQ638UyT4ueDlxEqAsEpfN23/56GTdoqiqj/JiDRPwr5xFq2R09itmrnjjy1Tt3zodLL/nTmQQ9Sm6CONeeRwvoP9gnQ=,iv:+X8NyDUYY8NySWUaYfBGPTPFAa2YJHaz9gT/5pp6u8U=,tag:W4utRNHzEL4oXRjW0SowLw==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.3