Compare commits
445 Commits
v1
...
f658b0087e
| Author | SHA1 | Date | |
|---|---|---|---|
| f658b0087e | |||
| ac24ac7c34 | |||
| 8f85097140 | |||
| 036931ac51 | |||
| 8fdd9dc423 | |||
| 8e8a142465 | |||
| d79ad3517e | |||
| e0fba56ee6 | |||
| 786a8ad30e | |||
| bcdd3e14ee | |||
| 0347156315 | |||
| 26dc6f605e | |||
| f899b192fe | |||
| 8a4914c22e | |||
| 6671a2b95d | |||
| 7df7563051 | |||
| a818c19a4e | |||
| 07400ab4f9 | |||
| 2f5c1c8b99 | |||
| bbdc329ed6 | |||
| 5479ade702 | |||
| e324876eb7 | |||
| 8fc0a9bc29 | |||
| bdc86d0f41 | |||
| 90f0faf87b | |||
| fe6697bd22 | |||
| 321ccf7905 | |||
| a2151f424b | |||
| c32b51897e | |||
| ff5017ea5d | |||
| d31bdf05cd | |||
| 4a0b76cc97 | |||
| a6f86cf575 | |||
| 3cc644edd9 | |||
| ff0f9d6359 | |||
| 121b91f69c | |||
| 0cd6212b9c | |||
| 3e6cc6fe84 | |||
| 30e632ff65 | |||
| 02ec0f10cd | |||
| 207b6f3c64 | |||
| 48e73ab6d3 | |||
| 2e433ad088 | |||
| 5aad3153ce | |||
| 39c8b3e0eb | |||
| 55a516e1c7 | |||
| 4a63f27bf4 | |||
| 9e64358225 | |||
| 44aac0fcde | |||
| a40f99e417 | |||
| 64f7379982 | |||
| be636e14b9 | |||
| 9a280b2e18 | |||
| c05988c6d1 | |||
| b4fe4985bf | |||
| 060bf524af | |||
| e9ab0d8d64 | |||
| 52d1581131 | |||
| e9dd3d14c5 | |||
| edc1415521 | |||
| c9dd7575bf | |||
| e4800d7d8e | |||
| 1f6f21249e | |||
| 9dac8a8b23 | |||
| 856a9d1a49 | |||
| 0ba9633728 | |||
| 875dae9c58 | |||
| d8e1a13d50 | |||
| 013403deb4 | |||
| 46adcc9989 | |||
| e08426880d | |||
| be2d0d200b | |||
| e6be5a5770 | |||
| 748d4af414 | |||
| 25e498a5ef | |||
| 545e40f4d9 | |||
| 1e648b6c49 | |||
| a09399702d | |||
| 2c9df329dc | |||
| 70f2d4d54d | |||
| fbc93159a6 | |||
| 355d8cb1a4 | |||
| 6fbad46bca | |||
| 347f6544aa | |||
| 19ba4e2b12 | |||
| 0727ba6d44 | |||
| 0b464af3e5 | |||
| 00b1e10fc3 | |||
| b1c4e516c9 | |||
| 334354a103 | |||
| 0bb75b05a9 | |||
| a9f3a642ee | |||
| 7353568115 | |||
| 50bfdb99d0 | |||
| eb38008ebb | |||
| 9792976b11 | |||
| fbbeac6c79 | |||
| e2de9062d8 | |||
| 625b7e38a3 | |||
| e4404e0162 | |||
| bec826baf0 | |||
| 28be6779a7 | |||
| 44ecc9094e | |||
| f625c0eaf4 | |||
| ce480f3669 | |||
| 38ae6775e4 | |||
| e0da1ea40f | |||
| 9550d2be5e | |||
| b645573149 | |||
| 79528ff392 | |||
| 10853f662c | |||
| c97ecade63 | |||
| f7246941e8 | |||
| 720d9a38fb | |||
| 5d97cf23fe | |||
| d09c11d872 | |||
| 9d0abe5422 | |||
| 7bfe1af5b1 | |||
| 2a34c7398b | |||
| cffe432912 | |||
| 7ebfab4bd4 | |||
|
|
16846786e2 | ||
| b898d59295 | |||
| ae6fe1d679 | |||
| 53e3c32852 | |||
| 67400b07dc | |||
| 6f2fbde4f1 | |||
| 7aae204014 | |||
| c840202018 | |||
| 6439105d38 | |||
| 9e37e11915 | |||
| cc9caf802e | |||
| f9d5e2c0af | |||
| b98248fad8 | |||
| 190e13350b | |||
| 72e53e29a1 | |||
| 8eb672bada | |||
| 0416eafd71 | |||
| e8658fea5f | |||
| 4cdc80c9bc | |||
| ac7e6bdb4e | |||
| a9c1d8af12 | |||
| 5e1bd057d5 | |||
| 2020ab5853 | |||
| ebfd344390 | |||
| 3057a9e239 | |||
| 6967d8973c | |||
| dbc7741f58 | |||
| c608f58b5a | |||
| 13ecfa92f0 | |||
| 61d591f5d2 | |||
| b39808d847 | |||
|
|
50a558615c | ||
| 14dd663d56 | |||
|
|
919dc4afc0 | ||
|
|
482cb87c70 | ||
|
|
2200379ea2 | ||
|
|
42542334e8 | ||
|
|
05bab5576e | ||
|
|
f2fb4adbce | ||
|
|
435f468079 | ||
|
|
13f1b2b70e | ||
|
|
2cd38e168d | ||
|
|
78a3d60b02 | ||
|
|
fc63feaa9d | ||
|
|
9e8433e4d6 | ||
|
|
f47903a6a6 | ||
|
|
27f18e4ccc | ||
|
|
ce05eed754 | ||
|
|
523e44bbf8 | ||
|
|
fe0b972171 | ||
|
|
639f553cb7 | ||
|
|
8f720b1078 | ||
|
|
073adf743d | ||
|
|
3d40a7e1ed | ||
|
|
71786bda6b | ||
|
|
3132ba5ac8 | ||
|
|
56793c1c27 | ||
|
|
53f75f6e1b | ||
|
|
656ebc9735 | ||
|
|
4ebd6770ec | ||
|
|
4c656b1e73 | ||
|
|
5ef66c242e | ||
|
|
dafa83e922 | ||
|
|
c0fc16e25c | ||
|
|
37615c85a4 | ||
|
|
2fa5231215 | ||
|
|
808990592d | ||
|
|
49e0061dbb | ||
|
|
860c70466f | ||
|
|
c44616aeb0 | ||
|
|
429de59b52 | ||
|
|
79d73cd623 | ||
|
|
e841251399 | ||
|
|
cf97f5605d | ||
|
|
dc33694a56 | ||
|
|
7eb9b8ad4f | ||
|
|
aff50e6070 | ||
|
|
ba8963a385 | ||
|
|
2d0cb48d5c | ||
|
|
8c048a2692 | ||
|
|
858362cfcc | ||
|
|
2055842c33 | ||
|
|
59975a6a59 | ||
| 5c5e1f797c | |||
| 6356f9b408 | |||
| f0f568f8d5 | |||
| ac3b75b018 | |||
| b37a50c779 | |||
| d5ee59fcb1 | |||
| 8002757ca3 | |||
| c41c8abaa7 | |||
| 45c584db26 | |||
| 44264428af | |||
| 84c145467e | |||
| 0834038072 | |||
| 869ca6b16c | |||
| 1e1d568947 | |||
| d7427ae5da | |||
| e2ede3d813 | |||
| 3d5cb21302 | |||
| 910f357f08 | |||
| 3bc8a46927 | |||
| 79630314aa | |||
| b5263bcef8 | |||
| d547393e7b | |||
|
|
eb951569cf | ||
|
|
099d214529 | ||
|
|
2fff3ad628 | ||
|
|
3eeba0611d | ||
|
|
a95cf49ddf | ||
|
|
3760e39a54 | ||
|
|
5b93b97191 | ||
|
|
ad6b76e1eb | ||
|
|
3a874dccc8 | ||
|
|
82776d7da4 | ||
|
|
34362d6a92 | ||
|
|
a640de4fd4 | ||
|
|
bd6d94f973 | ||
|
|
3d324982b9 | ||
|
|
61d9ee1577 | ||
|
|
cb659ebb4c | ||
|
|
6b488d69ef | ||
|
|
1d6b25b96a | ||
|
|
14558ad296 | ||
|
|
5f3124d160 | ||
|
|
a102dff508 | ||
|
|
e81529c1bb | ||
|
|
2ef0a8b710 | ||
|
|
35699f170f | ||
|
|
a504f35b41 | ||
|
|
c962d07841 | ||
|
|
09388196a9 | ||
|
|
f09042adbe | ||
|
|
11d6a3df0f | ||
|
|
9aa8f250d6 | ||
|
|
be4efd38ea | ||
|
|
d8a796b6ee | ||
|
|
f606c78fcf | ||
|
|
c40c74e640 | ||
|
|
f94772de2c | ||
|
|
40c38dc4f5 | ||
|
|
def6e9dcbe | ||
|
|
4a99b64532 | ||
|
|
75d76fabd7 | ||
|
|
f6975a9797 | ||
|
|
7a5284b15a | ||
|
|
e01303851d | ||
|
|
97bf26a23f | ||
|
|
1ee17f6799 | ||
|
|
eba7924b0f | ||
|
|
c89cace149 | ||
|
|
41cd350a52 | ||
| 7d52d4b5e4 | |||
|
|
ed7fd93e16 | ||
|
|
5c875542d0 | ||
|
|
351b7adb8e | ||
|
|
70501b566a | ||
|
|
208b8d4444 | ||
|
|
02d7baeb7c | ||
|
|
caaac88da9 | ||
|
|
896b41faa3 | ||
|
|
d09c978b9b | ||
|
|
5273493c20 | ||
|
|
9c21aa64b1 | ||
|
|
6b7f2b4ce8 | ||
|
|
2ef56eae88 | ||
|
|
d6b5b344fa | ||
|
|
60c7878a6a | ||
|
|
8a5714f330 | ||
|
|
05c7c7b54f | ||
|
|
3322db7caf | ||
|
|
fbd4e0503e | ||
|
|
f97acda158 | ||
|
|
ff76781a97 | ||
|
|
856445c505 | ||
|
|
c6b759e19e | ||
|
|
eb8f9fad88 | ||
|
|
d41e5c18d2 | ||
|
|
452bb9d75f | ||
|
|
2bad7c524f | ||
|
|
1d8c1a4314 | ||
|
|
3dcbb2b0b6 | ||
|
|
b1a9d2ea66 | ||
|
|
db48bb85d5 | ||
|
|
6bd03747c9 | ||
|
|
850dc310df | ||
|
|
565f66372a | ||
|
|
be8d8a7d9b | ||
|
|
2954283e23 | ||
|
|
ce83a8e655 | ||
|
|
1143e67f95 | ||
|
|
714fbd1daf | ||
|
|
b18980f642 | ||
|
|
b1cf10ff06 | ||
| 60c6f14c08 | |||
| a8391c381d | |||
| 28a28e05d3 | |||
| 239c3c4551 | |||
| 92b3c1a1e3 | |||
| 8961fd6936 | |||
| 1928cd94f6 | |||
| 21c0c58ea2 | |||
| 926be22fd4 | |||
| cd5d3b51e2 | |||
| 6c61b51d55 | |||
| 28cbaf6f5e | |||
| d078ba2a82 | |||
| ce89fddab6 | |||
| cfecb612a6 | |||
| be5888d354 | |||
| f848e7390f | |||
| 907d3123ea | |||
| ddf9b186e4 | |||
|
|
175b877f95 | ||
|
|
7b599c5786 | ||
|
|
f924d92f1b | ||
|
|
f2c815ddbd | ||
|
|
16275947ec | ||
| 7f10a12ec8 | |||
| 825b7e25a4 | |||
| 801ffa4e24 | |||
| ccd896c8dc | |||
| 51e319d357 | |||
| 7569b678db | |||
| 28e21af206 | |||
| eecc214ca9 | |||
| 93521c8617 | |||
| 3f15f6451f | |||
|
|
8fded63b10 | ||
|
|
edd0bd38ce | ||
|
|
096440a7d7 | ||
|
|
7d5f091c29 | ||
|
|
90a5104f94 | ||
|
|
39cfe0fb12 | ||
|
|
01ee847490 | ||
|
|
808a3febdd | ||
|
|
4ad564bd80 | ||
|
|
3e49481d46 | ||
|
|
8d54eecd67 | ||
|
|
4e3acbe89c | ||
|
|
8f2e047f4a | ||
|
|
8874a22a37 | ||
|
|
310614bdd2 | ||
|
|
fb75c3dedf | ||
|
|
44d5516191 | ||
|
|
47c8b6f997 | ||
|
|
abc953ea84 | ||
|
|
d1d6a09d9d | ||
|
|
a360bc87ca | ||
|
|
1921839e0e | ||
|
|
9df18261f9 | ||
|
|
e02b4d8200 | ||
|
|
5eadf0cff6 | ||
|
|
26e393b65d | ||
|
|
fb78c814cc | ||
|
|
821032e81c | ||
|
|
b7b51e9354 | ||
|
|
0fe46221ea | ||
|
|
bfb80cb60d | ||
|
|
352c681f48 | ||
|
|
d2ac131b75 | ||
|
|
2a23e82169 | ||
|
|
1b8eccbdca | ||
|
|
3f5e414341 | ||
|
|
774bdad6e4 | ||
|
|
e05e3647e6 | ||
|
|
472d7502b4 | ||
|
|
b0b8a9a09b | ||
|
|
56f1cc3fa9 | ||
|
|
9cd6f5e30c | ||
|
|
a83edb0d36 | ||
|
|
fd6e5f60e9 | ||
|
|
a643391d3d | ||
|
|
63136a8cb1 | ||
|
|
0d80ab272f | ||
|
|
397dd5991a | ||
|
|
274ffc25e1 | ||
|
|
0e6c678835 | ||
|
|
18b854563b | ||
|
|
f70838c5b5 | ||
|
|
1e4427f6af | ||
|
|
a47aa3aff5 | ||
|
|
baab2d3b88 | ||
|
|
5ea4f0c9ac | ||
|
|
938753273d | ||
|
|
46af2f0632 | ||
|
|
bc4e8e1410 | ||
|
|
4c2ad38656 | ||
|
|
6d70bba8e6 | ||
|
|
9726ba3b2f | ||
|
|
4024da791f | ||
|
|
aac678335c | ||
|
|
7d6d74fd7f | ||
|
|
cdd1897b14 | ||
|
|
9976ea06f5 | ||
|
|
cdfe92a089 | ||
|
|
431af618cc | ||
|
|
47526ae6e2 | ||
|
|
86802f244e | ||
|
|
f3325385c1 | ||
|
|
aed41793f8 | ||
|
|
685126ab47 | ||
|
|
c8d0b09044 | ||
|
|
c2f90c7d0a | ||
|
|
5747dc9839 | ||
|
|
d1344712b8 | ||
|
|
37204f5f45 | ||
| 97b5944dc1 | |||
| 0c483f9512 | |||
| dfb57c2e34 | |||
| 995810927d | |||
| e1cf190dd2 | |||
|
|
70334e68bb | ||
|
|
6a4af69b0f | ||
|
|
d9466d6555 | ||
|
|
0e42763aea | ||
|
|
a6d6860054 | ||
|
|
5c43a93285 | ||
|
|
09c308b93c | ||
|
|
bdf20f2d8e | ||
|
|
c57d26864e | ||
|
|
019fdfc8bb | ||
|
|
c2281dbc61 | ||
|
|
b4fe52261b |
28
.claude/agents/nomarchy-runner.md
Normal file
28
.claude/agents/nomarchy-runner.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: nomarchy-runner
|
||||
description: Mechanical execution of the Nomarchy headless test harness. Use PROACTIVELY when a build or VM run just needs to be executed and its artifacts collected — nix build, tools/test-live-iso.sh, tools/test-install.sh, screenshot capture via tools/vm/vncshot.py and tools/vm/qmp.py, and the scripted checks (check-theme-contrast.py, audit-theme-design.py, check-option-docs.py). Never use for interpreting ambiguous failures, judging visual quality, or deciding what to test.
|
||||
tools: Read, Bash, Glob, Grep
|
||||
model: haiku
|
||||
---
|
||||
|
||||
You are a deterministic test executor for the Nomarchy repository. You run
|
||||
exactly the commands you are asked to run, headlessly, and hand back the
|
||||
evidence. You do not decide what to test, do not interpret ambiguous
|
||||
results, and do not judge whether something "looks fine".
|
||||
|
||||
Rules:
|
||||
- Run everything headless and unattended. Never launch a graphical VM
|
||||
window; never wait on human input. Wrap long-running commands in
|
||||
timeouts as instructed by the caller, and treat a timeout as a recorded
|
||||
failure, not something to silently retry.
|
||||
- Collect artifacts to the working directory the caller specifies: full
|
||||
command lines, exit codes, the tail of stdout/stderr (plus the complete
|
||||
logs as files), serial console output, and screenshot file paths.
|
||||
- Report format: for each command — command, exit code, wall time,
|
||||
artifact paths, and verbatim error lines if the exit code was nonzero.
|
||||
Nothing else. No summaries of what the results "mean".
|
||||
- Never mark anything as passed or verified. You return evidence; the
|
||||
caller makes the verification claim.
|
||||
- If a command fails in a way that prevents collecting artifacts, report
|
||||
exactly what happened and stop — do not improvise recovery steps beyond
|
||||
what the caller authorized.
|
||||
20
.claude/agents/nomarchy-scout.md
Normal file
20
.claude/agents/nomarchy-scout.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: nomarchy-scout
|
||||
description: Read-only reconnaissance inside the Nomarchy repo. Use PROACTIVELY for any task that is pure information gathering — locating where an option/module/theme is defined, mapping which files touch a subsystem, scanning build logs or serial-console output for errors, checking docs against code for drift, summarizing a directory. Never use for anything requiring judgment about design, correctness, or visual quality.
|
||||
tools: Read, Grep, Glob
|
||||
model: haiku
|
||||
---
|
||||
|
||||
You are a fast, cheap scout for the Nomarchy NixOS distribution repository.
|
||||
Your only job is to find things and report facts. You never edit, never
|
||||
judge design quality, and never draw conclusions beyond what the files
|
||||
literally say.
|
||||
|
||||
Rules:
|
||||
- Report file paths and line numbers, quote the minimum relevant snippet.
|
||||
- If asked to scan logs, extract the error/warning lines verbatim with
|
||||
enough surrounding context to locate them, and note timestamps.
|
||||
- If you cannot find something, say so plainly — never guess or infer that
|
||||
something "probably" exists.
|
||||
- Keep output terse and structured: the caller pays for every token you
|
||||
produce. Facts first, no prose padding, no recommendations.
|
||||
88
.claude/settings.json
Normal file
88
.claude/settings.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Glob",
|
||||
"Grep",
|
||||
|
||||
"Bash(ls *)",
|
||||
"Bash(cat *)",
|
||||
"Bash(head *)",
|
||||
"Bash(tail *)",
|
||||
"Bash(grep *)",
|
||||
"Bash(rg *)",
|
||||
"Bash(find *)",
|
||||
"Bash(tree *)",
|
||||
"Bash(wc *)",
|
||||
"Bash(file *)",
|
||||
"Bash(stat *)",
|
||||
"Bash(du *)",
|
||||
"Bash(df *)",
|
||||
"Bash(which *)",
|
||||
"Bash(diff *)",
|
||||
"Bash(jq *)",
|
||||
"Bash(sha256sum *)",
|
||||
|
||||
"Bash(mkdir *)",
|
||||
"Bash(cp *)",
|
||||
"Bash(mv *)",
|
||||
"Bash(touch *)",
|
||||
"Bash(ln -s *)",
|
||||
"Bash(tar *)",
|
||||
|
||||
"Bash(nix *)",
|
||||
"Bash(nix-store *)",
|
||||
"Bash(nix-instantiate *)",
|
||||
|
||||
"Bash(./tools/test-live-iso.sh *)",
|
||||
"Bash(./tools/test-install.sh *)",
|
||||
"Bash(./tools/audit-theme-design.py *)",
|
||||
"Bash(./tools/check-option-docs.py *)",
|
||||
"Bash(./tools/check-theme-contrast.py *)",
|
||||
"Bash(./tools/import-palettes.py *)",
|
||||
"Bash(./tools/vm/qmp.py *)",
|
||||
"Bash(./tools/vm/vncshot.py *)",
|
||||
"Bash(./tools/vm/gap-analysis.py *)",
|
||||
"Bash(python3 tools/*)",
|
||||
"Bash(bash tools/*)",
|
||||
|
||||
"Bash(qemu-system-x86_64 *)",
|
||||
"Bash(qemu-img *)",
|
||||
|
||||
"Bash(git status *)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git show *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git switch *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git restore *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git worktree *)",
|
||||
|
||||
"Bash(systemctl --user status *)",
|
||||
"Bash(journalctl --user *)",
|
||||
"Bash(pgrep *)",
|
||||
"Bash(pkill -f qemu*)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(git push *)",
|
||||
"Bash(rm *)",
|
||||
"Bash(git reset --hard *)",
|
||||
"Bash(git clean *)",
|
||||
"Bash(nix-collect-garbage *)",
|
||||
"Bash(curl *)",
|
||||
"Bash(wget *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(sudo *)",
|
||||
"Bash(git push --force *)",
|
||||
"Bash(git push -f *)"
|
||||
],
|
||||
"defaultMode": "default"
|
||||
}
|
||||
}
|
||||
102
.gitea/workflows/bump.yml
Normal file
102
.gitea/workflows/bump.yml
Normal file
@@ -0,0 +1,102 @@
|
||||
# Nomarchy scheduled lock bump — the automated half of "rock-stable
|
||||
# without rotting": once a week, update flake.lock (inputs track pinned
|
||||
# release branches, so this never jumps a NixOS release — that's a
|
||||
# deliberate hand-edited v2, see agent/GOALS.md), gate it, and land it
|
||||
# on main only on green. `v1` promotion stays human, always.
|
||||
#
|
||||
# Fast lane: workflow_dispatch. For a security fix upstream, run this
|
||||
# workflow manually from the Actions tab instead of waiting for Monday.
|
||||
#
|
||||
# Gate scope: `nix flake check --no-build` (eval tier) followed by a V1 build
|
||||
# (home-manager activation package + nixos toplevel) to catch compilation errors.
|
||||
# (this runner has no KVM; see item 20 for the VM-suite
|
||||
# upgrade path). The py_compile/bash -n steps are skipped on purpose:
|
||||
# they don't read flake.lock, so a lock bump cannot break them. The
|
||||
# bump commit's own push then triggers check.yml as a second net.
|
||||
# A green bump therefore guards evaluation and compilation, not VM behaviour —
|
||||
# the checks.* suite still runs locally / at promotion time.
|
||||
#
|
||||
# Failure mode: a red gate fails this run visibly and pushes nothing;
|
||||
# next schedule retries. A push race (someone landed on main mid-run)
|
||||
# also just fails the final push — rerun or wait a week.
|
||||
#
|
||||
# Container recipe (nixbld users, sandbox=false, pinned Nix, plain-shell
|
||||
# install) inherited from check.yml — the gotchas are documented there.
|
||||
|
||||
name: Lock bump
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 5 * * 1' # Mondays 05:17 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
NIX_CONFIG: |
|
||||
max-jobs = 1
|
||||
cores = 2
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
run: |
|
||||
NIX_VERSION=2.31.5
|
||||
groupadd -r nixbld 2>/dev/null || true
|
||||
for i in $(seq 1 10); do
|
||||
useradd -r -g nixbld -G nixbld -d /var/empty \
|
||||
-s /usr/sbin/nologin -c "Nix build user $i" "nixbld$i" 2>/dev/null || true
|
||||
done
|
||||
curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon
|
||||
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Update flake.lock
|
||||
# Inputs track release branches (nixos-26.05, release-26.05), so
|
||||
# the update stays within them by construction. The human-readable
|
||||
# change list goes into the commit body below.
|
||||
run: |
|
||||
nix flake update 2>&1 | tee /tmp/bump-log
|
||||
if git diff --quiet flake.lock; then
|
||||
echo "Lock already up to date — nothing to do."
|
||||
echo "changed=0" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=1" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
id: update
|
||||
|
||||
- name: Gate — evaluate every output (eval tier, memory-bounded)
|
||||
# Per-output processes, not one big `nix flake check --no-build`:
|
||||
# the single-process walk peaks ~6 GB RSS and OOMs inside the
|
||||
# runner's 2 GB container cap (details in tools/ci-eval.sh).
|
||||
if: steps.update.outputs.changed == '1'
|
||||
run: bash tools/ci-eval.sh
|
||||
|
||||
- name: Gate — V1 build (toplevel + home-manager)
|
||||
if: steps.update.outputs.changed == '1'
|
||||
run: |
|
||||
nix build .#homeConfigurations.nomarchy.activationPackage --no-link
|
||||
nix build .#nixosConfigurations.nomarchy.config.system.build.toplevel --no-link
|
||||
|
||||
- name: Commit + push on green
|
||||
if: steps.update.outputs.changed == '1'
|
||||
# The checkout step persists the Actions token, so this push
|
||||
# authenticates as the workflow; its push triggers check.yml as
|
||||
# the second net. Only flake.lock is committed — pathspec-limited,
|
||||
# nothing else can ride along.
|
||||
run: |
|
||||
{
|
||||
echo "chore(lock): scheduled upstream bump"
|
||||
echo
|
||||
grep -E '^(• | )' /tmp/bump-log || true
|
||||
echo
|
||||
echo "Gate: nix flake check --no-build (eval tier — see bump.yml header)."
|
||||
} > /tmp/bump-msg
|
||||
git -c user.name="nomarchy-bump" -c user.email="actions@git.bemagri.xyz" \
|
||||
commit --only flake.lock -F /tmp/bump-msg
|
||||
git push origin HEAD:main
|
||||
108
.gitea/workflows/check.yml
Normal file
108
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,108 @@
|
||||
# Nomarchy CI — the always-on net under direct-to-main pushes.
|
||||
#
|
||||
# Scope (deliberate): the EVAL tier only. The runner behind this Gitea
|
||||
# instance is act_runner + docker containers (the legacy repo's check.yml
|
||||
# ran 57 times on it) — no systemd, no /dev/kvm — so the checks.* VM
|
||||
# tests and full toplevel builds can't run here. `nix flake check
|
||||
# --no-build` still catches most breakage (type errors, missing options,
|
||||
# bad merges, template/mkFlake drift — see docs/TESTING.md §1); the VM
|
||||
# suite stays a local/promotion gate until a KVM-capable NixOS runner
|
||||
# exists (see the commented vm-checks job at the bottom).
|
||||
#
|
||||
# Inherited-from-legacy gotchas (learned over 57 runs, kept verbatim):
|
||||
# - Single-user Nix (--no-daemon): no systemd in the container. The
|
||||
# installer runs as root and honours build-users-group=nixbld from
|
||||
# its bundled nix.conf, aborting unless the group exists AND has
|
||||
# members — create nixbld + users first.
|
||||
# - sandbox=false: Stylix/base16.nix do import-from-derivation; the
|
||||
# single-user Nix in this container can't set up the build sandbox
|
||||
# (no user namespaces), which otherwise surfaces as
|
||||
# "path '…-source' is not valid".
|
||||
# - Pin the Nix version: 2.34's lazy-trees git cache doesn't
|
||||
# materialise flake-input `-source` paths into the store, breaking
|
||||
# the same IFD reads. 2.31.5 matches what wrote flake.lock.
|
||||
# - Plain-shell Nix install, not a JS action: act_runner's bundled act
|
||||
# tops out at node20; a `run:` step has no node-runtime coupling.
|
||||
|
||||
name: Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, v1]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
NIX_CONFIG: |
|
||||
max-jobs = 1
|
||||
cores = 1
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
run: |
|
||||
NIX_VERSION=2.31.5
|
||||
groupadd -r nixbld 2>/dev/null || true
|
||||
for i in $(seq 1 10); do
|
||||
useradd -r -g nixbld -G nixbld -d /var/empty \
|
||||
-s /usr/sbin/nologin -c "Nix build user $i" "nixbld$i" 2>/dev/null || true
|
||||
done
|
||||
curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon
|
||||
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Evaluate every output (eval tier, memory-bounded)
|
||||
# Same coverage as `nix flake check --no-build` — both nixos
|
||||
# configs, the home config, the checks.* derivations (instantiated,
|
||||
# not run) and the downstream template through lib.mkFlake — but
|
||||
# ONE output per nix process. A single process walking everything
|
||||
# peaks at ~6.0 GB RSS (measured 2026-07-12): it OOM'd the 4 GB
|
||||
# VPS itself, and can never fit this runner's 2 GB container cap.
|
||||
# Per-output processes cap at the largest single output (~1 GB).
|
||||
run: bash tools/ci-eval.sh
|
||||
|
||||
- name: Python syntax (all tracked scripts)
|
||||
# Every tracked *.py — theme-sync, the installer composers
|
||||
# (compose-lock, patch-template) and the maintainer tools
|
||||
# (tools/, tools/vm/). Via nix shell so the step doesn't depend on
|
||||
# the runner image preinstalling python3.
|
||||
run: |
|
||||
nix shell nixpkgs#python3 --command \
|
||||
bash -c 'git ls-files "*.py" | xargs -r python3 -m py_compile'
|
||||
|
||||
- name: Shell syntax (tracked scripts)
|
||||
# The distro's user-facing scripts are generated by Nix (their
|
||||
# syntax is exercised by the eval + local builds); this covers the
|
||||
# hand-written .sh files: installer helpers and maintainer tools.
|
||||
run: |
|
||||
set -e
|
||||
fail=0
|
||||
while IFS= read -r script; do
|
||||
head -1 "$script" | grep -qE '^#!.*\b(bash|sh)\b' || continue
|
||||
if ! bash -n "$script"; then
|
||||
echo "::error file=$script::bash syntax error"
|
||||
fail=1
|
||||
fi
|
||||
done < <(git ls-files '*.sh')
|
||||
exit "$fail"
|
||||
|
||||
# ── vm-checks (DISABLED — FUTURE #20, not NEXT) ─────────────────────
|
||||
# Eval-only CI is the standing decision: Gitea act_runner stays docker
|
||||
# compose (no /dev/kvm). Full checks.* VMs need a *separate* host with
|
||||
# nix + KVM (label nix-kvm); see agent/BACKLOG.md FUTURE #20. Do NOT
|
||||
# uncomment until that label is online — Gitea queues forever otherwise.
|
||||
#
|
||||
# vm-checks:
|
||||
# runs-on: nix-kvm
|
||||
# timeout-minutes: 120
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - run: nix flake check # builds + runs the VM tests
|
||||
# - run: nix build .#nixosConfigurations.nomarchy.config.system.build.toplevel --no-link
|
||||
# - run: nix build .#homeConfigurations.nomarchy.activationPackage --no-link
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,7 +1,3 @@
|
||||
# Study material from the previous iteration — not part of the flake.
|
||||
# Delete the directory once everything worth porting has been ported.
|
||||
old_distro/
|
||||
|
||||
# Nix build artifacts
|
||||
result
|
||||
result-*
|
||||
@@ -9,5 +5,9 @@ result-*
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
|
||||
# Ad-hoc hardware evidence photos dropped in the tree (not repo content)
|
||||
WhatsApp Image*.jpeg
|
||||
|
||||
# Claude Code machine-local settings (permissions etc.)
|
||||
.claude/settings.local.json
|
||||
.claude/settings.local.json.tmp.*
|
||||
|
||||
65
AGENTS.md
Normal file
65
AGENTS.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Nomarchy — agent entry point
|
||||
|
||||
Nomarchy is a NixOS-based distro: rock-stable, fully reproducible, themed
|
||||
from one JSON, configured through a menu that writes into the user's own
|
||||
flake checkout. Read the README for the architecture.
|
||||
|
||||
This file is the entry point for **any** AI coding agent, whatever the
|
||||
vendor or harness. Everything agents need lives in vendor-neutral,
|
||||
git-tracked markdown (`agent/`, `docs/`); harness-specific config is a
|
||||
thin *adapter* (see bottom) and never holds shared content.
|
||||
|
||||
## Where things live (read this first)
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| **`agent/`** | Agent instructions + loop state — **only executable queue is `BACKLOG.md`** |
|
||||
| **`agent/README.md`** | Map of the agent files |
|
||||
| **`docs/VISION.md`** | Product themes toward **v1.0** (not a queue) |
|
||||
| **`docs/ROADMAP.md`** | Design history + shipped log |
|
||||
| **`docs/README.md`** | Full documentation map |
|
||||
| **`.claude/`** | Claude Code adapter only (permissions, subagent defs) — not the backlog |
|
||||
|
||||
## If you're here to work autonomously (the loop)
|
||||
Follow **`agent/LOOP.md`** — one iteration: orient → pick one BACKLOG
|
||||
item → work → verify → commit+push on `main` → record. All loop state is
|
||||
git-tracked in `agent/`.
|
||||
|
||||
## Rules that apply to every session, loop or not
|
||||
- **Before your first change, read `agent/VERIFICATION.md`** and follow
|
||||
it — every change, however small, climbs its V0–V3 ladder. For theme
|
||||
or visual work, also read `agent/THEME-DESIGN.md`; before any VM test,
|
||||
`docs/TESTING.md`.
|
||||
- **Honesty rule** (`agent/VERIFICATION.md`, `docs/TESTING.md`): for
|
||||
visual/interactive changes, evaluation is not rendering — state the
|
||||
tier you reached. Cheap first: `nix flake check --no-build`.
|
||||
- **Conventions** (`agent/CONVENTIONS.md`): in-flake state, menu
|
||||
placement, Waybar parity with whole-swaps, toggle-vs-package discipline,
|
||||
template as SoT for opt-in comments (`templates/downstream`).
|
||||
- **Sync sweep on completion** (`agent/LOOP.md` §5): when a task ships,
|
||||
update or delete every cross-reference it made stale — BACKLOG
|
||||
pitches/pointers, HARDWARE-QUEUE entries, docs — in the same commit.
|
||||
No stale items, no unsynced information.
|
||||
- **Git:** direct commits on `main`, pushed; **`v1` is human-only** —
|
||||
never touch it. Never `nix flake update` unless the task is a lock bump.
|
||||
No formatter — match aligned hand-formatting.
|
||||
- Layout: `hosts/` machine · `modules/` distro · `themes/` data ·
|
||||
`pkgs/` code · `tools/` maintainer · `agent/` loop · `docs/` human docs.
|
||||
|
||||
## Delegation & escalation
|
||||
|
||||
Push mechanical work down to cheaper models; keep judgment on the
|
||||
strongest model available. Capability tiers, standing roles (scout /
|
||||
runner), and per-harness model mappings: **`agent/DELEGATION.md`**.
|
||||
Work above your tier? Return it — don't burn tokens.
|
||||
|
||||
## Harness adapters
|
||||
|
||||
One directory per harness, holding only what that harness *requires* in
|
||||
its own format (permissions, subagent/skill registration). Adapters point
|
||||
into `agent/`; they never carry policy, queue items, or vision text.
|
||||
|
||||
| Harness | Adapter |
|
||||
|---------|---------|
|
||||
| Claude Code | `.claude/` — `CLAUDE.md` is a symlink to this file |
|
||||
| others | add a sibling dir + a row here; keep it a thin shim |
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Bernardo Magri
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
335
README.md
335
README.md
@@ -7,11 +7,11 @@ partial.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ theme-state.json (single source of truth) │
|
||||
│ state.json (single source of truth) │
|
||||
│ lives INSIDE your flake checkout, git-tracked │
|
||||
└───────────────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
nomarchy-theme-sync apply gruvbox
|
||||
nomarchy-state-sync apply gruvbox
|
||||
1. merges the preset into the JSON (atomic write)
|
||||
2. runs `home-manager switch` (no sudo, no system rebuild)
|
||||
│
|
||||
@@ -19,7 +19,7 @@ partial.
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ Home Manager bakes EVERYTHING into one read-only generation: │
|
||||
│ Hyprland (colors/gaps/borders) Waybar (palette or whole-swap) │
|
||||
│ Ghostty (full ANSI palette) btop (asset or generated) │
|
||||
│ Kitty (full ANSI palette) btop (asset or generated) │
|
||||
│ Stylix → GTK, Qt, cursors, fonts │
|
||||
└───────────────────────────────────┬──────────────────────────────────────┘
|
||||
▼
|
||||
@@ -35,11 +35,11 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
|
||||
.
|
||||
├── flake.nix # inputs + the downstream API (exports below)
|
||||
├── lib.nix # nomarchy.lib.mkFlake — one-call downstream wrapper
|
||||
├── theme-state.json # ★ THE single source of truth (git-tracked!)
|
||||
├── themes/ # 21 presets: <slug>.json + optional <slug>/ assets
|
||||
├── state.json # ★ THE single source of truth (git-tracked!)
|
||||
├── themes/ # 28 presets: <slug>.json + optional <slug>/ assets
|
||||
│ ├── nord.json # palette (required, works alone)
|
||||
│ └── nord/ # assets (optional, fixed filenames)
|
||||
│ ├── backgrounds/ # wallpapers (auto-picked, SUPER+SHIFT+T cycles)
|
||||
│ ├── backgrounds/ # wallpapers (pinned nomarchy-wallpapers input, not this repo)
|
||||
│ ├── btop.theme # hand-made config drop (else generated)
|
||||
│ └── waybar.css # whole-swap: replaces the generated bar style
|
||||
├── modules/
|
||||
@@ -56,12 +56,13 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
|
||||
│ ├── stylix.nix # GTK/Qt/cursors/fonts from the same JSON
|
||||
│ ├── hyprland.nix # all JSON-driven
|
||||
│ ├── waybar.nix
|
||||
│ ├── ghostty.nix
|
||||
│ ├── kitty.nix
|
||||
│ ├── btop.nix
|
||||
│ ├── rofi.nix # launcher + nomarchy-menu (calc, emoji, clip…)
|
||||
│ ├── keybinds.nix # single source: Hyprland binds + SUPER+? sheet
|
||||
│ ├── swaync.nix # notifications, same JSON
|
||||
│ ├── idle.nix # hyprlock + hypridle, same JSON
|
||||
│ ├── battery-notify.nix # low-battery toasts at the bar's thresholds
|
||||
│ ├── yazi.nix # flagship TUI file manager + plugins
|
||||
│ ├── osd.nix # swayosd volume/brightness OSD
|
||||
│ ├── shell.nix # zsh + starship + bat/eza/zoxide
|
||||
@@ -71,12 +72,20 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
|
||||
│ ├── default/ # reference machine (thin: boot, user, hostname)
|
||||
│ └── live.nix # bootable live ISO (try the distro, no install)
|
||||
├── pkgs/
|
||||
│ ├── nomarchy-theme-sync/ # state writer + rebuild dispatcher (Python)
|
||||
│ └── nomarchy-install/ # live-ISO installer (gum + disko + mkFlake)
|
||||
├── templates/downstream/ # `nix flake init -t` starter for users
|
||||
├── docs/TESTING.md # how to verify changes (incl. AI-agent rules)
|
||||
├── docs/OVERRIDES.md # how downstream users override defaults
|
||||
├── docs/ROADMAP.md # forward-looking plans + shipped-fixes log
|
||||
│ ├── nomarchy-state-sync/ # state writer + rebuild dispatcher (Python)
|
||||
│ ├── nomarchy-install/ # live-ISO installer (gum + disko + mkFlake)
|
||||
│ ├── nomarchy-doctor/ # read-only health sheet, one command per failure
|
||||
│ └── nomarchy-battery-notify/ # low-battery toast watcher backing battery-notify.nix
|
||||
├── templates/downstream/ # machine flake SoT (`flake init` + installer copy/patch)
|
||||
├── docs/ # human docs — map: docs/README.md
|
||||
│ ├── VISION.md # product themes toward v1.0 (not a queue)
|
||||
│ ├── ROADMAP.md # design/decision records + shipped log
|
||||
│ ├── HARDWARE.md # firmware, profiles, drivers
|
||||
│ ├── TESTING.md · RECOVERY.md · OVERRIDES.md · MIGRATION.md
|
||||
├── agent/ # agent instructions + loop state — map: agent/README.md
|
||||
│ # BACKLOG (executable queue), LOOP, VERIFICATION, …
|
||||
├── AGENTS.md # agent entry, any vendor (CLAUDE.md symlinks here)
|
||||
├── .claude/ # Claude Code adapter: permissions + subagent defs
|
||||
└── tools/ # maintainer-only
|
||||
├── import-palettes.py # converts old-distro themes → JSON + assets
|
||||
├── test-live-iso.sh # build the ISO + boot it in QEMU
|
||||
@@ -92,11 +101,15 @@ probably shouldn't exist.
|
||||
|
||||
## 2. Try it first (live ISO)
|
||||
|
||||
**Before you install:** check **[docs/REQUIREMENTS.md](docs/REQUIREMENTS.md)**
|
||||
(UEFI x86_64, disk planning for the Nix store + BTRFS snapshots — 128 GiB is
|
||||
the comfortable floor).
|
||||
|
||||
Boot the full desktop from a USB stick or VM without installing anything:
|
||||
|
||||
```sh
|
||||
nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage
|
||||
# → result/iso/*.iso — dd to a stick, or boot it in QEMU:
|
||||
# → result/iso/nomarchy-live-….iso — dd to a stick, or boot it in QEMU:
|
||||
tools/test-live-iso.sh
|
||||
```
|
||||
|
||||
@@ -105,11 +118,19 @@ the locked inputs into the ISO store — so theme switching (including the
|
||||
`home-manager switch` it triggers) works **offline**, exactly like on an
|
||||
installed system. Verification checklist: [docs/TESTING.md](docs/TESTING.md).
|
||||
|
||||
It's a working desktop, not a demo shell: Chromium, LibreOffice, Text Editor,
|
||||
Amberol and Snapshot are in the launcher alongside the terminal tooling — so
|
||||
you can read these docs in a browser while you try it, or boot the stick to
|
||||
rescue a machine that won't start and get a document off it. Installed
|
||||
machines get their app list from the template's `home.packages` instead, which
|
||||
is yours to edit.
|
||||
|
||||
Like what you see? **`nomarchy-install`** (in a terminal) walks you through
|
||||
installing to disk: pick a disk, LUKS2 full-disk encryption **by default**
|
||||
(in exchange the desktop logs in passwordless — the passphrase already
|
||||
gates the machine), user + hostname + timezone, hardware autodetection
|
||||
(DMI → nixos-hardware profile), a hibernation-ready swapfile sized to RAM,
|
||||
(DMI → nixos-hardware profile — see **[docs/HARDWARE.md](docs/HARDWARE.md)**),
|
||||
a hibernation-ready swapfile sized to RAM,
|
||||
then disko partitions (GPT + ESP + BTRFS subvolumes incl. `@snapshots` —
|
||||
snapper timeline snapshots are on) and `nixos-install` runs — **without a
|
||||
network** when the ISO was built from a clean tree (the target's
|
||||
@@ -128,8 +149,18 @@ mkdir my-machine && cd my-machine
|
||||
nix flake init -t "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1"
|
||||
```
|
||||
|
||||
The template's own README then walks the first-run steps: a real
|
||||
`hardware-configuration.nix`, `git init` (flakes only see tracked
|
||||
files), the two first rebuilds, and landing the checkout at
|
||||
`~/.nomarchy` so the theme CLI finds it. (The installer does all of
|
||||
this for you.)
|
||||
|
||||
Already running NixOS on this machine? No reinstall needed — adopt
|
||||
Nomarchy in place, reusing your existing `hardware-configuration.nix`:
|
||||
**[docs/MIGRATION.md](docs/MIGRATION.md)**.
|
||||
|
||||
You own two files day-to-day: `system.nix` and `home.nix` (plus
|
||||
`theme-state.json`, written by the CLI). Your `flake.nix` is set up once —
|
||||
`state.json`, written by the CLI). Your `flake.nix` is set up once —
|
||||
later by the installer — and never hand-edited; it's a single call:
|
||||
|
||||
```nix
|
||||
@@ -161,65 +192,139 @@ home-manager switch --flake .#me # desktop: every theme change, no
|
||||
Day-to-day you'll use the shipped shortcuts instead:
|
||||
|
||||
```sh
|
||||
sys-update # nix flake update + system rebuild (BTRFS snapshot first when available)
|
||||
home-update # home-manager switch (no flake update, no sudo)
|
||||
nomarchy-pull # nix flake update — refresh inputs (nomarchy, nixpkgs, …);
|
||||
# optional git pull only if ~/.nomarchy tracks a remote
|
||||
nomarchy-rebuild # system rebuild against the CURRENT lock (sudo inside;
|
||||
# BTRFS snapshot first when snapper is on; nvd diff after)
|
||||
nomarchy-home # home-manager switch — desktop only, no sudo, no lock bump
|
||||
# (prints nvd + a "What changed" toast when the generation moved)
|
||||
nomarchy-what-changed # plain-language last rebuild (system + desktop; --summary for one-liners)
|
||||
```
|
||||
|
||||
**Order matters when pulling distro updates.** `home-update` does *not*
|
||||
touch the lock — it rebuilds the desktop against the **current**
|
||||
`flake.lock`. A new Nomarchy revision (new keybinds, theming, modules)
|
||||
arrives only when the lock is updated, which `sys-update` does
|
||||
(`nix flake update`). So to pull an update that affects the desktop layer:
|
||||
run `sys-update` **first** (updates the lock + rebuilds the system), **then**
|
||||
`home-update` (re-applies the desktop against the new lock). Doing them in
|
||||
the other order rebuilds the desktop against the *old* inputs and silently
|
||||
skips the new home-side changes. After a home-side keybind/config change,
|
||||
also `hyprctl reload` (or relogin) so the running session re-reads it.
|
||||
`~/.nomarchy` is **your machine flake** (`system.nix` / `home.nix`). Newer
|
||||
Nomarchy code arrives when `flake.lock` updates the `nomarchy` input
|
||||
(`nomarchy-pull`), not by `git pull` of that directory (many installs have
|
||||
no remote there — only local commits from auto-commit). With auto-commit
|
||||
on (menu: **System › Auto-commit**), all three commands above also sweep
|
||||
any pending hand edits into a commit before they switch, so
|
||||
`git -C ~/.nomarchy log` mirrors your generations.
|
||||
|
||||
| When | Run |
|
||||
|---|---|
|
||||
| Pull newer Nomarchy / nixpkgs / inputs | `nomarchy-pull` |
|
||||
| You changed `system.nix` (or after pull) | `nomarchy-rebuild` |
|
||||
| You changed `home.nix` / theme / desktop | `nomarchy-home` |
|
||||
| Full upgrade (inputs + both layers) | `nomarchy-pull && nomarchy-rebuild && nomarchy-home` |
|
||||
|
||||
**Order matters for distro updates.** `nomarchy-home` rebuilds against the
|
||||
**current** `flake.lock` — it never updates inputs. A new Nomarchy revision
|
||||
lands only when the lock is updated (`nomarchy-pull`). So for an upstream
|
||||
desktop change: **pull → rebuild → home**. Home before pull rebuilds against
|
||||
the old inputs and silently skips new home-side changes. After a home-side
|
||||
keybind/config change, also `hyprctl reload` (or relogin) so the session
|
||||
re-reads it.
|
||||
|
||||
Legacy aliases still work: `sys-update` → pull+rebuild, `sys-rebuild` →
|
||||
`nomarchy-rebuild`, `home-update` → `nomarchy-home`.
|
||||
|
||||
Override anything via the `nomarchy.*` surface or plain NixOS/HM options:
|
||||
appearance (gaps/colors/fonts) changes through `nomarchy-theme-sync`,
|
||||
appearance (gaps/colors/fonts) changes through `nomarchy-state-sync`,
|
||||
behaviour (input/misc/monitor/chrome) is `mkDefault` so a plain `home.nix`
|
||||
assignment wins, and bind/exec-once lists concatenate. Full guide with
|
||||
examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
|
||||
|
||||
**Where each option goes.** `nomarchy.system.*`, `nomarchy.hardware.*`, and
|
||||
`nomarchy.services.*` are NixOS options — set them in `system.nix`. Everything
|
||||
else under `nomarchy.*` is a Home Manager option — set it in `home.nix`. The
|
||||
two tables below are split along exactly that line.
|
||||
|
||||
**`home.nix`** (Home Manager — the desktop):
|
||||
|
||||
| Option | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `nomarchy.stateFile` | — (required) | Path to your theme-state.json |
|
||||
| `nomarchy.terminal` | `"ghostty"` | Terminal for keybinds and `$TERMINAL` |
|
||||
| `nomarchy.stateFile` | — (required) | Path to your state.json |
|
||||
| `nomarchy.terminal` | `"kitty"` | Terminal for keybinds and `$TERMINAL` (Kitty is the only shipped/themed emulator) |
|
||||
| `nomarchy.kitty.enable` | `true` | Nomarchy's Kitty config (palette/font/opacity from theme-state) |
|
||||
| `nomarchy.keyboard.layout` | `"us"` | XKB layout for the Hyprland session (installer writes the matching `services.xserver.xkb` in system.nix; the console + LUKS prompt follow via the distro default) |
|
||||
| `nomarchy.keyboard.variant` | `""` | XKB variant for the session |
|
||||
| `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant |
|
||||
| `nomarchy.keyboard.layouts` | `[]` | Extra candidate layouts; when set, a watcher prompts (rofi) for a layout on a newly-connected keyboard and remembers it per-device |
|
||||
| `nomarchy.nightlight.enable` | `false` | Scheduled blue-light filter (hyprsunset) — warm at night (`.temperature`, default 4000K) between `.sunset`/`.sunrise`, no shift by day |
|
||||
| `nomarchy.keyboard.layouts` | `[]` | Optional layouts to put first in the new-keyboard picker; the watcher runs by default, offers every installed XKB layout, and remembers the choice per-device. Pick/change one anytime at System › Keyboard |
|
||||
| `nomarchy.nightlight.enable` | `false` | Scheduled blue-light filter (hyprsunset) — warm at night (`.temperature`, default 4000K) between `.sunset`/`.sunrise`, no shift by day; set BOTH `.latitude`/`.longitude` for **geo mode** (wlsunset computes sunrise/sunset from your location; the fixed times are ignored); off by default — enable it from the menu (System › Night light; the first enable rebuilds), then toggle on/off instantly from the menu or the Waybar moon indicator (writes `settings.nightlight.on` in your flake, no rebuild) and it survives reboot, so it stays reproducible |
|
||||
| `nomarchy.updates.enable` | `false` | Passive update awareness: a background check (`.interval`, default daily) that flags when flake inputs (nixpkgs, the Nomarchy input, …) are behind upstream — and, with Flatpak on, when apps have updates (`.flatpak`) — via a Waybar indicator + notification. Never changes anything; click the indicator to run the upgrade flow |
|
||||
| `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config |
|
||||
| `nomarchy.waybar.enable` | `true` | Nomarchy's Waybar |
|
||||
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
|
||||
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
|
||||
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 30) |
|
||||
| `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery |
|
||||
| `nomarchy.dockAudio.enable` | `true` | On a fresh external-monitor plug, PipeWire/WirePlumber are reprobed after hardware settles and the highest-priority available HDMI/DP/USB sink becomes default, with a toast and journal result. A later manual speaker choice sticks until the next physical plug; unplug falls back to built-in. Manual route: System › Audio |
|
||||
| `nomarchy.firstBootWelcome.enable` | `true` | One dismissible “you're set” toast on the first session (SUPER+M / SUPER+T / SUPER+? + network pointer); marker is `settings.firstBootShown` in the flake checkout. Also fires at most one follow-up “Hardware tips” toast pointing at System › Firmware / Fingerprint when `fwupdmgr` / `fprintd-list` are on PATH (`settings.hardwareHintsShown`) |
|
||||
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (lock 5 min; display off 10 min in every dock/lid state — input wakes it compositor-side, #127; suspend 15 min battery-only via `nomarchy-suspend`) |
|
||||
| `nomarchy.idle.fingerprint` | `false` | Unlock the lock screen with a fingerprint as well as the password, and say so on the input field. Set it alongside `nomarchy.hardware.fingerprint.pam` in system.nix: hyprlock is configured from Home Manager, which cannot read the NixOS option — and hyprlock does **not** take a fingerprint through PAM at all (its PAM stack runs only on submit), so it uses its own fprintd backend that this switch turns on |
|
||||
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
|
||||
| `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute |
|
||||
| `nomarchy.shell.enable` | `true` | zsh + starship prompt + bat/eza/zoxide (zsh is the default login shell) |
|
||||
| `nomarchy.ghostty.enable` | `true` | Nomarchy's Ghostty |
|
||||
| `nomarchy.btop.enable` | `true` | btop with per-theme colors |
|
||||
| `nomarchy.stylix.enable` | `true` | GTK/Qt/cursor theming |
|
||||
| `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo |
|
||||
| `nomarchy.keys.enable` | `true` | gpg-agent fronting SSH + pinentry-qt |
|
||||
| `nomarchy.displays.enable` | `true` | nwg-displays interactive monitor arranger (helper for `nomarchy.monitors`) |
|
||||
| `nomarchy.viewers.enable` | `true` | Document/image viewers: zathura (Stylix-themed PDF) + imv |
|
||||
| `nomarchy.mime.enable` | `true` | Default "open with" associations (PDF/image/video/text/browser/directory); entries for apps you removed are skipped, so it degrades with the suite |
|
||||
| `nomarchy.monitors` | `[]` | Declarative per-output layout → Hyprland `monitor` rules (applied on hotplug); `,preferred,auto,1` wildcard kept as fallback |
|
||||
| `nomarchy.displayProfiles` | `{}` | Named layouts for the same outputs (docked/undocked/…), switched from System › Display › Profiles: instant via hyprctl, persisted in-flake (`settings.displayProfile`), baked over `nomarchy.monitors` at the next rebuild. Profiles that disable the laptop panel use the same safe ordering as Dock mode (external on → workspace handoff → internal off). The menu's Auto-switch row (`settings.displayProfileAuto`) applies the matching profile from Hyprland hotplug events. Workspace pins (`workspaces = { "1" = "DP-3"; }`) are moved instantly and baked as rules |
|
||||
| `nomarchy.launchOrFocus` | `[]` | Launch-or-focus binds: `SUPER+<key>` focuses the app's window (case-insensitive class match) or launches it; entries land in the SUPER+? cheatsheet, and a bind whose app was removed notifies instead of failing silently |
|
||||
| `nomarchy.themesDir` | Nomarchy's `themes/` | Where per-theme app overrides are probed |
|
||||
| `nomarchy.package` | overlay's `nomarchy-state-sync` | The theme/state tool package, overridable if you fork it |
|
||||
|
||||
**Greeter keyboard layout:** tuigreet runs on a kernel VT, which has exactly
|
||||
one keymap — `console.useXkbConfig` follows `services.xserver.xkb.layout`
|
||||
(`nomarchy.keyboard.layout`), not Hyprland's per-device `kb_layout` binding.
|
||||
Per-device layouts (`nomarchy.keyboard.devices`) only take effect once the
|
||||
Hyprland session starts, so at the login prompt an external keyboard types
|
||||
the **system** layout, not its remembered one — if your password comes out
|
||||
wrong there, type it as if on the system layout, or use the laptop's own
|
||||
keyboard. A VT constraint, not a bug.
|
||||
|
||||
**Always-on, no toggle by design:** `services.cliphist`, `services.udiskie`
|
||||
(automount + safe-removal toasts) and `services.easyeffects` (mic noise
|
||||
cancellation) ship unconditionally — small, low-risk pieces with no
|
||||
Nomarchy-specific config behind them (the toggle-vs-package rule in
|
||||
`agent/CONVENTIONS.md`). Disable one from `home.nix` with the plain HM
|
||||
option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
|
||||
**[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
|
||||
|
||||
**`system.nix`** (NixOS — the machine):
|
||||
|
||||
| Option | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `nomarchy.system.plymouth.enable` | `true` | Branded boot splash, background from the theme JSON (recolors on system rebuilds) |
|
||||
| `nomarchy.system.stateFile` | `null` | state.json for the system-side consumers (the Plymouth tint); `lib.mkFlake` wires it for you — set it only when composing the modules by hand |
|
||||
| `nomarchy.system.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) |
|
||||
| `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet |
|
||||
| `nomarchy.system.greeter.autoLogin` | `null` | Auto-login this user into Hyprland (installer sets it on LUKS machines) |
|
||||
| `nomarchy.system.audio.enable` | `true` | Pipewire stack |
|
||||
| `nomarchy.system.greeter.autoLogin` | state | Auto-login this user into Hyprland. Owned by System › Auto-login (`settings.greeter.autoLogin`; installer seeds it on LUKS machines) — set it here only to pin it against the menu |
|
||||
| `nomarchy.system.audio.enable` | `true` | PipeWire stack; WirePlumber priority rules provide HDMI/USB preference/fallback, while the Home Manager dock watcher performs the fresh-display reprobe and explicit default selection |
|
||||
| `nomarchy.system.bluetooth.enable` | `true` | Bluetooth + blueman |
|
||||
| `nomarchy.system.autoTimezone.enable` | `false` | Automatic timezone (geoclue + automatic-timezoned) — the clock follows your location; toggle from System › Auto timezone (a menu enable rebuilds: it has to unset the static `time.timeZone`) |
|
||||
| `nomarchy.system.snapper.enable` | `false` | Hourly/daily BTRFS timeline snapshots + `nixos-rebuild-snap` (installer enables it; no-op unless root is BTRFS) |
|
||||
| `nomarchy.system.power.enable` | `true` | Active power management (see below) |
|
||||
| `nomarchy.system.power.backend` | `"ppd"` | `"ppd"` (power-profiles-daemon + menu/Waybar switcher) or `"tlp"` (deeper battery tuning, no switcher) — mutually exclusive |
|
||||
| `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present |
|
||||
| `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present. Clamshell uses logind's `HandleLidSwitchDocked=ignore`; the desktop additionally holds a low-level lid-switch inhibitor across dock/undock, releasing only after the internal panel is restored and the lid opens. Normal future undocked lid-close suspend remains enabled |
|
||||
| `nomarchy.system.power.thermal.enable` | `false` | thermald (Intel-only); the installer enables it on a GenuineIntel CPU |
|
||||
| `nomarchy.system.power.batteryChargeLimit` | `null` | Stop charging at this % (e.g. `80`) where the hardware supports it; needs `power.laptop` |
|
||||
| `nomarchy.services.tailscale.enable` | `false` | Opt-in: Tailscale mesh VPN (then `sudo tailscale up`) |
|
||||
| `nomarchy.system.power.suspendThenHibernate` | `true` (state) | On battery, suspend falls through to hibernate after **1 hour** (`suspend-then-hibernate` + `HibernateDelaySec=1h`; never while on AC). Needs a hibernate resume path (`boot.resumeDevice`). Toggle: System › Preferences › **Suspend then hibernate** (`settings.power.suspendThenHibernate`; system rebuild for logind lid policy). Idle and the Power menu use `nomarchy-suspend` (live state). |
|
||||
| `nomarchy.hardware.intel.enable` | `false` | Intel enablement above nixos-hardware (GuC/HuC firmware via `i915.enable_guc=3` — `.guc` toggles just that; the installer unsets it on `xe`-driver GPUs); the installer sets it on an Intel CPU/GPU |
|
||||
| `nomarchy.hardware.intel.computeRuntime` | `false` | Opt-in: Intel GPU compute — OpenCL/Level-Zero (`intel-compute-runtime`) + oneVPL (`vpl-gpu-rt`) |
|
||||
| `nomarchy.hardware.amd.enable` | `false` | AMD enablement above nixos-hardware (amd-pstate EPP + radeonsi VA-API, each toggleable via `.pstate` / `.vaapi`); installer-set on an AMD CPU/GPU |
|
||||
| `nomarchy.hardware.amd.rocm.enable` | `false` | Opt-in: ROCm HIP/OpenCL GPU compute (multi-GB); pair with `.gfxOverride` (e.g. `"11.0.0"`) for an unlisted iGPU |
|
||||
| `nomarchy.hardware.fingerprint.enable` | `false` | fprintd for a detected fingerprint reader (installer-set); enroll with `fprintd-enroll` |
|
||||
| `nomarchy.hardware.fingerprint.pam` | `false` | Opt-in: use the fingerprint for login + sudo (PAM) |
|
||||
| `nomarchy.hardware.fingerprint.parallel` | `true` | With PAM on: password *or* fingerprint at the same prompt (whichever comes first); `false` = stock sequential pam_fprintd. Password alone always stays sufficient |
|
||||
| `nomarchy.hardware.npu.enable` | `false` | Opt-in/experimental: load the on-die NPU driver (`amdxdna`/`intel_vpu`); userspace runtime is BYO |
|
||||
| `nomarchy.hardware.latestKernel` | `false` | Opt-in: ship `linuxPackages_latest` instead of the default kernel — for very new hardware whose drivers landed recently |
|
||||
| `nomarchy.hardware.camera.hideIrSensor` | `false` | Hide a dual-sensor webcam's IR node from PipeWire's **v4l2** path so apps only ever see the colour camera (the "second, dark Integrated Camera"); installer-set on a paired RGB+IR webcam. `/dev/video*` stays open, so Howdy-style face unlock still works; `.irMatch` overrides the IR-name regex. Does **not** hide IR from libcamera / portal / Flatpak pickers — see [HARDWARE.md §7](docs/HARDWARE.md) |
|
||||
| `nomarchy.hardware.i2c.enable` | `false` | I2C devices support — access to `/dev/i2c-*` (RGB controllers, sensors, DDC/CI monitor control) |
|
||||
| `nomarchy.hardware.i2c.ddcci` | `false` (distro default: **`true`**) | the ddcci-driver kernel module, exposing external monitors as standard backlight devices via DDC/CI so brightness keys and swayosd natively control them |
|
||||
| `nomarchy.services.tailscale.enable` | `false` | Opt-in: Tailscale mesh VPN — the login user is made the operator, so `tailscale up/down/set` and the System › VPN menu work without sudo |
|
||||
| `nomarchy.services.syncthing.enable` | `false` | Opt-in: Syncthing file sync as the login user (GUI at `127.0.0.1:8384`) |
|
||||
| `nomarchy.services.podman.enable` | `false` | Opt-in: rootless Podman (`docker` aliased to it) |
|
||||
| `nomarchy.services.flatpak.enable` | `false` | Opt-in: Flatpak + the Flathub remote |
|
||||
@@ -235,14 +340,33 @@ examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
|
||||
| `nomarchy.services.ollama.enable` | `false` | Opt-in: Ollama local LLM runtime on `127.0.0.1:11434` (CPU; GPU via `services.ollama.acceleration`) |
|
||||
| `nomarchy.services.printing.enable` | `false` | Opt-in: CUPS + Avahi/mDNS network printer discovery |
|
||||
| `nomarchy.services.openrgb.enable` | `false` | Opt-in: OpenRGB daemon for RGB peripheral/motherboard lighting |
|
||||
| `nomarchy.services.restic.enable` | `false` | Opt-in: scheduled daily restic backup (set `.repository` + `.passwordFile`; 7/4/6 retention; list/restore via the `restic-nomarchy` wrapper) |
|
||||
| `nomarchy.services.restic.enable` | `false` | Opt-in: scheduled daily restic backup (set `.repository` + `.passwordFile`; backs up `.paths`, default `/home`; 7/4/6 retention; list/restore via the `restic-nomarchy` wrapper) |
|
||||
|
||||
Beyond the `nomarchy.*` surface, the system layer turns on the usual
|
||||
desktop services with `lib.mkDefault` (override natively). One worth
|
||||
calling out: **`services.fwupd.enable`** is on by default for firmware
|
||||
desktop services with `lib.mkDefault` (override natively) and enforces
|
||||
baseline security/stability defaults. A few worth calling out:
|
||||
**`security.apparmor.enable`** is on by default for mandatory access
|
||||
control confinement. **Kernel panics** are configured to auto-reboot the
|
||||
machine after 10 seconds (`panic=10 oops=panic`) rather than hanging
|
||||
indefinitely. **`services.fwupd.enable`** is on by default for firmware
|
||||
updates via LVFS — it only refreshes metadata, never flashes on its own,
|
||||
so run `fwupdmgr update` to apply. Disable with `services.fwupd.enable =
|
||||
false;` on machines without real firmware (VMs/headless).
|
||||
so run `fwupdmgr update` to apply; disable with `services.fwupd.enable =
|
||||
false;` on machines without real firmware (VMs/headless). Full hardware
|
||||
story (profiles, fingerprint, NVIDIA, unsupported machines, contributing
|
||||
a DMI line): **[docs/HARDWARE.md](docs/HARDWARE.md)**. And
|
||||
**`services.earlyoom`** is on by default so running out of memory kills
|
||||
the offending process (with a desktop notification) instead of freezing
|
||||
the desktop — process-level on purpose, since a Hyprland session is one
|
||||
cgroup and systemd-oomd would kill all of it (oomd is disabled
|
||||
accordingly). Opt out with `services.earlyoom.enable = false;`.
|
||||
**`zramSwap`** is also on by default (zstd, 50% of RAM, priority 100):
|
||||
day-to-day memory pressure pages into compressed RAM instead of the
|
||||
disk, keeping the disk swapfile free for **hibernation** — the installer
|
||||
creates a RAM-sized encrypted swapfile and wires `resume`, so
|
||||
Power › Hibernate works out of the box (swap size 0 at install = no
|
||||
swap). Opt out with `zramSwap.enable = false;`; to add hibernation to a
|
||||
machine installed before it existed, follow
|
||||
**[docs/MIGRATION.md](docs/MIGRATION.md)** § Enabling hibernation.
|
||||
|
||||
## 4. How theming works
|
||||
|
||||
@@ -252,24 +376,56 @@ The trap with "read a mutable file from Nix" is pure evaluation: flakes
|
||||
cannot read arbitrary `$HOME` paths without `--impure` (the old prototype
|
||||
required it — never again). Nomarchy's convention: **the state file lives
|
||||
inside the consuming flake** and is wired via
|
||||
`nomarchy.stateFile = ./theme-state.json;`. Reading it is pure — it's flake
|
||||
source. It must be git-tracked (`nomarchy-theme-sync` runs
|
||||
`nomarchy.stateFile = ./state.json;`. Reading it is pure — it's flake
|
||||
source. It must be git-tracked (`nomarchy-state-sync` runs
|
||||
`git add --intent-to-add` after every write as a safety net).
|
||||
|
||||
### One change = one generation
|
||||
|
||||
`nomarchy-theme-sync apply <theme>` merges the preset into the JSON and runs
|
||||
`nomarchy-state-sync apply <theme>` merges the preset into the JSON and runs
|
||||
`home-manager switch` (override the command with `$NOMARCHY_REBUILD`, or pass
|
||||
`--no-switch` to only write). Everything is baked: Hyprland, Waybar, Ghostty,
|
||||
`--no-switch` to only write). Everything is baked: Hyprland, Waybar, Kitty,
|
||||
btop, and — via Stylix, mapped onto base16 roles — GTK, Qt, cursors and
|
||||
fonts. No runtime patching means no partial states, and `home-manager
|
||||
generations` is also your theme history. Waybar even restyles in place: it
|
||||
re-reads `style.css` when the symlink flips.
|
||||
|
||||
**Cleanup is automatic.** A weekly timer (`nomarchy-gen-prune`) removes
|
||||
**system** and **Home Manager** generations that are older than **14 days**
|
||||
*and* beyond the **three most recent past** gens — so you always keep the
|
||||
current generation plus at least three rollbacks, even on a rarely rebuilt
|
||||
machine. Manual: `sudo nomarchy-gen-prune --dry-run`. Full story:
|
||||
[docs/RECOVERY.md §4](docs/RECOVERY.md#4-how-generations-are-kept-and-cleaned-up).
|
||||
|
||||
The **wallpaper** is the one runtime piece (awww — nixpkgs' swww — is
|
||||
imperative; nothing in Nix consumes the path): applied at session start and
|
||||
after every switch via a tiny activation hook, cycled instantly with
|
||||
`bg next`.
|
||||
imperative; nothing in Nix consumes the path): applied at session start,
|
||||
after every switch via a tiny activation hook, and again when a monitor is
|
||||
hotplugged; cycled instantly with `bg next`.
|
||||
|
||||
### Config the menu writes (not just themes)
|
||||
|
||||
The in-flake-state model isn't only for appearance. **Feature toggles you flip
|
||||
from the menu are written into a `settings.*` section of the *same* state file**
|
||||
— git-tracked, reproducible, never stashed in `~/.local/state`. The menu is just
|
||||
an ergonomic writer for your flake, so version-controlling your downstream
|
||||
reproduces the machine, settings and all. Where a toggle can take effect without
|
||||
a rebuild it does: the menu writes the key (`--no-switch`) and flips the running
|
||||
service, which reads the *live* flake state at session start, so the choice is
|
||||
both instant and survives reboot.
|
||||
|
||||
**Night light** is the first to use this — enable it from the menu (System ›
|
||||
Night light; the first enable rebuilds to install hyprsunset), then on/off is
|
||||
instant. Internally it's two keys: `settings.nightlight.installed` (sticky —
|
||||
gates the unit, the first enable rebuilds) and `settings.nightlight.on` (the
|
||||
instant on/off). Expect more `nomarchy.*` toggles to migrate to this pattern.
|
||||
|
||||
**Auto-commit (opt-in):** System › Auto-commit makes every `apply`/`set`
|
||||
mutation also `git commit` state.json in your flake — *only* that
|
||||
file, so unrelated dirty work is never swept up — turning your settings
|
||||
history into `git log`. Off by default; the toggle is instant (nothing in
|
||||
Nix consumes the flag), the off-write is itself committed so history stays
|
||||
consistent, and wallpaper cycling (`bg next`) is deliberately excluded —
|
||||
the current wallpaper rides along with the next real commit.
|
||||
|
||||
### Per-theme app assets (`themes/<slug>/`)
|
||||
|
||||
@@ -278,35 +434,44 @@ per theme — a single place to look, unlike the old distro's split:
|
||||
|
||||
| Asset | Mechanism |
|
||||
|---|---|
|
||||
| `backgrounds/` | wallpapers; empty `wallpaper` in the state means "first one"; `bg next` cycles |
|
||||
| `backgrounds/` | wallpapers; empty `wallpaper` in the state means "first one"; `bg next` cycles. Pinned in a separate `nomarchy-wallpapers` flake input, not this repo (ROADMAP § "Faster switches") — merged in at build time |
|
||||
| `btop.theme` | baked into the generation (generated from the palette when absent) |
|
||||
| `waybar.css` | **whole-swap**: replaces the generated bar style entirely (probed at eval time, self-contained) |
|
||||
| `waybar.jsonc` | whole-swap for the bar *layout* (must be plain JSON) |
|
||||
| `rofi.rasi` | **whole-swap**: replaces the generated launcher/menu theme entirely |
|
||||
|
||||
Six ported themes ship a `waybar.css` identity (catppuccin, lumon, nord,
|
||||
retro-82, summer-day, summer-night). Custom user themes can live in
|
||||
Eight themes ship a `waybar.css` identity: the three Nomarchy-exclusive
|
||||
day/night pairs — boreal ↔ boreal-dawn, executive-slate ↔ executive-ivory,
|
||||
kiln ↔ kiln-clay — plus summer-night ↔ summer-day. Custom user themes can live in
|
||||
`$NOMARCHY_PATH/themes/` (preset lookup) and `nomarchy.themesDir` (eval-time
|
||||
asset probe).
|
||||
asset probe) — including their own `backgrounds/`, which wins over the
|
||||
pinned `nomarchy-wallpapers` input (checked first); an explicit `wallpaper`
|
||||
path in `state.json` works too.
|
||||
|
||||
## 5. Day-to-day
|
||||
|
||||
```sh
|
||||
nomarchy-theme-sync list # 21 presets (nord, gruvbox, rose-pine, …)
|
||||
nomarchy-theme-sync apply kanagawa # whole desktop, one generation (~a switch)
|
||||
nomarchy-theme-sync set ui.gapsOut 16 # tweak one knob (also a switch)
|
||||
nomarchy-theme-sync bg next # cycle wallpapers — instant, no rebuild
|
||||
nomarchy-theme-sync bg auto # back to the theme's default wallpaper
|
||||
nomarchy-theme-sync get colors.accent
|
||||
sys-update # update inputs + rebuild the system (snapshots first)
|
||||
home-update # rebuild just the desktop layer
|
||||
nomarchy-state-sync list # 28 presets (nord, gruvbox, rose-pine, …)
|
||||
nomarchy-state-sync apply kanagawa # whole desktop, one generation (~a switch)
|
||||
nomarchy-state-sync set ui.gapsOut 16 # tweak one knob (also a switch)
|
||||
nomarchy-state-sync bg next # cycle wallpapers — instant, no rebuild
|
||||
nomarchy-state-sync bg auto # back to the theme's default wallpaper
|
||||
nomarchy-state-sync get colors.accent
|
||||
nomarchy-pull # flake input update (no rebuild)
|
||||
nomarchy-rebuild # rebuild the system, current lock
|
||||
nomarchy-home # rebuild just the desktop layer
|
||||
nomarchy-doctor # read-only health sheet (also: menu › System › Doctor)
|
||||
```
|
||||
|
||||
Keybinds: `SUPER+Return` terminal · `SUPER+D` launcher · `SUPER+T` theme
|
||||
Something broke anyway? Every rebuild is a generation and (on BTRFS)
|
||||
every hour is a snapshot — the undo story, from a bad theme to a
|
||||
machine that won't boot, is **[docs/RECOVERY.md](docs/RECOVERY.md)**.
|
||||
|
||||
Keybinds: `SUPER+Return` terminal · `SUPER+Space` launcher · `SUPER+T` theme
|
||||
picker · `SUPER+SHIFT+T` next wallpaper · `SUPER+X` power menu ·
|
||||
`SUPER+E` file manager (yazi) · `SUPER+N` notifications · `SUPER+CTRL+V`
|
||||
clipboard history · `SUPER+Q`
|
||||
close · `SUPER+1..9` workspaces · `Print` region screenshot.
|
||||
clipboard history · `SUPER+SHIFT+C` color picker (hyprpicker) · `SUPER+Q`
|
||||
close · `SUPER+1..9` workspaces · `SUPER+ALT+arrow` move workspace to monitor · `Print` region screenshot.
|
||||
|
||||
Shell aliases (zsh, gated on `nomarchy.shell.enable`) — `alias` lists them
|
||||
all; the curated set:
|
||||
@@ -324,10 +489,10 @@ gd gds # diff · diff --staged
|
||||
gl glg # log graph (last 20 · all branches)
|
||||
gp gpl gf # push · pull · fetch --all --prune
|
||||
|
||||
# nix (system/home rebuilds keep their full sys-update / home-update names)
|
||||
# nix (lifecycle: nomarchy-pull / nomarchy-rebuild / nomarchy-home — full names)
|
||||
ns nr # nix shell · nix run (e.g. ns nixpkgs#ripgrep)
|
||||
nfu nfc # nix flake update · check
|
||||
nsearch ngc # nix search nixpkgs · nix-collect-garbage -d
|
||||
nfu nfc # nix flake update · check (prefer nomarchy-pull for day-to-day)
|
||||
nsearch ngc # nix search · nix-collect-garbage -d (prefer: sudo nomarchy-gen-prune)
|
||||
|
||||
# misc
|
||||
path # print $PATH, one entry per line
|
||||
@@ -338,10 +503,13 @@ reload # exec zsh (reload the shell)
|
||||
|
||||
- **New theme:** drop a JSON into `themes/` (schema = any existing preset),
|
||||
plus an optional `themes/<slug>/` assets directory.
|
||||
- **New themed value:** add the key to `theme-state.json` and consume it in
|
||||
- **New themed value:** add the key to `state.json` and consume it in
|
||||
the Nix modules. One place — there is no second renderer to keep in sync.
|
||||
- **Importing more old-distro palettes:**
|
||||
`tools/import-palettes.py <palettes-dir> themes/`.
|
||||
`tools/import-palettes.py <palettes-dir> themes/` (roles are first-class:
|
||||
when ANSI color0==color8 the tool derives an overlay step; light themes
|
||||
do not use dark ANSI black as surface — hand-tune after import; do not
|
||||
bulk-reimport shipped JSON without a hierarchy pass).
|
||||
- **New opt-in feature (convention):** when a feature is off by default and
|
||||
needs the user to set `nomarchy.*` options (e.g. night light, per-device
|
||||
keyboard layouts, monitor layout, power management), ship a **commented**
|
||||
@@ -354,9 +522,28 @@ reload # exec zsh (reload the shell)
|
||||
line to slim the machine, uncomment an extra (a browser, email, full TeX
|
||||
Live), or add your own. No `nomarchy.apps.*` toggles — a package list is
|
||||
already its own opt-out, so the distro doesn't impose these or wrap them.
|
||||
- **A newer individual app (unstable channel, #134):** Nomarchy carries a
|
||||
second, newer nixpkgs channel and exposes it as `unstable` through its
|
||||
overlay, so any package can be taken from it by prefixing `unstable.` in
|
||||
`home.packages` — e.g. `unstable.lmstudio` — with no second flake input of
|
||||
your own to manage. Three costs, stated plainly: (a) **you own this
|
||||
combination** — nothing upstream tests a pinned Nomarchy plus an unstable
|
||||
app together; (b) an unstable package brings its own second toolchain into
|
||||
your closure (measured: `unstable.lmstudio` shares only ~690 of ~4k store
|
||||
paths with the pinned set); (c) it moves whenever the flake lock moves
|
||||
(`nomarchy-pull`), not on its own schedule. Home packages only — that's
|
||||
the supported surface, not `system.nix` or the module set.
|
||||
|
||||
## Roadmap & known issues
|
||||
|
||||
See **[docs/ROADMAP.md](docs/ROADMAP.md)** — forward-looking plans plus the
|
||||
log of shipped fixes. Kept out of the README so this stays a focused entry
|
||||
point.
|
||||
| Doc | Role |
|
||||
|-----|------|
|
||||
| **[agent/BACKLOG.md](agent/BACKLOG.md)** | What to do next (agent queue) |
|
||||
| **[docs/VISION.md](docs/VISION.md)** | Product themes toward **v1.0** |
|
||||
| **[docs/ROADMAP.md](docs/ROADMAP.md)** | Design history + shipped log |
|
||||
| **[docs/README.md](docs/README.md)** · **[agent/README.md](agent/README.md)** | Maps |
|
||||
| **[agent/LOOP.md](agent/LOOP.md)** | Autonomous iteration protocol |
|
||||
|
||||
Kept out of the README body so this stays a focused entry point.
|
||||
|
||||
License: [MIT](LICENSE).
|
||||
|
||||
352
agent/BACKLOG.md
Normal file
352
agent/BACKLOG.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# Backlog — the prioritized task queue
|
||||
|
||||
**This is the only executable work list for agents.** Product themes and
|
||||
v1.0 intent live in [`docs/VISION.md`](../docs/VISION.md); design history
|
||||
in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in
|
||||
[`docs/README.md`](../docs/README.md) and [`agent/README.md`](README.md).
|
||||
|
||||
**Rules:**
|
||||
- Agents take the topmost actionable item (see LOOP.md). Finished items
|
||||
are **deleted** here — the journal + git log are the record; durable
|
||||
design notes get a ✓-entry in docs/ROADMAP.md (and/or a note in VISION)
|
||||
if worth keeping.
|
||||
- Item numbers are **stable IDs** — never renumbered or reused. A gap in
|
||||
the sequence means shipped (or dropped) work; new items take the next
|
||||
free number regardless of tier.
|
||||
- Tags: `[blocked:hw]` needs real hardware (see HARDWARE-QUEUE.md) ·
|
||||
`[human]` needs Bernardo · `[stuck]` two failed attempts, needs help ·
|
||||
`[big]` must be split before starting.
|
||||
- Agents may append to **PROPOSED** and **Decisions** freely (include
|
||||
`VISION § …` or `ROADMAP § …` when relevant); only Bernardo moves items
|
||||
*out* of PROPOSED into the tiers.
|
||||
|
||||
---
|
||||
|
||||
## NOW
|
||||
|
||||
### Live ISO / install hardware findings — Acer Aspire M5-481T + Dell XPS 9350
|
||||
|
||||
Bernardo, real installs 2026-07-13–14 (photos of the install end screens and
|
||||
post-boot sessions). Preserve separation: the installer bake failure and the
|
||||
flake pin are different root causes even when they show up on the same machine.
|
||||
(Terminal / Ghostty-on-Acer → shipped as Kitty-only, #95.)
|
||||
|
||||
## NEXT
|
||||
|
||||
### 151. `[human]` v1 launch plan — devise with Bernardo (includes the GitHub move)
|
||||
|
||||
Bernardo, 2026-07-17: before tagging v1, sit down and devise the launch plan.
|
||||
Known ingredients, so that session starts concrete:
|
||||
- **The repo moves to GitHub.** Everything baking the Gitea URL follows: the
|
||||
downstream template's `inputs.nomarchy.url`, the `nomarchy-wallpapers`
|
||||
input URL (baked 2026-07-17 — sweep it in the same move), README/docs
|
||||
links, and CI (eval CI runs on Gitea act_runner — decide GitHub Actions
|
||||
vs keeping Gitea CI on a mirror).
|
||||
- The move is the natural moment for the **history-rewrite decision**, and
|
||||
it is now measured (2026-07-17): the 107 MiB pack is 94.2 MB wallpaper
|
||||
blobs + 9.9 MB preview generations; all 848 commits of *text* pack to
|
||||
~15–20 MiB. So the history is not long-heavy, it is image-heavy.
|
||||
Recommendation on the table: **filter, don't reset** —
|
||||
`git filter-repo` stripping only `themes/*/backgrounds/` keeps every
|
||||
commit's diffs/blame/`log -S` (load-bearing: it diagnosed #147) at a
|
||||
~15–20 MiB clone. Cost: all SHAs change, and ROADMAP/journal cite them —
|
||||
mitigate by freezing the Gitea repo as a read-only archive (old SHAs
|
||||
resolve there; stash filter-repo's commit-map in it) + one ROADMAP note.
|
||||
Argue against a full mirror (ships 94 MB of deleted images to every
|
||||
clone forever) and against fresh-start (burns the archaeology to save
|
||||
~15 MiB).
|
||||
- The existing v1 bar (VISION § v1.0): HARDWARE-QUEUE burn-down, install P0
|
||||
re-verify before the `v1` fast-forward, visual ritual. LICENSE shipped
|
||||
(MIT, 2026-07-17).
|
||||
- Adjacent calls that may fold in: cachix/public binary cache (#120's
|
||||
prerequisite), the netinstall decision itself, release notes/announce.
|
||||
|
||||
`v1` stays human-only throughout — agents prepare evidence and checklists,
|
||||
never the tag or branch.
|
||||
|
||||
## LATER
|
||||
|
||||
- **Pre-built theme variants** (ROADMAP § Faster switches follow-on): if
|
||||
`home-manager switch` itself is still the theme-switch bottleneck now
|
||||
that the wallpapers split shipped (2026-07-17), pre-build each theme's
|
||||
generation so a switch just activates a cached one. Measure first.
|
||||
- **Installer round 2** (ROADMAP § Installer): multi-disk BTRFS RAID,
|
||||
impermanence, BIOS/legacy boot.
|
||||
- **Boot-from-snapshot**: a systemd-boot equivalent of grub-btrfs.
|
||||
- **MIPI/IPU software-ISP camera** support (no-UVC machines).
|
||||
- **NixOS release bump → v2** `[human]`: deliberate, hand-edited, never
|
||||
automated; the previous attempt was discarded (2026-06-22) over a
|
||||
Hyprland OOM blocker — see MEMORY.md before retrying.
|
||||
|
||||
## FUTURE (decided deferred — not the agent queue head)
|
||||
|
||||
Work we **intend** someday but explicitly **not** NEXT. Agents do not
|
||||
pick these unless Bernardo promotes one into NEXT/NOW.
|
||||
|
||||
### 20. KVM runner → VM suite in CI `[human]`
|
||||
**Status (2026-07-10):** keep **eval-only** CI on the current Gitea
|
||||
stack (act_runner in docker-compose on the 4c/4 GB IONOS VPS). Nested
|
||||
KVM + RAM headroom on that host are a poor fit next to Gitea; full
|
||||
`checks.*` VMs stay local / promotion-time until a **separate**
|
||||
KVM-capable machine exists.
|
||||
|
||||
**When ready:** register a second runner (host-mode nix + `/dev/kvm`,
|
||||
label `nix-kvm` — not the existing docker eval runner), then uncomment
|
||||
the `vm-checks` job in `.gitea/workflows/check.yml` (`runs-on: nix-kvm`,
|
||||
`nix flake check` + toplevel/HM builds). Do not enable the job until
|
||||
that label is online (Gitea queues forever otherwise).
|
||||
|
||||
### Formatter — adopt later `[human]`
|
||||
**Intent:** add a Nix formatter (likely `nixfmt-rfc-style`) in a dedicated
|
||||
pass: reformat the tree once, document in CONVENTIONS, optional CI
|
||||
check. **Not** the queue head — no drive-by reformats until that pass.
|
||||
|
||||
## PROPOSED (agent suggestions — await human triage)
|
||||
|
||||
*Agents: append here with a one-paragraph pitch (what/why/cost). Do not
|
||||
implement. Bernardo moves accepted items into a tier.*
|
||||
|
||||
*Open work only. Shipped exam/A–C items (#47–#63, #14, #52 theme
|
||||
high-ROI, etc.) live in the journal + ROADMAP — not here.*
|
||||
|
||||
### Product / day-2
|
||||
|
||||
### 153. Desktop widgets (clock / system stats) as an opt-in per-theme surface
|
||||
|
||||
Pitch (filed while shipping the three exclusive theme pairs, 2026-07-18):
|
||||
themed desktop widgets — a clock, calendar, or system-stats panel living on
|
||||
the wallpaper, conky-style — would deepen the identity themes (imagine
|
||||
kiln's copper stats panel or executive-ivory's letterhead clock). No such
|
||||
surface exists today, so it is a NEW theming surface: per CONVENTIONS it
|
||||
must consume the palette from the state JSON (no side pipeline) and ship
|
||||
as an opt-in `nomarchy.*` toggle with a commented template example. Engine
|
||||
choice is the real decision — conky is X11-era; a Wayland layer-shell
|
||||
widget system (eww or a small QML/GTK layer-shell tool) fits the stack
|
||||
better. Cost: moderate (engine spike + palette bridge + 2–3 widget
|
||||
layouts; start with the exclusive pairs only, not all 28 themes). Value:
|
||||
v1+ polish, not a v1 blocker.
|
||||
|
||||
### 146. `[watch]` hypridle hangs instead of exiting when its compositor vanishes
|
||||
|
||||
**Rewritten 2026-07-16, hours after filing: the premise was disproven and almost
|
||||
nothing survives.** As filed it said hypridle "dies silently ~2×/week — 19 times
|
||||
in 11 days", with Jul 14 as the clean example ("`CRITICAL` at 13:08:17, then
|
||||
eleven hours of silence"). Every number there was an artifact of the
|
||||
measurement. **18 of the 19** `Disconnected from pollfd id 1` lines are
|
||||
hypridle's noisy goodbye while systemd *deliberately stops it* — each bracketed
|
||||
by `Stopped hypridle` / `Started hypridle` with a healthy replacement seconds
|
||||
later. The "eleven hours of silence" came from grepping **one PID**: systemd
|
||||
logged `Stopped hypridle` 13:08:17, `Started hypridle` 13:09:00, and PID 1678
|
||||
ran fine all evening where the filter could not see it. Do not re-derive the old
|
||||
rate; it counts normal shutdown noise.
|
||||
|
||||
**What is real, and it is small.** Exactly one genuine zombie has been observed
|
||||
(2026-07-16 17:25:39), verified by process inspection rather than logs — `ps`
|
||||
(alive 9h36m), `ss` (48 KB unread on its D-Bus socket, i.e. not reading),
|
||||
`NRestarts=0` while `systemctl --user is-active` said `active (running)`. That
|
||||
is upstream hyprwm/hypridle#171 ("Possible deadlock in pollThr when compositor
|
||||
exits?", open since 2025-09, in neither 0.1.7 nor main) and it is a true bug:
|
||||
hypridle hangs rather than exits, so `Restart=always` never fires.
|
||||
|
||||
**A second real one joined it with #127's close (2026-07-17):** an
|
||||
inhibit-lock underflow (`BUG THIS: inhibit locks < 0: -1`, hypridle's own
|
||||
words, Jul 15 10:36:29) left the fired DPMS listener's `on-resume` permanently
|
||||
dead on 0.1.7 — the incident's actual mechanism (ROADMAP § "The idle brick's
|
||||
CAUSE found"). Reported upstream with the full trace 2026-07-17:
|
||||
https://github.com/hyprwm/hypridle/issues/208
|
||||
(#74/#104/#128 are the same class, closed before 0.1.7 evidently without
|
||||
killing it). Its impact path here is closed by the compositor-side wake fix
|
||||
(a2151f4); same watch posture — take the upstream fix when a release carries
|
||||
it.
|
||||
|
||||
**But its impact path is closed, which is why this is `[watch]` and not work.**
|
||||
The zombie only *persists* if systemd never stops the unit — which needed the
|
||||
user manager to survive logout, i.e. the stray pre-migration linger removed in
|
||||
#147. Without linger, logout tears the manager down and takes any zombie with
|
||||
it; the Jul 14 trace above shows the healthy path (`Stopped` → `Started`) doing
|
||||
exactly that. A Hyprland death ends the logind session anyway (2026-07-16:
|
||||
`Session 2 logged out` → greeter), so there is no known route left to a
|
||||
surviving zombie.
|
||||
|
||||
**Do before building anything:** re-measure. Count only disconnects with **no**
|
||||
`Stopped/Started hypridle` around them and **no** logind session event, and
|
||||
confirm by `ps`/`ss` that the process is really wedged — not by grepping a PID.
|
||||
If that count is zero over a few weeks post-linger, **close this unbuilt**. If it
|
||||
is not, the upstream issue has sat ten months on vaxerski's "that will cause a
|
||||
segfault no?" and a reproducer would be worth more than a local guard.
|
||||
|
||||
### 150. Extract the display-transition tool — idle.nix carries a lossy copy under the same name
|
||||
|
||||
Found by the 2026-07-17 structural audit. `modules/home/idle.nix:23-53` defines
|
||||
`miniTransition`, a ~22-line `writeShellScriptBin` named
|
||||
**`nomarchy-display-transition`** — the same binary name as the real ~175-line
|
||||
tool in `modules/home/hyprland.nix:136-310` — implementing only
|
||||
`undock|enable`, with none of the full tool's safety: no logging, no
|
||||
lid-inhibitor gate, no `restore_keyboards`, no workspace moves, no
|
||||
failure dump. The comment says why: "a mini transition avoids a circular
|
||||
import on hyprland.nix". No PATH collision today (the mini is never installed;
|
||||
it is baked by store path into hypridle's wake tool via `display-tools.nix`),
|
||||
but nothing asserts the two stay behaviourally consistent, and this is the
|
||||
subsystem where #100/#142/#148 all lived. Pitch: extract the full tool into
|
||||
its own module file (e.g. `modules/home/display-transition.nix`) imported by
|
||||
both hyprland.nix and idle.nix, delete the mini, re-run the
|
||||
`tools/monitor-fallback.nix` harness + `checks.docking-ux` as the V2 gate.
|
||||
Cost: a focused refactor of safety-critical code — deliberately NOT done as
|
||||
hygiene drive-by; deserves its own iteration.
|
||||
|
||||
### 149. `[watch]` Waybar's exec-once workaround may be defending against nothing post-linger
|
||||
|
||||
Spun out of #147 (shipped 2026-07-17) so its loose thread survives the entry:
|
||||
`modules/home/hyprland.nix` runs Waybar from `exec-once` rather than a systemd
|
||||
unit because the unit "raced Hyprland's IPC on a **warm** relogin ... never
|
||||
retried, so the bar vanished" — and a warm relogin is exactly what linger
|
||||
created. With the stray linger removed (2026-07-16) that workaround may be
|
||||
defending against nothing. Do not rip it out on this note alone: it works, and
|
||||
the claim needs a real relogin without linger to test (V3 — the same relogin
|
||||
that verifies #148's watcher fix would answer it).
|
||||
|
||||
**2026-07-18 evidence: the warm relogin is real post-linger.** On the dev
|
||||
box the user manager survived a Hyprland logout (the tty1 greetd session
|
||||
keeps it alive — no linger involved) and the teardown broke:
|
||||
`graphical-session.target`'s stop transaction was rejected as destructive
|
||||
(easyeffects had a queued start job), the target stayed active,
|
||||
cliphist/swaync/portals/swayosd crash-looped against the dead Wayland
|
||||
socket into `start-limit-hit`, and the relogin's plain `start` was a no-op
|
||||
— doctor red for the whole session. Session bring-up now stops stale
|
||||
targets + `reset-failed` before starting (`hyprland.nix`
|
||||
systemd.extraCommands); relogin V3 check queued in HARDWARE-QUEUE. The
|
||||
exec-once posture this entry watches is therefore still earning its keep.
|
||||
|
||||
### 120. A netinstall ISO, next to the fat offline one
|
||||
|
||||
**Deferred to PROPOSED 2026-07-16 (Bernardo): not now.** Nothing below is
|
||||
stale — the measurements stand and the decision it needs is unchanged. It sits
|
||||
here because it is the only genuinely large item left, and because the gotcha
|
||||
at the bottom (no binary cache for our own derivations) probably makes a
|
||||
cachix the real first step, not the ISO.
|
||||
|
||||
Bernardo 2026-07-14, after seeing the measured size: **keep the current ISO
|
||||
exactly as it is** — the guaranteed offline install is the feature it buys —
|
||||
and ship a **much lighter netinstall variant alongside it**. Two products, one
|
||||
distro: "works on a plane" and "8 GiB is absurd to download" are both true, and
|
||||
a second target settles them without compromising either.
|
||||
|
||||
**Measured facts (2026-07-14), so this starts from numbers, not vibes.**
|
||||
*(These stand as measured: #121 would have cut ~0.67 GiB of duplicate chromium
|
||||
from them, but it was **reverted** — decided against, ROADMAP § one chromium,
|
||||
not two. If a netinstall ships, revisit it: the duplicate is worth ~195 MiB of
|
||||
**download**, which is this item's whole currency, even though it is worth
|
||||
almost nothing on disk or on the ISO.)*
|
||||
|
||||
> **Read this before using the numbers below.** They are **closure arithmetic**,
|
||||
> and #121 proved the hard way that closure size is neither disk size nor image
|
||||
> size: removing a 687 MiB path shrank the ISO by **8 KiB**, because
|
||||
> **mksquashfs dedupes duplicate files** and **`auto-optimise-store` hardlinks**
|
||||
> them on disk. So a change that looks like it sheds gigabytes of closure can
|
||||
> shed nothing off the actual image. **Measure the artifact — build the ISO and
|
||||
> `stat` it.** The corollary cuts the other way and is the good news for this
|
||||
> item: what dedupe cannot help is the **wire**, so a netinstall's download is
|
||||
> the one figure closure/NAR size predicts honestly (`nix path-info --store
|
||||
> https://cache.nixos.org --json` gives the real `downloadSize`).
|
||||
- Current ISO **8.078 GiB** compressed; **18.03 GiB** of store uncompressed
|
||||
(`zstd -19`, 2.23:1 — compression is already near-max, not the lever).
|
||||
- The offline pin (`system.extraDependencies`, 60 roots: a representative
|
||||
installed system + the template HM closure + all flake inputs) is **4.02 GiB
|
||||
uncompressed of that — only ~22%**. Dropping it entirely still leaves a
|
||||
**~13.3 GiB** desktop → roughly **6 GiB** compressed at the same ratio.
|
||||
**So "no pin" alone is NOT the lighter ISO** — this is the trap to avoid.
|
||||
- The desktop's own top weights: libreoffice 1457 MiB, initrd 1369,
|
||||
linux-firmware 770, chromium 1391 (two builds — #121, left in), llvm-lib 540,
|
||||
bibata-cursors 322, mesa 264, mbrola-voices 259, nerd-fonts ~420 combined.
|
||||
Note what that list implies: no single lever gets a desktop ISO under ~4 GB —
|
||||
which is the case for (b) below.
|
||||
|
||||
**So the real decision is what a netinstall ISO IS**, and it should be settled
|
||||
first (`[human]`): (a) the full try-before-install desktop minus the pin
|
||||
(~6.3 GiB — barely lighter, probably not worth a second target); (b) a **TUI
|
||||
installer only, no desktop** (~1 GiB, the actual "netinstall" in the Debian
|
||||
sense) which drops "try before install" from that medium — the fat ISO still
|
||||
offers it; (c) a middle desktop (no libreoffice/chromium — but note #103 just
|
||||
put those there deliberately, and a *demo* desktop that can't browse is the
|
||||
bug #103 fixed).
|
||||
|
||||
**The gotcha that decides feasibility:** without the pin, a netinstall target
|
||||
fetches from `cache.nixos.org` for stock nixpkgs paths — but **Nomarchy's own
|
||||
derivations are in no binary cache**, so they would build *from source on the
|
||||
user's machine* during install. That is the same failure `tools/vm/gap-analysis.py`
|
||||
exists to diagnose (and #113 is a live instance of). So this item probably
|
||||
depends on a public binary cache (cachix) for the flake's own outputs, or it
|
||||
trades an 8 GiB download for a 40-minute install. Establish that before
|
||||
building the target.
|
||||
|
||||
Pass = a second, documented ISO target that is *substantially* smaller (state
|
||||
the measured number, both ISOs built from one tree), installs successfully with
|
||||
a network in a QEMU run, says clearly at boot that it needs one, and leaves the
|
||||
offline ISO's behaviour untouched (`checks.*` for the offline path stay green).
|
||||
- **NVIDIA first-class options** — **deferred past v1** (Bernardo
|
||||
2026-07-10). Keep #59 commented install guidance; no
|
||||
`nomarchy.hardware.nvidia.*` until a hybrid maintainer + queue.
|
||||
|
||||
_(#80–#83 + #85–#88 shipped 2026-07-11. Theme A day-2 + neon-glass finish
|
||||
shipped — VISION ✓. Dock/hibernate V3 → HARDWARE-QUEUE. Parallel
|
||||
fingerprint-or-password shipped 2026-07-12 (Bernardo promoted it live;
|
||||
`fingerprint.parallel`, pam-fprint-grosshack) — reader V3 →
|
||||
HARDWARE-QUEUE.)_
|
||||
|
||||
### v1.0 pointer
|
||||
|
||||
See **VISION**. Open PROPOSED: NVIDIA deferred past v1; IR portal (b)/(c)
|
||||
need T14s (HARDWARE-QUEUE § T14s). Standing calls: browser = Chromium;
|
||||
power = PPD. (Post-install hardware hints shipped 2026-07-17.)
|
||||
|
||||
## Decisions `[human]`
|
||||
|
||||
Open calls only Bernardo can make; agents add options/evidence but never
|
||||
decide. **Resolved** entries stay for history; agents treat them as closed.
|
||||
|
||||
### Resolved (2026-07-17)
|
||||
|
||||
- **#143 rofi refilter highlight** — the 07-16 "wait for upstream" ruling
|
||||
was priced against carrying a patch; the maintainer's reply on
|
||||
rofi#2317 pointed at the `inputchange {}` config block instead. Adopted
|
||||
same day as pure config (`kb-row-first` on query edits, generated
|
||||
config.rasi) — no patch, no source build, watch closed
|
||||
(ROADMAP § Rofi highlight).
|
||||
- **#120 netinstall** — stays deferred in PROPOSED (re-affirmed). When it
|
||||
is promoted, a public binary cache (cachix) is the likely first step.
|
||||
- **#134 unstable packages** — build it: Nomarchy carries the
|
||||
`nixpkgs-unstable` input, `unstable.*` via the overlay, home-scope only.
|
||||
Shipped the same day (ROADMAP § `unstable.<pkg>` in the downstream).
|
||||
- **#114 greeter layouts** — document only: a VT has one keymap by design,
|
||||
so tuigreet cannot honour per-device layouts. Shipped as the README
|
||||
"Greeter keyboard layout" note + a RECOVERY.md pointer; entry deleted.
|
||||
|
||||
### Resolved (2026-07-10)
|
||||
|
||||
- **Docs site vs Markdown-in-repo** — **markdown in-repo for now**
|
||||
(`docs/`, README). A rendered docs site is FUTURE if wanted.
|
||||
- **Default browser** — **ship Chromium** in
|
||||
`templates/downstream/home.nix`; mime → `chromium-browser.desktop`.
|
||||
Opt out: delete the line / override mime.
|
||||
- **Default power backend** — **keep PPD** (`nomarchy.system.power.backend`
|
||||
default). TLP remains the one-line opt-in. Rationale: stability + live
|
||||
profile API for menu/Waybar; Omarchy’s TLP experiment reverted.
|
||||
|
||||
### Resolved (2026-07-10, more)
|
||||
|
||||
- **Formatter adoption** — **yes, but not now.** Tracked as FUTURE
|
||||
(below). Nix-source style only (`nixfmt-rfc-style` or similar); one
|
||||
bulk reformat + CI/check when promoted. Until then: hand-aligned
|
||||
style per CONVENTIONS.
|
||||
|
||||
- **Hibernation** — **want by default** (product intent). Needs a
|
||||
disk-backed swap (file or partition) sized for resume; not zram alone.
|
||||
**Shipped as #76**; V3 power-cycle PASSED on TuringMachine 2026-07-12
|
||||
(ROADMAP § Hibernation + zram by default).
|
||||
|
||||
### Resolved (2026-07-10, #76 design)
|
||||
|
||||
- **Swap sizing** — **exactly RAM** (installer default, unchanged). Hibernate
|
||||
image ≤ RAM; zram takes day-to-day paging. **`swapSize=0`** stays no-swap.
|
||||
- **Migration** — **docs runbook** (`docs/MIGRATION.md`), not a tool.
|
||||
- **No-swap Hibernate** — keep the menu row; **notify on failure**.
|
||||
75
agent/CONVENTIONS.md
Normal file
75
agent/CONVENTIONS.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Conventions — how Nomarchy code is written
|
||||
|
||||
The standing rules an agent must follow while coding. GOALS.md says what
|
||||
we're building; this says how. Details/rationale live in docs/ROADMAP.md's
|
||||
decision records and the README.
|
||||
|
||||
## Repo layout (the rule of thumb)
|
||||
`modules/` is the distro (reusable, no machine specifics) · `hosts/` is a
|
||||
machine · `themes/` is data · `pkgs/` is code · `tools/` is maintainer-only
|
||||
· `agent/` is loop state. If a new file doesn't obviously belong to one of
|
||||
those, it probably shouldn't exist.
|
||||
|
||||
## Nix style
|
||||
- **No formatter.** Files use deliberate aligned hand-formatting — match
|
||||
the surrounding style exactly; never reflow a file you're only touching
|
||||
a line of.
|
||||
- Distro defaults use `lib.mkDefault` so a plain downstream assignment
|
||||
wins; bind/exec lists concatenate. Behaviour options overridable,
|
||||
appearance flows from the state JSON.
|
||||
- Options live in the existing surfaces: `nomarchy.system.*` /
|
||||
`nomarchy.hardware.*` / `nomarchy.services.*` (NixOS, `system.nix`),
|
||||
everything else `nomarchy.*` (HM, `home.nix`). Update the README tables
|
||||
when the surface changes.
|
||||
|
||||
## Feature design
|
||||
- **In-flake state:** any user-settable config gets a menu writer that
|
||||
lands it in `state.json` (`settings.*`), git-tracked. No
|
||||
`~/.local/state`, no side files. Instant-effect where possible
|
||||
(`--no-switch` + flip the service; the service reads the *live* working
|
||||
tree at start — the night-light `ExecCondition` pattern). Rebuild-baked
|
||||
values graduate via `mkDefault` reads of the settings key.
|
||||
- **Toggle vs package:** a `nomarchy.*` toggle only when there's real
|
||||
config behind it (units, groups, udev, firewall). A bare package goes
|
||||
in the downstream template's `home.packages` — opt-out is deleting the
|
||||
line.
|
||||
- **Opt-in features** ship a commented example in
|
||||
`templates/downstream/home.nix` or `system.nix`. That template is the
|
||||
single source of truth for machine files: `nomarchy-install` copies it
|
||||
and patches install-time values only (never a thinner second catalog).
|
||||
- **Menu:** new entries go in the right submenu (Tools › / System ›; root
|
||||
stays six entries), end lists with the shared `↩ Back`, self-gate on
|
||||
the feature's availability, and add the direct
|
||||
`SUPER+CTRL+<mnemonic>` bind in `keybinds.nix` (single source — it
|
||||
feeds both Hyprland and the SUPER+? cheatsheet).
|
||||
- **Waybar:** new indicators self-gate (hidden when irrelevant), use
|
||||
named `writeShellScriptBin`s on PATH (so static configs can exec them
|
||||
by bare name), and are added to **both** the generated `waybar.nix`
|
||||
config **and** every `waybar.jsonc` whole-swap — summer-day, summer-night,
|
||||
executive-slate, executive-ivory, boreal, boreal-dawn, kiln and
|
||||
kiln-clay (the parity rule).
|
||||
- **Theming:** every new visual surface consumes the palette from the
|
||||
state JSON. There is no second renderer to keep in sync — add the key
|
||||
to the JSON, consume it in the module.
|
||||
|
||||
## Testing
|
||||
- docs/TESTING.md is canonical; LOOP.md's ladder (V0–V3) sets the
|
||||
required tier. Cheap first: `nix flake check --no-build`, `bash -n`,
|
||||
`py_compile`.
|
||||
- Prefer a permanent `checks.*` runNixOSTest over a one-off manual poke;
|
||||
reusable recipes (headless Hyprland with software GL, QMP screenshots,
|
||||
udev-event fakes) are indexed in MEMORY.md and demonstrated by the
|
||||
existing checks (`distro-id`, `hardware-toggles`,
|
||||
`battery-charge-limit`).
|
||||
- The honesty rule: report exactly what you verified and at which tier.
|
||||
|
||||
## Git
|
||||
- `main` is development (direct commits, pushed); `v1` is the release
|
||||
pointer — **human-only, fast-forward-only, never touched by agents**.
|
||||
- Commit style: `feat|fix|test|docs|chore(scope): summary`, body with
|
||||
what/why + verification tier + what remains. Bookkeeping (`agent/`
|
||||
updates) rides in the same commit as the change.
|
||||
- `flake.lock` moves only when the task *is* a lock bump. The release
|
||||
inputs stay within their pinned release branches; the `nixpkgs-unstable`
|
||||
input (#134, feeds `unstable.*`) tracks `nixos-unstable` and bumps
|
||||
together with the rest — never alone, never `nix flake update`.
|
||||
112
agent/DELEGATION.md
Normal file
112
agent/DELEGATION.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Delegation — capability tiers, roles, token economy
|
||||
|
||||
How to spend model capacity in this repo, for **any** agent harness.
|
||||
Tasks are matched to capability *tiers*, not vendor model names; each
|
||||
harness maps tiers to its own models (table at the bottom). GOALS says
|
||||
what to build, CONVENTIONS how to write it — this says who does which
|
||||
part.
|
||||
|
||||
## The dividing line: evidence vs judgment
|
||||
|
||||
Cheap models gather evidence; they never make verification claims or
|
||||
design decisions. The following always stay on the **frontier** tier and
|
||||
are never delegated downward:
|
||||
|
||||
- deciding what to test and the regression scope (VERIFICATION.md)
|
||||
- interpreting an ambiguous or flaky failure
|
||||
- viewing screenshots and judging visual quality — aesthetic judgment is
|
||||
exactly what small models do badly, and it's load-bearing here
|
||||
- writing non-trivial Nix (module structure, overlays, cross-cutting
|
||||
refactors)
|
||||
- the final diff review and report
|
||||
|
||||
Product calls ("finish vs quarantine", promoting PROPOSED items) sit
|
||||
above even that: they belong to the human.
|
||||
|
||||
## Tiers
|
||||
|
||||
| Tier | Best for | Delegate to it? |
|
||||
|------|----------|-----------------|
|
||||
| **light** | bulk mechanical: search, summarize, audit sweeps, running the harness | freely — never for judgment |
|
||||
| **standard** | well-specified scoped edits, routine research | when the spec is already written |
|
||||
| **frontier** | design, novel code, ambiguous failures, taste | this is the loop's own tier |
|
||||
|
||||
Reasoning effort: default moderate; go maximum only for the hardest
|
||||
calls. A frontier parent may spawn a frontier child for one hard call.
|
||||
Work above your tier gets returned, not attempted.
|
||||
|
||||
## Standing roles
|
||||
|
||||
Two mechanical roles exist for any harness that supports subagents
|
||||
(Claude Code implementations: `.claude/agents/`; other harnesses
|
||||
implement the same contracts in their own format):
|
||||
|
||||
- **scout** (light, read-only): locate where things are defined, map
|
||||
which files touch a subsystem, scan build logs / serial output for
|
||||
error lines, docs-vs-code drift sweeps. Reports facts with paths and
|
||||
line numbers, quotes the minimum snippet, says "not found" plainly —
|
||||
never guesses, never recommends.
|
||||
- **runner** (light, executes): builds, VM boots, screenshot capture,
|
||||
the scripted `tools/` checks. Headless and unattended, every wait
|
||||
bounded by a timeout. Returns commands, exit codes, wall time, and
|
||||
artifact paths — never marks anything passed or verified; the caller
|
||||
makes the verification claim.
|
||||
|
||||
Use them for pure information-gathering or pure execution instead of
|
||||
pulling bulk (log files, wide scans) into the main context. When a
|
||||
result surprises you, spot-check it yourself before building on it —
|
||||
cheap models are allowed to be wrong about hard things, which is
|
||||
precisely why they're not allowed to make claims.
|
||||
|
||||
## Economy rules
|
||||
|
||||
- **Only delegate when writing the spec is cheaper than doing the
|
||||
work** — a spawned agent starts cold and must re-derive context. A
|
||||
one-file read is cheaper done directly.
|
||||
- **Brief every child cold; point at the spec, don't restate it.** "The
|
||||
spec is in `agent/BACKLOG.md` #NN — implement it" plus only the
|
||||
*constraints* (scope files, branch, no-VM, no-push).
|
||||
- **Match the tier to the task, not the prestige.** An item whose spec
|
||||
is already written (exact files, exact fixes) is standard-tier work;
|
||||
reserve frontier children for genuine multi-step reasoning. This is
|
||||
the single biggest saving.
|
||||
- The strong model writes the spec, reviews the result, and owns the
|
||||
commit.
|
||||
|
||||
## Fanning out parallel work (V0/V1, no VM)
|
||||
|
||||
When several NEXT items are independent and don't need the VM, spread
|
||||
them across worktree-isolated subagents in parallel:
|
||||
|
||||
- **Disjoint file lanes.** Partition items so no two agents touch the
|
||||
same file (map the touched files first). If two items must share a
|
||||
file (README, flake.nix, rofi.nix), give both to one agent or keep
|
||||
one for yourself.
|
||||
- **Isolation + you own landing.** Each agent works in an isolated
|
||||
worktree, commits to its own branch, and **never pushes or touches
|
||||
`main`/`v1`**. You review each diff, cherry-pick onto `main`, and do
|
||||
the bookkeeping — a single landing agent can't race itself. Clean up
|
||||
worktrees and branches after landing.
|
||||
- **Lean on scriptable checks as primary evidence.** Where a
|
||||
deterministic `tools/` check or `checks.*` guard already proves the
|
||||
property, that near-free run *is* the V0/V1 evidence.
|
||||
- **Batch V2 at the end, once.** Delegated visual/behavioural items come
|
||||
back "V2 pending"; collect the landed changes and do **one** VM pass
|
||||
covering all of them — the VM render + screenshot review is the most
|
||||
expensive step in the loop; amortise it.
|
||||
- **Re-verify on `main`, but leanly.** After landing, confirm the
|
||||
agent's V0/V1 on the merged tree with a targeted build, not a full
|
||||
re-run. Trust-but-spot-check scales; blind re-running doesn't.
|
||||
|
||||
The judgment list above still holds: *you* review every diff before it
|
||||
lands.
|
||||
|
||||
## Per-harness model mapping
|
||||
|
||||
| Tier | Claude Code |
|
||||
|------|-------------|
|
||||
| light | `haiku` |
|
||||
| standard | `sonnet` |
|
||||
| frontier | the session's top model (`opus` and up) |
|
||||
|
||||
Other harnesses: add a column when one is actually used on this repo.
|
||||
60
agent/GOALS.md
Normal file
60
agent/GOALS.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Goals — what "done" looks like for Nomarchy
|
||||
|
||||
The north star every loop iteration serves. When two options conflict,
|
||||
the earlier pillar wins. Product *themes* toward a **v1.0** ship (day-2
|
||||
confidence, default identity, release bar) live in
|
||||
[`docs/VISION.md`](../docs/VISION.md) — still subordinate to these pillars.
|
||||
|
||||
## The four pillars (in priority order)
|
||||
|
||||
1. **Rock-stable.** A workstation you never fight. Everything is a NixOS/HM
|
||||
generation — atomic, rollbackable, never partial. `nix flake check` is
|
||||
green at every commit on `main`. Regressions are caught by the VM-test
|
||||
suite (`checks.*`), not by users. `v1` only ever advances after human
|
||||
on-hardware QA.
|
||||
2. **Reproducible, with zero hidden state.** The downstream flake checkout
|
||||
*is* the machine. All user-settable config is menu-writable into the
|
||||
git-tracked state file (`state.json` `settings.*`) — never
|
||||
`~/.local/state`, never `~/.config` side files. Re-cloning your flake
|
||||
reproduces the machine, settings and all.
|
||||
3. **Effortless to configure.** The user never has to learn Nix. Every
|
||||
common knob is reachable from `nomarchy-menu` (SUPER+M); the menu is an
|
||||
ergonomic writer for the flake. Where a toggle can take effect without a
|
||||
rebuild, it must (`--no-switch` + flip the running service).
|
||||
4. **Beautiful.** One JSON themes the entire desktop coherently — Hyprland,
|
||||
Waybar, Kitty, btop, rofi, GTK/Qt, boot splash, greeter. Every new
|
||||
surface follows the palette. Informative, self-gating Waybar modules
|
||||
(they hide when irrelevant). No unthemed corner survives contact with
|
||||
the theme switcher.
|
||||
|
||||
## Quality bars (non-negotiable)
|
||||
|
||||
- **The honesty rule** (docs/TESTING.md): for anything visual, "the Nix
|
||||
evaluates" is not "it renders". Verify at the highest tier you can reach
|
||||
(see LOOP.md's verification ladder) and *state the tier you reached*.
|
||||
Never claim a check you didn't run.
|
||||
- **Parity rule:** any module added to the generated Waybar config must
|
||||
also be added to the summer-day/night `waybar.jsonc` whole-swaps.
|
||||
- **Menu placement:** new menu entries go in the right category submenu
|
||||
(Tools › / System ›), with a direct `SUPER+CTRL+<mnemonic>` bind and
|
||||
self-gating where applicable. The root picker stays six entries.
|
||||
- **Opt-in features** ship a commented example in
|
||||
`templates/downstream/{home,system}.nix`.
|
||||
- **Option surface discipline:** a toggle exists only when there is real
|
||||
config behind it. Bare package installs go in the template's
|
||||
`home.packages` (opt-out = delete the line), never a `nomarchy.apps.*`.
|
||||
|
||||
## Non-goals (do not drift into these)
|
||||
|
||||
- **No binary cache.** Compile-from-source is a deliberate values call;
|
||||
automation targets the config/lock channel, not artifact distribution.
|
||||
- **No second theming pipeline.** The dispatcher owns menu structure; the
|
||||
renderer (rofi) stays swappable. No GTK4 launcher.
|
||||
- **No nixpkgs major bump on `main`.** Lock updates stay within the pinned
|
||||
release branch; a release jump is a deliberate `v2`, hand-edited by the
|
||||
maintainer (the last attempt was discarded over a Hyprland OOM — see
|
||||
agent/MEMORY.md).
|
||||
- **No repo-wide reformat.** The `.nix` files use deliberate aligned
|
||||
hand-formatting; adopting a formatter is an open maintainer decision
|
||||
(BACKLOG.md § Decisions), not a cleanup.
|
||||
- **No multi-DE.** Hyprland is the desktop.
|
||||
633
agent/HARDWARE-QUEUE.md
Normal file
633
agent/HARDWARE-QUEUE.md
Normal file
@@ -0,0 +1,633 @@
|
||||
# Hardware queue — V3 checks only a human can run
|
||||
|
||||
Everything shipped at V1/V2 whose final verification needs real hardware.
|
||||
Agents **append** (newest at the bottom of a section) with exact steps;
|
||||
Bernardo runs them and either checks off (`[x]` + date + verdict) or files
|
||||
the failure as a NOW bug in BACKLOG.md. Checked-off entries are **pruned**
|
||||
on the next sync sweep (LOOP.md §5) — outcomes live in the journal/ROADMAP;
|
||||
git history is the archive. Machines: the **AMD dev box** (Ryzen AI
|
||||
laptop: AMD + fingerprint + NPU), the **Latitude 5310/5410** (Intel QA),
|
||||
the **T14s** (webcam case).
|
||||
|
||||
## Suggested order (sweep 2026-07-15)
|
||||
|
||||
Run these first when you have a laptop session (AMD dev box unless noted):
|
||||
|
||||
| # | Item | Why first |
|
||||
|---|------|-----------|
|
||||
| 1 | **#115** suspend-then-hibernate | Just shipped; quick path is minutes (battery vs AC + `nomarchy-suspend`) |
|
||||
| 2 | **#127** the brick's *cause* (reopened) | Wake path fixed + fully verified 2026-07-16; cause unknown — start at the dead Ctrl+Alt+F3, not the wake path |
|
||||
| 3 | **Docking recovery round 8** + headphone jack-follow | Same dock session; relogin once, then undock ×5 + jack |
|
||||
| 4 | **#104** airplane mode | Fast radio smoke after `nomarchy-home` |
|
||||
| 5 | **#101** charge-limit USB-C burst | Dock plug/unplug storm; pairs with #3 |
|
||||
| 6 | **Parallel fingerprint** hyprlock/greeter residual | sudo path already PASSED 2026-07-14 |
|
||||
| 7 | **#95** Kitty-only on Acer | Validates terminal stack on oldest GPU |
|
||||
| 8 | **#123** / **#124** install bake + flake pin | Next full reinstall window |
|
||||
|
||||
Everything else below stays open; order is convenience, not a gate.
|
||||
|
||||
## Any machine (dev box is fine)
|
||||
|
||||
- [ ] **Theme pairs V3 — the four new themes on real GL** — after pulling
|
||||
the pair commits: `nomarchy-state-sync apply <slug>` for each of
|
||||
boreal-dawn, executive-ivory, kiln, kiln-clay. Pass = all of:
|
||||
(1) the bar renders true — boreal-dawn's frosted pills actually blur
|
||||
what's behind them (softGL VM can't prove blur), kiln's plank shows
|
||||
its copper edge, no font-fallback boxes (GeistMono / JetBrainsMono /
|
||||
CaskaydiaCove); (2) wallpaper paints at native res, no banding on the
|
||||
kiln ember gradient; (3) Kitty + btop under each theme — btop colors
|
||||
match (the VM never started the GL terminal; btop.theme is
|
||||
file-asserted only); (4) boreal-dawn app launcher (SUPER+Space) lays
|
||||
out its icon grid; (5) an nm-applet/tray menu is readable under all
|
||||
four (the `*`-reset counter-rules). If a VM-rendered preview looks
|
||||
off in the theme-picker grid next to the hardware-captured ones,
|
||||
recapture that preview on hardware into themes/<slug>/preview.png.
|
||||
- [ ] **Light-theme contrast refits (2026-07-18 report)** — under any
|
||||
light theme: (1) with two workspaces open, the inactive number is
|
||||
readable on the bar (subtext, not the washed-out grey); (2) hover a
|
||||
notification popup — background stays the theme's light card with a
|
||||
faint tint, text readable (the swaync default's dark
|
||||
.notification-default-action hover is now overridden); close
|
||||
button matches the palette. VM-verified with a hover probe under
|
||||
summer-day; this is the on-hardware confirmation of the report.
|
||||
- [ ] **Auto-theme pair flip on hardware** — set
|
||||
`settings.autoTheme.{day,night}` to one of the new pairs (e.g.
|
||||
kiln-clay / kiln), set sunset (or sunrise) a few minutes ahead via
|
||||
Look & Feel › Auto theme (the time edit itself rebuilds — since the
|
||||
exact-time revision the timer fires AT the configured minute, no
|
||||
15-min poll), let the timer flip once each way. Pass = (1)
|
||||
`systemctl --user list-timers nomarchy-auto-theme` shows the two
|
||||
configured times as the trigger; (2) the switch lands within ~a
|
||||
minute of the configured time; (3) the whole desktop switches in
|
||||
one generation, and nothing leaks from the previous theme — fonts,
|
||||
rounding, terminal opacity all reset (the per-theme appearance
|
||||
block), wallpaper follows; (4) with autoCommit on, `git -C
|
||||
~/.nomarchy log` shows an `apply theme` commit and state.json is
|
||||
not left dirty (the 0.5.1 pathspec fix).
|
||||
- [ ] **#148 dock-intent enforcement without auto-profiles (dev box, docked)** —
|
||||
after pulling the #148 commit: **log out and back in first** — the
|
||||
watcher is `exec-once`, so `nomarchy-home` alone leaves the old one
|
||||
running. Then, docked clamshell (lid closed, panel off, dockMode set
|
||||
from the Display menu or a prior auto-dock), run `nomarchy-home` or a
|
||||
bare `hyprctl reload`. **Pass:** eDP-1 re-lights for at most ~1–2 s and
|
||||
goes back off by itself; `journalctl -t nomarchy-display-watch
|
||||
--since -5min` shows `dock-intent=true … action=re-dock`; a "Docked —"
|
||||
toast appears. Same relogin answers #149's question (does the Waybar
|
||||
unit still race Hyprland IPC on a cold relogin without linger?).
|
||||
**Fail:** the panel stays lit inside the shut lid (the #148 symptom).
|
||||
- [ ] **#115 suspend-then-hibernate (laptop with hibernate/resume wired)** —
|
||||
after system rebuild: Preferences shows **Suspend then hibernate (on)**
|
||||
(hidden if `CanHibernate=no`). Confirm:
|
||||
`grep -E 'HibernateDelaySec|HibernateOnACPower' /etc/systemd/sleep.conf`
|
||||
→ `1h` / false; `grep HandleLidSwitch /etc/systemd/logind.conf` →
|
||||
undocked s2h, ExternalPower=suspend, Docked=ignore.
|
||||
**Quick path:** on battery, `nomarchy-suspend` then
|
||||
`systemctl list-jobs` / journal should show suspend-then-hibernate
|
||||
(not plain suspend). On AC, same command → plain suspend.
|
||||
**Full path (optional, long):** battery, lid close or idle past 15m,
|
||||
leave ~1h+, open → single LUKS unlock (encrypted) or lock screen
|
||||
(unencrypted), session intact. Toggle **off** + rebuild → plain
|
||||
suspend only. **Pass:** battery s2h + 1h hibernate resume works;
|
||||
AC never plans hibernate; no row without hibernate support.
|
||||
- [ ] **#104 airplane mode radios (any laptop with Wi-Fi + BT)** —
|
||||
after `nomarchy-home` + waybar restart: System › **Airplane mode (off)**
|
||||
(or SUPER+CTRL+R). **Pass on:** Wi-Fi and Bluetooth both drop (nm-applet /
|
||||
blueman confirm); Waybar shows a plane glyph; click glyph or menu again
|
||||
restores **only** the radios that were on before. If Wi-Fi was already
|
||||
off, it must stay off after disengage. Glyph must **not** appear when
|
||||
airplane is off.
|
||||
- [ ] **#96 battery-limit row on threshold-less firmware (Acer M5-481T)** —
|
||||
on the Acer (live or installed session with the commit carrying this
|
||||
entry): open System (`SUPER+CTRL+I`). **Pass:** a "Battery limit" row
|
||||
IS listed (battery present); picking it shows the notification
|
||||
explaining the firmware exposes no charge-stop control (not a raw
|
||||
error, and the row never silently disappears). On the T14s the row
|
||||
must still open the working preset picker.
|
||||
- [ ] **#101 charge-limit service survives USB-C power-event bursts**
|
||||
(round 2) — round 1 FAILED on TuringMachine 2026-07-13 18:16: a
|
||||
*spaced* storm (each run finished before the next AC event) landed 5
|
||||
successful starts in 10s → `start-limit-hit`, unit marked failed,
|
||||
doctor badge — although every run succeeded. Fixed by exempting the
|
||||
unit from start rate limiting (`StartLimitIntervalSec=0`) + a
|
||||
spaced-storm guard in `checks.battery-charge-limit` (the commit
|
||||
carrying this entry). **Re-check:** after pulling/rebuilding this
|
||||
commit, `sudo systemctl reset-failed nomarchy-battery-charge-limit
|
||||
.service`, then plug/unplug the powered USB-C dock several times in
|
||||
quick succession. **Pass:** `systemctl is-failed
|
||||
nomarchy-battery-charge-limit.service` stays false, `Result=success`,
|
||||
BAT0's `charge_control_end_threshold` still matches the limit, and the
|
||||
journal has no new `start-limit-hit`/`Failed with result` lines.
|
||||
- [ ] **Docking recovery round 8 (undock panel restore, 2026-07-14)** —
|
||||
round 7 **FAILED** on the AMD dev box (Bernardo, 5–6 consecutive
|
||||
unplugs, none recovered). Its whole diagnosis was wrong, and the way it
|
||||
was wrong is the lesson: round 6 read `result=enable-timeout` ×2 +
|
||||
`result=ok` ×1 as an intermittent race and retried the keyword. But the
|
||||
keyword is **inert, not raced** — with ZERO enabled outputs (panel
|
||||
disabled by the dock, external gone) Hyprland 0.55.4 accepts
|
||||
`keyword monitor` (prints `ok`, exits 0) and never flushes it until a
|
||||
DRM event arrives. Retrying an inert command 25×/poll for 6 polls just
|
||||
bought 30s of black screen. Every `result=ok` in that journal was
|
||||
**Bernardo plugging the cable back in** — his hotplug flushed the queued
|
||||
rule and the next poll took the credit. Probed live 2026-07-14: keyword
|
||||
inert across 4s and 5s in two runs, `dispatch forcerendererreload` also
|
||||
inert, and only `hyprctl reload` worked — 99ms and 289ms, cable out.
|
||||
The transition now escalates to `reload` when the keyword proves inert,
|
||||
re-asserts the rule, and restores per-device keyboard layouts (a reload
|
||||
drops runtime `device[…]:kb_layout` keywords).
|
||||
**Already proven on hardware** (2026-07-14, two real unplugs invoking
|
||||
the fixed `nomarchy-display-transition undock eDP-1` directly with the
|
||||
watcher paused): panel on and workspaces 1–3 home in 1.8s, journal
|
||||
`keyword=inert escalate=reload` → `enable=via-reload` → `result=ok`; and
|
||||
the keyboard-restore path with a Logitech K400 whose receiver is in the
|
||||
laptop — it kept its remembered `us` across the undock, while a control
|
||||
`hyprctl reload` with no restore dropped it to the session's `gb`
|
||||
(so `restore_keyboards` is load-bearing, not insurance).
|
||||
**What is NOT proven, and is this round:** the *watcher-driven* path
|
||||
(the running watcher calls the transition by baked store path, so it
|
||||
only picks the fix up at relogin — the round-5 trap), and repetition.
|
||||
**Re-check:** relogin (mandatory — see below), dock, then unplug the
|
||||
cable **at least five times**, plus once with the lid shut.
|
||||
**Pass:** the panel comes back every time with workspaces intact, and
|
||||
`journalctl --user
|
||||
-t nomarchy-display-transition -t nomarchy-display-watch --since
|
||||
'-10 min'` shows `result=ok` for each undock with **no**
|
||||
`result=enable-failed`. `keyword=inert escalate=reload` on every undock
|
||||
is EXPECTED — that is the fix firing, not a fault. A `result=ok`
|
||||
**without** a preceding `keyword=inert` line means the keyword flushed
|
||||
on its own and the reload was never needed: interesting, worth
|
||||
reporting, still a pass.
|
||||
- [ ] **Headphone jack-follow (last open count from rounds 4–6,
|
||||
2026-07-14)** — rounds 4–6 opened five counts on the AMD dev box; four
|
||||
are now **confirmed fixed on hardware** by round 6 (Bernardo): wallpaper
|
||||
on the external, automatic dock mode, audio reaching the monitor, and
|
||||
the external keyboard prompting **exactly once** (was four times). The
|
||||
undock panel is round 7 above. This is the one count **never tested**:
|
||||
plugging headphones had stopped moving audio to them, because a pinned
|
||||
`default.configured.audio.sink` outranks the priority rules, which kills
|
||||
jack-follow on UCM cards where the headphones are their own sink (fixed
|
||||
in ce480f3's lineage, unverified). **Re-check:** docked and undocked,
|
||||
plug and unplug headphones — audio must move to them and back each time,
|
||||
and `journalctl --user -t nomarchy-dock-audio` must show `trigger=jack
|
||||
selected=…` on each plug. Worth folding into the round 7 session: the
|
||||
dock is already in hand and the two don't interfere.
|
||||
|
||||
**Historical detail for the four settled counts** (kept only until
|
||||
round 7 closes; the fixes themselves are in the log):
|
||||
— round 4 was run on the AMD dev box and **failed on four counts**
|
||||
(Bernardo): no wallpaper on the external monitor, no automatic dock
|
||||
mode (System › Display had to be used by hand), audio staying on the
|
||||
laptop speakers while docked, and one dock + one external keyboard
|
||||
asking for a layout **four times**. Separately, plugging headphones had
|
||||
stopped moving audio to them. Causes and fixes in the commit carrying
|
||||
this entry: the display watcher's `socat -T 1` closed Hyprland's IPC
|
||||
socket after a second of idle, so a dock plugged into an idle desktop
|
||||
lost `monitoradded` in the reconnect gap (wallpaper + dock mode + audio
|
||||
all hang off that one event); Hyprland lists every key-capable evdev
|
||||
node as a "keyboard"; and a pinned `default.configured.audio.sink`
|
||||
outranks the priority rules, which kills jack-follow on UCM cards where
|
||||
the headphones are their own sink. **Re-run round 4 below in full**,
|
||||
plus: (a) leave the desktop untouched for ~30s, then plug the dock —
|
||||
wallpaper, dock mode, and audio must all follow with no manual step;
|
||||
(b) the external keyboard must prompt **exactly once**, and never for
|
||||
the monitor or the lid/power buttons; (c) docked and undocked, plug and
|
||||
unplug headphones — audio must move to them and back each time.
|
||||
`journalctl --user -t nomarchy-display-watch` should show
|
||||
`outputs-changed added=…`, and `-t nomarchy-dock-audio`
|
||||
`trigger=jack selected=…` on a headphone plug.
|
||||
|
||||
**Round 5 was itself run and failed (2026-07-14); this entry now covers
|
||||
round 6.** Two of the four reports were never actually testing the fix:
|
||||
both watchers are Hyprland `exec-once`, so `nomarchy-home` swaps the
|
||||
config but leaves the *old* processes running — only `nomarchy-dock-audio`
|
||||
(a user service) had picked up new code. **Relogin is mandatory before
|
||||
testing either watcher**; confirm with `tr '\0' '\n' < /proc/$(pgrep -f
|
||||
bin/nomarchy-display-profile-watch)/cmdline | sed -n 2p` and grep the
|
||||
script for `outputs-changed`. The other two were real and are fixed in
|
||||
the commit carrying this entry: automatic dock mode did not exist at all
|
||||
(the watcher auto-*undocked* but only ever matched display profiles on
|
||||
plug, and this box has none saved with `displayProfileAuto` unset, so
|
||||
nothing happened — `Dock mode` was interactive-only), and the BenQ's
|
||||
audio was unreachable because WirePlumber had `alsa_card.pci-0000_c3_00.1`
|
||||
pinned to `pro-audio`, whose raw `pro-output-N` sinks have no ports and
|
||||
no routing. The BenQ's own USB card is input-only (all profiles
|
||||
`sinks: 0`), so DisplayPort is the only path to its speakers.
|
||||
**Also check:** no display profile is saved, yet plugging the BenQ must
|
||||
now dock by itself; and `journalctl --user -t nomarchy-dock-audio` shows
|
||||
`repaired-card=alsa_card.pci-0000_c3_00.1 profile=HiFi` on the first
|
||||
plug, after which audio reaches the monitor's speakers. A saved+matching
|
||||
display profile must still win over auto-dock.
|
||||
- [ ] **Docking recovery round 4 (#100, closed-lid BenQ sequence)** — after
|
||||
updating to the commit carrying this entry, run `nomarchy-home` and
|
||||
relogin. With the lid open, put visible windows on at least workspaces
|
||||
1, 2, and 3 and start audio; plug the BenQ GW2790QT. **Pass on plug:**
|
||||
wallpaper appears, audio toasts and moves to a BenQ/GPU HDMI sink, and
|
||||
`journalctl --user -t nomarchy-dock-audio -n 10` shows
|
||||
`trigger=monitoradded` plus `selected=…` (not only a card event). Open
|
||||
System › Display › **Dock mode**. **Pass on dock:** every workspace and
|
||||
window is on the BenQ, focus follows, eDP is disabled in `hyprctl
|
||||
monitors all -j`, and `systemd-inhibit --list` shows Nomarchy holding
|
||||
`handle-lid-switch` for “Safe dock/undock display transition”; there is
|
||||
no dangling laptop workspace and no application exits. Close the lid;
|
||||
audio/session must stay awake. While still docked, manually choose the
|
||||
speakers and change volume/mute: it must stay on speakers. Unplug the
|
||||
BenQ **with the lid still closed**, wait 10 seconds, then open it.
|
||||
**Pass on undock:** no suspend/crash/hard reset, eDP is already active,
|
||||
workspaces 1/2/3 and their windows are intact on it, and the inhibitor
|
||||
disappears only after lid-open. `journalctl -b --since '-5 min'` must
|
||||
contain no cable-removal `systemd-logind: Suspending...`. Finally close
|
||||
the undocked lid normally: the laptop must suspend, proving policy was
|
||||
restored. Wake it, replug the BenQ: that fresh plug must move audio back
|
||||
to HDMI (manual speakers do not survive a physical replug). If any step
|
||||
fails, capture `journalctl --user -t nomarchy-display-watch -t
|
||||
nomarchy-display-transition -t nomarchy-dock-audio --since '-10 min'`,
|
||||
`systemd-inhibit --list`, `hyprctl monitors all -j`, `hyprctl workspaces
|
||||
-j`, and `pactl --format=json list sinks` before changing state again.
|
||||
- [ ] **Parallel fingerprint-or-password on the real reader** (AMD dev
|
||||
box, 2026-07-12) — **sudo path PASSED 2026-07-14** (Bernardo, dev box,
|
||||
gen 422, `fingerprint.pam = true`, parallel default): `sudo -k true`
|
||||
showed ONE prompt, the typed password worked, touching the sensor
|
||||
instead worked, and wrong-finger ×3 fell back to password.
|
||||
**Still open:** hyprlock and the greeter must accept both factors and
|
||||
not wedge on a leftover prompt after a fingerprint win (known cosmetic
|
||||
quirk of the hack — pthread_cancel'd prompt); `fingerprint.parallel =
|
||||
false` must restore the old sequential behavior.
|
||||
**Do NOT "check password still works with `systemctl stop fprintd`"** —
|
||||
that check was in this item and it is a trap that cannot fail. fprintd
|
||||
is D-Bus activated, so PAM asks D-Bus and D-Bus starts it straight back
|
||||
up: Bernardo ran it and *the fingerprint still authenticated*, which is
|
||||
the tell that the daemon never stayed down. It proves nothing about a
|
||||
dead reader. The property it was reaching for is structural anyway —
|
||||
password is `auth sufficient` at order 11700, independent of the fprintd
|
||||
rule at 11400, so no fprintd failure can gate it (verify by eval:
|
||||
`nix eval --raw <flake>#nixosConfigurations.<host>.config.security.pam
|
||||
.services.sudo.text`), and checks.hardware-toggles asserts that shape in
|
||||
the VM with no reader at all. If someone genuinely wants it on hardware
|
||||
it needs `systemctl mask` + stop, and masking a working machine's
|
||||
authentication daemon to re-prove an evaluated invariant is a bad trade.
|
||||
- [ ] **#55 fingerprint enroll on real reader** — with
|
||||
`nomarchy.hardware.fingerprint.enable` and a physical reader: System ›
|
||||
Fingerprint › Enroll a finger; List shows it; Verify succeeds.
|
||||
Enroll/List/Verify verified on hardware 2026-07-14 (T14s); the
|
||||
remaining unknown is a reader-less machine's self-gate.
|
||||
- [ ] **fingerprint + auto-login toggles on hardware** (this commit) — on the
|
||||
T14s, after removing the pinning lines from `~/.nomarchy/system.nix`
|
||||
(`greeter.autoLogin`, `hardware.fingerprint.pam`):
|
||||
1. System › Fingerprint shows **Fingerprint (on)** (state already true).
|
||||
Toggle it off → terminal opens, sudo system rebuild + home switch →
|
||||
toast "Fingerprint off". Expect: sudo now refuses the finger and asks
|
||||
only for a password; the lock screen (SUPER+CTRL+L) shows no
|
||||
"or scan your finger" line. Toggle back on → both return.
|
||||
2. With fingerprint OFF and no finger enrolled, the toggle must REFUSE
|
||||
with "Enroll a finger first" and write nothing.
|
||||
3. System › Auto-login shows **(on)**. Toggle off → sudo rebuild →
|
||||
reboot → tuigreet asks, and accepts password OR finger (fingerprint
|
||||
on). Toggle back on → reboot → straight to the session, no prompt.
|
||||
Expected throughout: the two are independent, and auto-login on means
|
||||
no boot prompt regardless of the fingerprint switch.
|
||||
- [ ] **#60 non-BAT* battery name (if available)** — on a machine whose
|
||||
system battery is **not** named `BAT*` (e.g. `CMB0`): confirm
|
||||
charge-limit oneshot writes the threshold, System › Battery limit
|
||||
and Power profile rows appear, Waybar power-profile module shows,
|
||||
doctor charge-limit section runs. On `BAT0`-only machines this is
|
||||
a no-op (charge-limit re-apply passed on `BAT0` — Latitude
|
||||
2026-07-10).
|
||||
- [ ] **btop theme fidelity (#52 residual)** — softGL theme-shot cannot
|
||||
open a real terminal UI, so the hand `btop.theme` assets were only
|
||||
guest-file asserted (main_bg / inactive_fg keys) + desktop/bar
|
||||
rendered for rose-pine, everforest, summer-night, vantablack,
|
||||
catppuccin. On a real session: `nomarchy-state-sync apply <slug>`
|
||||
then `btop` and confirm backgrounds/text match the theme (esp.
|
||||
rose-pine Dawn light bg `#faf4ed`, vantablack near-black `#0d0d0d`,
|
||||
catppuccin Mocha `#1E1E2E`). One pass cycling those five is enough.
|
||||
- [ ] **SSH_AUTH_SOCK for GUI clients** — after relogin, launch a GUI git
|
||||
client (or `rofi`-launched terminal-less app) and confirm it reaches
|
||||
gpg-agent's SSH socket without an interactive shell parent.
|
||||
- [ ] **Notification inhibited-state glyph** (item 28 color-only sweep,
|
||||
iteration #69) — have an app hold a notification inhibitor (e.g. run
|
||||
a fullscreen video, or `busctl --user call org.freedesktop.Notifications
|
||||
… Inhibit`); the Waybar bell must switch to the muted **bell-off**
|
||||
(same glyph as DND), NOT the normal bell /. Then release it → the
|
||||
bell returns. Repeat on a whole-swap bar (summer-day/night use their
|
||||
own bell-off glyph, executive-slate/boreal use ). The glyph itself
|
||||
already renders (DND uses it); this only confirms swaync emits the
|
||||
`inhibited-*` class and the bar routes it.
|
||||
- [ ] **Keyboard layout cycle bind** — with a comma layout (e.g.
|
||||
`nomarchy.keyboard.layout = "us,de"`), SUPER+SHIFT+K cycles the
|
||||
focused keyboard's layout, the Waybar `` indicator follows, and
|
||||
the row shows in the SUPER+? cheatsheet. — 2026-07-04 attempt:
|
||||
no comma layout was configured, so the bind wasn't rendered (the
|
||||
gate working as designed, not a failure) — retest after setting a
|
||||
comma layout + rebuild + relogin. The SUPER+? no-op found en
|
||||
route was a real bug → BACKLOG item 26 (fix shipped; SUPER+?
|
||||
cheatsheet PASS on Latitude 2026-07-10).
|
||||
- [ ] **swaync readable on summer-day** (item 25 fix) — on summer-day
|
||||
after `home-update`: `notify-send "title" "body text"` shows
|
||||
readable body text; open the control centre (SUPER+N), hover a
|
||||
notification row and check the Clear button — all text legible
|
||||
(body/buttons now @text on tinted chips, not subtext/surface).
|
||||
- [ ] **Bar + rofi legible on flexoki-light** (item 27 fix) — switch to
|
||||
flexoki-light: every Waybar module readable (window title, tray
|
||||
row, dimmed inactive workspaces / muted volume visible-but-dim),
|
||||
rofi inputbar/alternate rows show text. Spot-check one dark theme
|
||||
(e.g. tokyo-night) for no visual regression in the same spots.
|
||||
- [ ] **Stub-bar themes got the generated bar** (item 28, 90a5104) —
|
||||
switch to catppuccin, lumon, nord and retro-82: each now shows
|
||||
the generated styled bar in its own palette (workspace pill,
|
||||
padded right cluster) instead of the old raw default Waybar.
|
||||
Their rofi menus should look unchanged.
|
||||
- [ ] **Back-audit spot-check** (item 24) — the keybinds cheatsheet
|
||||
(SUPER+? / root menu › Keybindings) now ends in ↩ Back and
|
||||
returns to the root picker; spot a couple of submenus (Display,
|
||||
VPN › Tailscale) still Back correctly after the audit pass.
|
||||
- [ ] **Per-device keyboard menu + hotplug restore (re-verify after in-flake
|
||||
graduation)** — update to the current `main`, run `nomarchy-home`, and
|
||||
relogin with an external keyboard already connected. Open System ›
|
||||
Keyboard: both the built-in and external boards must be listed with
|
||||
their current layouts. Pick a different layout for the external board;
|
||||
it must apply only there and persist in `settings.keyboard.devices`.
|
||||
Unplug/replug it: the saved layout must restore without another prompt
|
||||
and graduate into a `device{}` block on the next rebuild. Finally plug
|
||||
a previously unseen keyboard in after login: the automatic picker must
|
||||
appear once and remember that choice too.
|
||||
- [ ] **Snapshots restore + rollback exercise** (residual — the
|
||||
GUI/polkit half passed on hardware 2026-07-04, a47aa3a): in
|
||||
`sudo nomarchy-snapshots`, restore a single file (`undochange`)
|
||||
and walk a root-config rollback up to (or through) the
|
||||
typed-`yes` gate.
|
||||
- [ ] **Rollback menu (item 9b)** — after a couple of theme changes,
|
||||
menu › System › Rollback: recent desktop generations listed
|
||||
(newest marked current); picking an older one opens a terminal,
|
||||
activates it, and the theme visibly reverts; picking the newer one
|
||||
again rolls forward. The two System rows: Snapshots opens the
|
||||
snapshot flow, "boot an older generation (how)" fires an
|
||||
instruction notification.
|
||||
- [ ] **Open-a-file smoke (viewers + mime defaults, item 8)** — after
|
||||
`nomarchy-home`: from yazi/Thunar, open a PDF (→ zathura, themed to
|
||||
the palette), an image (→ imv, NOT GIMP), a video (→ mpv);
|
||||
`xdg-open .` on a directory → Thunar; a link → **Chromium**
|
||||
(`chromium-browser.desktop` — template default as of 2026-07-10).
|
||||
- [ ] **Update awareness** — with `nomarchy.updates.enable`, let the timer
|
||||
fire (or start the unit): indicator appears only when inputs are
|
||||
behind, notification only on count growth, click opens the upgrade
|
||||
flow.
|
||||
- [ ] **VPN menu live paths** — import a real WireGuard `.conf` and an
|
||||
`.ovpn` via System → VPN, toggle up/down (● / ○ state), and the
|
||||
Tailscale block: up/down + exit-node without sudo (operator grant).
|
||||
Exit-node rows now show "hostname — Country City" (2026-07-11,
|
||||
header-offset parsing): confirm Mullvad-style nodes carry their
|
||||
location, a locationless self-hosted node stays a bare hostname,
|
||||
and picking either still sets the node (`tailscale status` shows it).
|
||||
- [ ] **Printer menu** — with `nomarchy.services.printing`, System →
|
||||
Printers opens system-config-printer; add a printer, test page.
|
||||
- [ ] **GRUB UEFI ISO theme render** — boot the ISO on UEFI hardware:
|
||||
composed splash background, palette menu in the lower third, accent
|
||||
timeout bar.
|
||||
- [ ] **Visual theme pass** — live ISO: all six identity themes (bars) +
|
||||
the four authored rofi `.rasi` (nord/retro-82/lumon/kanagawa) look
|
||||
right, not just parse.
|
||||
- [ ] **Display profiles, slice a (item 15)** — declare two
|
||||
`nomarchy.displayProfiles` (e.g. docked disables eDP-1 + arranges
|
||||
the externals; undocked re-enables it), `home-update`, then:
|
||||
System › Display › Profiles lists them (● marks active); applying
|
||||
is instant (no rebuild) and survives a relogin (the baked
|
||||
overlay); "Base layout" restores; a menu resolution pick made
|
||||
earlier must NOT re-enable a profile-disabled panel after the
|
||||
next rebuild.
|
||||
- [ ] **Display profiles workspace pins, slice c (item 15)** — give the
|
||||
docked profile `workspaces = { "1" = "<ext>"; "9" = "eDP-1"; }`:
|
||||
applying the profile moves open workspaces 1/9 to those outputs
|
||||
immediately, new visits land there too, and the pins survive a
|
||||
relogin (baked `workspace` rules). Switching to a profile without
|
||||
pins leaves workspaces where they are (stale session pins are
|
||||
expected until reload/rebuild — noted in the applier).
|
||||
- [ ] **Display profiles auto-switch, slice b (item 15)** — same setup,
|
||||
then menu › Profiles › Auto-switch on: plugging the dock/monitor
|
||||
applies the matching profile within ~3s (toast names it);
|
||||
unplugging switches back; Auto-switch off stops that; with two
|
||||
profiles naming the same outputs, no flapping (ties do nothing).
|
||||
- [ ] **P2 retunes eyeball (item 28b)** — spot-check on top of the P1
|
||||
entry below: gruvbox/nord/lumon/everforest/retro-82/white dimmed
|
||||
text (inactive workspaces, muted volume) now visible-but-dim;
|
||||
latte + rose-pine(dawn) + summer-day warn/battery tint reads on
|
||||
the light bases; flexoki-light statuses are the deeper canonical
|
||||
600s; latte selected-row/alt-accent pink darker. Anything that
|
||||
lost its "dim" feel → reopen 28b.
|
||||
- [ ] **Retuned palettes eyeball (item 28b P1)** — switch through
|
||||
summer-day, flexoki-light, kanagawa, miasma: chips/menus/toasts
|
||||
now draw on a *raised* surface (summer-day's was slate-on-cream,
|
||||
kanagawa's near-black, miasma's pure black), secondary text
|
||||
(tooltips, fastfetch labels) is visible on summer-day +
|
||||
flexoki-light, kanagawa floats are lighter than the bg (upstream
|
||||
sumiInk4). Anything that reads worse than before → reopen 28b.
|
||||
- [ ] **Doctor bar tripwire (LATER item)** — with everything healthy
|
||||
the bar shows nothing; `systemctl --user start doomed`-style
|
||||
induced failure → within ~5 min a red appears (tooltip lists
|
||||
the ✖ lines), clicking opens the sheet in a terminal;
|
||||
reset-failed → it disappears on the next poll. Also visible on
|
||||
both summer bars.
|
||||
- [ ] **OCR region (LATER item)** — Tools › Capture › "OCR region →
|
||||
clipboard": select a region with visible text → toast reports
|
||||
the word count and `wl-paste` yields the text; Esc in slurp
|
||||
cancels silently; a text-free region toasts "No text recognized"
|
||||
without clobbering the clipboard.
|
||||
- [ ] **Look & Feel submenu (item 19)** — root menu: "Look & Feel"
|
||||
(replacing Theme, root still six rows) → Theme grid opens, Next
|
||||
wallpaper cycles instantly, Night light toggles (and is GONE
|
||||
from System). SUPER+T / SUPER+SHIFT+T direct binds unchanged.
|
||||
- [ ] **Launch-or-focus (item 17)** — uncomment the template's
|
||||
`nomarchy.launchOrFocus` firefox example (with firefox
|
||||
installed), `home-update` + reload: SUPER+B launches firefox
|
||||
when closed, FOCUSES the existing window when open (also from
|
||||
another workspace); with firefox removed the bind fires a
|
||||
"not installed" toast; the row shows under SUPER+?.
|
||||
- [ ] **Themed greeter + console (item 16)** — after `sys-rebuild` +
|
||||
logout: tuigreet renders in theme colors (dark container, accent
|
||||
border, themed prompt/time) and a raw tty (CTRL+ALT+F2) shows the
|
||||
theme's ANSI palette; on a LUKS machine the passphrase prompt
|
||||
follows too. Then switch theme + `sys-rebuild` + logout → the
|
||||
greeter follows the new palette. (tuigreet can't run under the
|
||||
VM harness — see MEMORY.md — so rendering is hardware-tier.)
|
||||
- [ ] **Color picker (item 13, final slice)** — SUPER+CTRL+P (or Tools ›
|
||||
Color picker): hyprpicker's zoom loupe appears; click a pixel →
|
||||
toast shows the hex and `wl-paste` yields it; Esc cancels with no
|
||||
toast. Row present in the SUPER+? cheatsheet.
|
||||
- [ ] **Low-battery toasts (item 13 slice)** — on battery, drain past
|
||||
25%: one "Battery low" toast; past 10%: a critical "Battery
|
||||
critical" toast that stays up until dismissed (swaync); plug in,
|
||||
drain again → it re-notifies; no repeat toasts while it just keeps
|
||||
draining. (Crossing logic VM-verified — this checks the real
|
||||
swaync rendering in a session.)
|
||||
|
||||
- [ ] **Audio opens in Amberol** (iteration #60, item 37) — after
|
||||
`nomarchy-home`: double-click an mp3/flac/ogg (or `xdg-open song.mp3`)
|
||||
→ it opens in Amberol, not mpv; video files still open in mpv.
|
||||
(`xdg-mime query default audio/mpeg` → io.bassi.Amberol.desktop.)
|
||||
- [ ] **rofi menu polish** (iteration #58, item 39 + #56/#57 34/40) —
|
||||
after `nomarchy-home`: (a) menu › System › Power profile shows a
|
||||
colored icon per profile (performance/balanced/power-saver), not
|
||||
just text — icons *render* (not blank); (b) every submenu's ↩ Back
|
||||
shows a single arrow, no double; (c) on this single-monitor box,
|
||||
System › Display → pick a resolution → Back returns to System
|
||||
(not back into the same resolution list).
|
||||
- [ ] **Screenshot annotation (satty)** (iteration #73) — after `nomarchy-home`:
|
||||
hit SUPER+SHIFT+Print (or Tools ▸ Capture ▸ Annotate region) → a region
|
||||
select (slurp) appears, then the `satty` UI opens in fullscreen with the
|
||||
screenshot loaded. Check that the UI draws on the current theme palette
|
||||
(tools colored properly) and hitting save places the screenshot in
|
||||
`~/Pictures/Screenshots/` while hitting copy places it in the clipboard.
|
||||
- [ ] **#76 no-swap Hibernate notify** (#76 itself is closed — the
|
||||
hibernate→resume power-cycle **PASSED on TuringMachine 2026-07-12**:
|
||||
Bernardo ran the full hibernate → power off → single LUKS unlock →
|
||||
session-restored cycle "flawlessly"; this notify check is the one
|
||||
remaining leftover) — on a **Nomarchy** machine installed
|
||||
with `swap = 0` (or temporarily `sudo swapoff -a` on one): `nomarchy-menu
|
||||
→ Power → Hibernate` must surface a desktop notification ("Couldn't
|
||||
hibernate — likely no swap is configured. See docs/MIGRATION.md →
|
||||
Enabling hibernation."), **not** a silent no-op. (`swapon` after, if you
|
||||
swapoff'd for the test.)
|
||||
- [ ] **Night-light geo mode (2026-07-11)** — in `home.nix` set
|
||||
`nomarchy.nightlight = { enable = true; latitude = "<lat>";
|
||||
longitude = "<long>"; }` (your real coordinates), `nomarchy-home`,
|
||||
relogin. Then: `systemctl --user status wlsunset` is running (and
|
||||
`hyprsunset` is NOT); after local sunset the screen visibly warms
|
||||
(or test by setting coordinates where it's currently night); the
|
||||
Waybar moon + menu toggle still flip it instantly and an *off*
|
||||
survives relogin (the ExecCondition gate on the swapped unit —
|
||||
the eval check proves the wiring, not the runtime gate). **Pass** =
|
||||
warm shift at the location's night + toggle/persistence intact.
|
||||
- [ ] **#103 live-ISO baseline apps actually launch** (this commit) — the
|
||||
half no headless check can reach: `checks.live-baseline-apps` proves the
|
||||
binaries and `.desktop` entries are in the exact HM generation the ISO
|
||||
ships, but not that a GUI app opens on real hardware (the agent may not
|
||||
drive a graphical VM, and the live ISO has no SSH to script one). On the
|
||||
**Acer M5-481T** (the machine that found this — its GPU is the oldest
|
||||
shipped, so it is the honest test), boot the new live ISO and, from the
|
||||
launcher only — no terminal, that's the point: open **Chromium**,
|
||||
**LibreOffice** (Writer), **Text Editor**, **Amberol**, **Snapshot**.
|
||||
**Pass** = all five are *listed in the launcher* and each opens a window.
|
||||
Also: a `.html` file opens in Chromium (the HTTPS mime default now names
|
||||
a browser that is present — #94's exact bug), and a `.txt` file opens in
|
||||
**Text Editor** (`text/plain` falls through to
|
||||
`org.gnome.TextEditor.desktop` after #119; vscode remains preferred on
|
||||
template installs). Firefox is deliberately absent; its absence is
|
||||
correct, not a miss.
|
||||
- [ ] **#123 install bake: first boot fully themed without manual HM switch**
|
||||
— re-install from a main ISO (this commit+) on **either** the Acer
|
||||
M5-481T or the Dell XPS 9350. **Pass:** install ends with "Desktop
|
||||
pre-activated", no `dconf-CRITICAL` in
|
||||
`/var/log/nomarchy-hm-preactivate.log`, first graphical login has
|
||||
Stylix GTK + nm-applet **without** hand `home-manager switch`.
|
||||
- [ ] **#95 Kitty-only terminal on Acer M5-481T** — after rebuild/install
|
||||
from main: SUPER+Return opens **themed Kitty**; System › Doctor opens
|
||||
a floating classed Kitty window. No Ghostty on PATH required.
|
||||
- [ ] **#124 flake pin after main-ISO install** — installed machine’s
|
||||
`~/.nomarchy/flake.nix` has `?ref=main` (not lagging v1). **Pass:**
|
||||
`nomarchy-rebuild` does not die with
|
||||
`The option 'nomarchy.hardware' does not exist`.
|
||||
- [ ] **Relogin session recovery (2026-07-18 incident)** — after pulling
|
||||
the session-recovery commit and rebuilding: from a full Hyprland
|
||||
session (bar up, a browser open so easyeffects/tray are busy), log
|
||||
out (SUPER+SHIFT+E) and log straight back in at the greeter — do
|
||||
**not** reboot between. **Pass:** `nomarchy-doctor` shows *no failed
|
||||
user units*; `systemctl --user status cliphist swaync
|
||||
xdg-desktop-portal-hyprland swayosd` are all `active (running)`; the
|
||||
bar, notifications, and clipboard history (SUPER+V) work. This is
|
||||
the V3 close for the stale `graphical-session.target` /
|
||||
`start-limit-hit` relogin breakage (BACKLOG #149 note).
|
||||
|
||||
## AMD dev box only
|
||||
- [ ] **#118 smartd still runs where drives DO have SMART** (this commit) — the
|
||||
half a VM cannot answer: QEMU exposes no SMART, so `checks.smartd-gate`
|
||||
proves the skip but has to drive the *with-device* path through a stub.
|
||||
On the dev box (real NVMe), after `nomarchy-rebuild` + reboot:
|
||||
`systemctl status smartd` is **active/running**, and
|
||||
`systemctl show -p ExecCondition --value smartd` names
|
||||
`smartd-any-smart-device`. **Pass** = smartd is running, exactly as
|
||||
before this commit — i.e. the gate skips nothing on real hardware.
|
||||
**Fail** = inactive/skipped, which would mean the gate is silently
|
||||
disabling drive-health monitoring: revert it, don't tune it.
|
||||
Cheap bonus while you are there: `nomarchy-doctor` reports no failed
|
||||
units (the red-icon symptom that started #118).
|
||||
- [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP
|
||||
active and PPD switching governors; opt-ins: ROCm (`rocminfo`, a GPU
|
||||
PyTorch/Ollama smoke) and the XDNA NPU driver loading.
|
||||
- [ ] **Fingerprint** — `fprintd-enroll` + (opt-in PAM) login/sudo.
|
||||
- [ ] **#137/#145 splash on a real docked boot** — the one thing the render rig
|
||||
cannot do: its fake heads exist from the start, so the canvas never
|
||||
resizes, which *is* the bug. **Docked**, reboot: pass = the logo is centred
|
||||
on **both** panels at boot **and** at shutdown (it was off-centre on the
|
||||
external), and the LUKS prompt shows the padlock, the entry, and beneath it
|
||||
a keyboard icon + your layout (`us`). Then reboot **undocked** — unchanged.
|
||||
Rendering is already proven (`tools/plymouth-preview.sh`, both heads +
|
||||
`ask-for-password`), so what is unproven is only a canvas that changes
|
||||
under a real DRM boot. If the splash is ever blank, the passphrase prompt
|
||||
is invisible but still live — type it blind, or pick the previous
|
||||
generation in the boot menu, which carries the old theme.
|
||||
- [ ] **#142 dock mode survives a rebuild** — **relogin first** (the watcher is
|
||||
`exec-once`; `nomarchy-home` alone leaves the old one running and you
|
||||
would be testing nothing — round 6's lesson). Then, docked: run
|
||||
`nomarchy-home`. Pass = the laptop panel stays off, no workspace lands on
|
||||
it, no menu trip; `journalctl --user -t nomarchy-display-watch` shows
|
||||
`dock-intent=true panel=eDP-1 state=re-enabled action=re-dock` (a rebuild
|
||||
re-lights the panel and the watcher takes it back within ~1s, so a brief
|
||||
flicker is expected — the panel staying *up* is the failure).
|
||||
Then the three gates that must NOT fire, since this code can disable a
|
||||
panel: (a) Menu ▸ Display ▸ **Screen on** → the panel stays on (intent
|
||||
cleared; if it snaps off again within a second, that is the bug);
|
||||
(b) **undock** → panel returns and stays; (c) boot/relogin **undocked**
|
||||
with `settings.display.dockMode` still `true` in state.json → panel stays
|
||||
on (no external ⇒ never re-dock). (c) is the one that matters: it is the
|
||||
#127 brick if it is wrong. Gates are unit-tested against stubs, but only
|
||||
hardware proves the wiring.
|
||||
- [ ] **#138 dock audio does not break a running browser** — the plug event
|
||||
itself is the only thing V2 could not do (the tool was driven by hand;
|
||||
Hyprland calls the same entry point). **Relogin first** — the watcher is
|
||||
`exec-once`, so `nomarchy-home` alone leaves the old process running and
|
||||
you would be testing nothing (round 6's lesson). Then, with Chromium
|
||||
**already open** on a Meet call: dock/undock. Pass = output still follows
|
||||
to the monitor, **Meet keeps its mic and speakers without restarting the
|
||||
browser**, and `journalctl --user -t nomarchy-dock-audio` shows
|
||||
`selected=…` with **no** `action=graph-restart-fallback`, while
|
||||
`systemctl --user show -p MainPID --value pipewire.service` is unchanged
|
||||
across the plug. If a `graph-restart-fallback` line does appear, rung 3
|
||||
fired on a healthy plug — that is a bug, not the recovery working: file
|
||||
it with the surrounding journal.
|
||||
- [ ] **System ▸ Firmware menu on real LVFS hardware** (item #43,
|
||||
`nomarchy-menu firmware`) — on a machine whose firmware/SSD/dock is
|
||||
on LVFS: open Menu ▸ System ▸ **Firmware**; confirm the terminal runs
|
||||
`fwupdmgr refresh` → lists real `get-updates` → the y/N confirm gates
|
||||
correctly → `fwupdmgr update` applies and prints the reboot note when a
|
||||
capsule needs one. Verify **no** flash happens on "N"/cancel. (VM only
|
||||
proves the menu row + flow renders; a real capsule write is
|
||||
hardware-only.)
|
||||
|
||||
## Acer Aspire M5-481T only (1366×768 — the narrow-panel case)
|
||||
- [ ] **#139 sheets on a small panel** — Waybar clock → calendar, System ▸
|
||||
Doctor, and the Waybar updates click. Pass = each opens floating and
|
||||
centred at roughly 60%×65% / 55%×70% / 45%×50% of *that* screen (they are
|
||||
computed from the focused monitor now, so this is checking the fallback
|
||||
path and the font, not the arithmetic), fully on-screen, none clipped by
|
||||
the bar. Dev box (2560×1440) already measured exact.
|
||||
- [ ] **#131 menu width on the real narrow panel** — open Menu ▸ Recovery.
|
||||
Pass = every label complete (no ellipsis) and the picker visibly *not*
|
||||
hogging the screen (≤65%, the cap). The geometry was already reproduced
|
||||
pixel-exactly on the dev box (888px + JetBrainsMono 14 = what 65% of 1366
|
||||
produces) and passed, so what is genuinely unproven here is only the
|
||||
Acer's own fontconfig/DPI resolving `ch` the same way — try it under a
|
||||
**JetBrainsMono 14 theme** (summer-day/night, kanagawa), the widest case.
|
||||
|
||||
## Latitude 5310 / 5410 only
|
||||
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —
|
||||
general smoke before the next `main → v1` promotion (broader than
|
||||
the 2026-07-10 session; include theme/ISO/greeter leftovers).
|
||||
|
||||
## T14s only
|
||||
- [ ] **Webcam IR-hide end-to-end on Nomarchy** — installer detects the
|
||||
RGB+IR pair, bakes `hardware.camera.hideIrSensor`; `wpctl status`
|
||||
shows one colour source; an app picker lists one camera; Howdy-style
|
||||
direct `/dev/video2` reads still work.
|
||||
- [ ] **Portal/Flatpak libcamera IR (b)/(c)** — after #71 docs: on hardware,
|
||||
confirm a Flatpak/portal picker still lists the internal IR node;
|
||||
only then investigate (b) WirePlumber libcamera GREY-only monitor
|
||||
rule or (c) libcamera/udev-layer hide. Do not ship either without a
|
||||
live dual-sensor check (see HARDWARE.md §7 / ROADMAP § Webcam).
|
||||
2225
agent/JOURNAL-ARCHIVE.md
Normal file
2225
agent/JOURNAL-ARCHIVE.md
Normal file
File diff suppressed because it is too large
Load Diff
1964
agent/JOURNAL.md
Normal file
1964
agent/JOURNAL.md
Normal file
File diff suppressed because it is too large
Load Diff
161
agent/LOOP.md
Normal file
161
agent/LOOP.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# The loop — autonomous iteration protocol
|
||||
|
||||
How an AI agent works on Nomarchy unattended. One **iteration** = pick one
|
||||
task, do it, verify it, commit it, record it. The protocol is
|
||||
harness- and vendor-agnostic; the same iteration works under any of:
|
||||
|
||||
- **An interactive self-paced loop** in any agent harness (e.g. `/loop`
|
||||
in Claude Code) — the agent iterates until stopped.
|
||||
- **Headless** (a one-shot CLI invocation, cron/systemd-timer) — one
|
||||
invocation runs one iteration (or a small fixed number) and exits.
|
||||
- **A fresh manual session** — a human says "do a loop iteration"; the
|
||||
files below carry all the state, so any session can pick up where the
|
||||
last left off.
|
||||
|
||||
All loop state lives in this directory, git-tracked. There is no state
|
||||
outside the checkout (the distro's own philosophy, applied to its agents).
|
||||
|
||||
## The files
|
||||
|
||||
| File | Role | Who writes it |
|
||||
|---|---|---|
|
||||
| `GOALS.md` | North star + quality bars + non-goals | Human (agents propose edits) |
|
||||
| `BACKLOG.md` | Prioritized task queue (NOW/NEXT/LATER/PROPOSED/DECISIONS) | Both — see its header rules |
|
||||
| `JOURNAL.md` | Append-only iteration log | Agents |
|
||||
| `MEMORY.md` | Curated durable lessons/gotchas | Agents (curated, not append-only) |
|
||||
| `HARDWARE-QUEUE.md` | Pending on-hardware checks only Bernardo can run | Agents append, human checks off |
|
||||
| `CONVENTIONS.md` | Repo/design conventions to follow while coding | Human (agents propose edits) |
|
||||
|
||||
Instructions live next to the state: `VERIFICATION.md` (enforcement),
|
||||
`DELEGATION.md` (tiers/roles/economy), `GOALS.md`, `THEME-DESIGN.md`.
|
||||
|
||||
## Model & token economy
|
||||
|
||||
Spend expensive tokens on judgment, not mechanics. Tiers, roles, and the
|
||||
full delegation rules are in **`DELEGATION.md`**; the loop-specific
|
||||
habits:
|
||||
|
||||
- **Plan and reason on the strong model.** Orientation, task selection,
|
||||
design, debugging, Nix eval semantics, verification judgment, and
|
||||
anything that would land in a commit unreviewed stay with the
|
||||
frontier-tier model running the loop.
|
||||
- **Delegate mechanical subtasks down.** When a subtask is fully
|
||||
specified and needs no design judgment — grep/audit sweeps,
|
||||
README-option-table reconciliation, a repeated edit applied across
|
||||
files, summarizing long logs or check output — hand it to a
|
||||
light/standard-tier subagent per `DELEGATION.md`. The strong model
|
||||
writes the spec, reviews the result, and owns the commit.
|
||||
- **Read narrowly.** Step 0's list is the whole orientation read (the
|
||||
*last 3–5 entries* of the journal, never the full file). Read large
|
||||
files by section, don't re-read what's already in context, and tail
|
||||
build/check logs instead of dumping them.
|
||||
- **Write tersely.** Journal entries follow the template and no more;
|
||||
commit bodies state what/why/tier, not a narrative.
|
||||
- **Headless runners** may run whole low-stakes iterations (QA sweeps,
|
||||
docs-drift passes) on a cheaper model; iterations touching
|
||||
`modules/` or `pkgs/` behavior keep the strong model.
|
||||
|
||||
## One iteration, step by step
|
||||
|
||||
### 0. Orient
|
||||
1. Read `GOALS.md`, `CONVENTIONS.md`, `MEMORY.md`, the **last 3–5 entries**
|
||||
of `JOURNAL.md`, and `BACKLOG.md`. If the top task is product-shaped
|
||||
(UX, release bar, day-2 confidence), also read the matching section of
|
||||
**`docs/VISION.md`** — do not invent work from VISION; only execute
|
||||
BACKLOG items. Map of docs vs agent state: `docs/README.md`,
|
||||
`agent/README.md`.
|
||||
2. `git pull --ff-only` (skip silently if offline). Confirm you are on
|
||||
`main` with a clean tree. **A dirty tree you didn't create → stop and
|
||||
report; never stash or discard someone else's work.**
|
||||
3. Sanity baseline: if the last journal entry reports a red
|
||||
`nix flake check`, or you have any reason to suspect breakage, run
|
||||
`nix flake check --no-build` first. **A red baseline preempts the
|
||||
backlog — fixing it *is* this iteration's task.**
|
||||
|
||||
### 1. Pick exactly one task
|
||||
- Take the **topmost actionable** item: NOW before NEXT; never LATER
|
||||
unless NOW and NEXT are empty or all blocked.
|
||||
- *Actionable* means: not `[blocked:hw]` (those wait in
|
||||
`HARDWARE-QUEUE.md`), not `[human]` (decisions), and small enough to
|
||||
finish + verify in one iteration. If the top item is too big, **split
|
||||
it in BACKLOG.md** (that edit is part of the iteration) and take the
|
||||
first slice.
|
||||
- Never implement anything from **PROPOSED** — those await human triage.
|
||||
- If nothing is actionable, do a **QA sweep** instead: run the full check
|
||||
suite, hunt drift (README option tables vs the live `nomarchy.*`
|
||||
surface, template drift, dead code), deepen a VM test, or research and
|
||||
write up a PROPOSED item. An iteration that only improves the backlog
|
||||
is a valid iteration. If even that yields nothing, journal it and stop
|
||||
— do not manufacture churn.
|
||||
|
||||
### 2. Work
|
||||
- Keep the diff focused on the task. Unrelated fixes you trip over become
|
||||
PROPOSED/NOW entries, not scope creep.
|
||||
- Mechanical, fully-specified sub-steps go to a cheaper model
|
||||
(see *Model & token economy* above); design and review stay here.
|
||||
- Follow `CONVENTIONS.md`. Match the surrounding hand-formatting; never
|
||||
run a formatter.
|
||||
- New gotcha discovered the hard way → one line in `MEMORY.md` now, while
|
||||
it's fresh.
|
||||
|
||||
### 3. Verify — the ladder
|
||||
Climb as high as the change warrants and your environment allows; **record
|
||||
the tier reached** in the commit body and journal entry.
|
||||
|
||||
| Tier | What | When required |
|
||||
|---|---|---|
|
||||
| **V0** | `nix flake check --no-build` (+ `bash -n` / `py_compile` for scripts) | Every change, no exceptions |
|
||||
| **V1** | Build the touched output: `system.build.toplevel`, the HM generation, the ISO, or the package | Anything beyond docs/comments |
|
||||
| **V2** | VM: a `checks.*` runNixOSTest (add one if the change is guardable), or boot `tools/test-live-iso.sh` / `tools/test-install.sh` | Behavioral changes — services, boot, installer, session |
|
||||
| **V3** | Real hardware | Cannot be done by the agent → append to `HARDWARE-QUEUE.md` with exact test steps |
|
||||
|
||||
The honesty rule governs: a visual/interactive change verified only to V1
|
||||
is **not done** — it ships as "V1-verified, V2/V3 pending" with the pending
|
||||
check queued. Prefer *adding a permanent `checks.*` test* over a one-off
|
||||
manual VM poke when the behavior is testable headlessly (see MEMORY.md for
|
||||
the reusable recipes).
|
||||
|
||||
### 4. Commit + push
|
||||
- Style: match the log — `feat(scope): …`, `fix(scope): …`,
|
||||
`test(scope): …`, `docs(scope): …`. Body explains what/why + the
|
||||
verification tier reached and what remains.
|
||||
- Include the `agent/` bookkeeping updates (backlog/journal/memory/queue)
|
||||
**in the same commit** as the change they describe.
|
||||
- Commit directly on `main` and `git push` (Bernardo's standing workflow).
|
||||
- **Never:** force-push; touch the `v1` branch or any branch/tag you
|
||||
didn't create; commit secrets or binaries; run `nix flake update`
|
||||
unless the task is explicitly a lock bump; delete themes, wallpapers,
|
||||
or user-facing assets without the backlog saying so.
|
||||
|
||||
### 5. Record
|
||||
1. Mark the task in `BACKLOG.md` (move to its ✓ line or delete, per that
|
||||
file's rules).
|
||||
2. Append a `JOURNAL.md` entry (template in that file).
|
||||
3. Queue any V3 checks in `HARDWARE-QUEUE.md`.
|
||||
4. **Sync sweep.** Grep the item's number and feature name across
|
||||
`agent/` and `docs/` and update or delete every cross-reference the
|
||||
ship made stale: the PROPOSED pitch that spawned it (and BACKLOG's
|
||||
v1.0 pointer), HARDWARE-QUEUE entries it supersedes, ROADMAP/README/
|
||||
docs mentions of the old behavior. While there, prune checked-off
|
||||
`[x]` HARDWARE-QUEUE entries whose outcome is already recorded
|
||||
(journal/ROADMAP — git history is the archive). A shipped item must
|
||||
leave no stale pointer behind; the sweep rides in the same commit.
|
||||
|
||||
### 6. Pace (self-paced runners only)
|
||||
Under `/loop`, continue to the next iteration while tasks remain
|
||||
actionable and checks stay green. Stop the loop when: nothing is
|
||||
actionable, the same task has failed twice (journal the failure analysis
|
||||
and mark the item `[stuck]`), or a `[human]` decision blocks everything
|
||||
remaining.
|
||||
|
||||
## Stop-and-escalate conditions (any runner)
|
||||
|
||||
Write a journal entry + a BACKLOG note, then stop, when:
|
||||
- A fix would require touching `v1`, force-pushing, or a nixpkgs release
|
||||
jump.
|
||||
- The working tree contains uncommitted work you didn't create.
|
||||
- A task turns out to need a design decision Bernardo hasn't made → move
|
||||
it to **Decisions** in BACKLOG.md with the options laid out.
|
||||
- Two consecutive iterations failed on the same task (`[stuck]`).
|
||||
- Anything would delete or rewrite user data, git history, or the state
|
||||
file schema in a non-backward-compatible way.
|
||||
219
agent/MEMORY.md
Normal file
219
agent/MEMORY.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Memory — durable lessons, learned the hard way
|
||||
|
||||
Curated, not append-only: one line per fact, newest at the top of its
|
||||
section; delete entries that stop being true. Details usually live in a
|
||||
docs/ROADMAP.md decision record — pointer given as (§ item). Add a fact
|
||||
here the moment a debugging session teaches you something a future
|
||||
iteration would otherwise rediscover.
|
||||
|
||||
## Testing & VM recipes
|
||||
- **Doctor float V2:** `THEME=<slug> nix build --impure -f tools/doctor-float.nix`
|
||||
— softGL Hyprland, `nomarchy-menu doctor`, asserts
|
||||
`class=com.nomarchy.doctor` + `floating` + centered midpoints +
|
||||
screenshots (Kitty `--class=…`). SoftGL may still struggle with a GPU
|
||||
terminal; size can ignore percent windowrules if client geometry wins.
|
||||
- **theme-shot softGL may not start Kitty** — `btop.png` is best-effort
|
||||
(usually identical to desktop). Guest asserts on
|
||||
`~/.config/btop/themes/nomarchy.theme` prove baking; the TUI look is
|
||||
hardware/GL tier (HARDWARE-QUEUE).
|
||||
- **Waybar `custom/doctor` tripwire:** status helper must invoke
|
||||
`nomarchy-doctor` by **absolute store path** (waybar's env can miss
|
||||
system PATH → `command -v … || exit 0` self-hides forever); empty
|
||||
`"text"` also self-hides; strip ANSI before packing the tooltip; use
|
||||
signal 10 + `format = "{}"`. theme-shot asserts class:bad + glyph and
|
||||
pokes RTMIN+10 before the desktop shot.
|
||||
- **tuigreet dies silently under runNixOSTest** (even bare, no theme
|
||||
flag: greetd sits as "(greetd)" with no child, nothing in the
|
||||
journal — its stderr goes to the VT) — nixpkgs' own greetd test uses
|
||||
agreety instead. Greeter *rendering* is interactive-ISO/hardware
|
||||
tier; don't burn another session on a checks.greeter VM test.
|
||||
- In VM tests `pgrep -f PATTERN` can match the test backdoor's own
|
||||
`bash -c` wrapper (the pattern is in its cmdline) — use `pgrep -x`
|
||||
or a `[t]uigreet`-style bracket pattern.
|
||||
- **Don't default a "timer/session" feature to V3 — most of it is VM-testable.**
|
||||
A scheduled/session behaviour usually decomposes into a *generic* step
|
||||
already covered elsewhere (e.g. `home-manager switch`, exercised by every
|
||||
theme apply) and a *specific* decision (which theme, when). Stub the generic
|
||||
step (`NOMARCHY_REBUILD=<marker>` for theme-sync) and **simulate time by
|
||||
moving the VM clock** (`date -s`, `timedatectl set-ntp false`, `time.timeZone
|
||||
= "UTC"`), then assert the decision + state change headlessly. `checks.auto-theme`
|
||||
does exactly this for #79's sunset/sunrise. Only the literal
|
||||
timer-fires-on-`OnCalendar` is truly on-hardware, and `systemd-analyze
|
||||
calendar` validates that schedule. (I first mis-framed #79 as V3 — it's V2.)
|
||||
- **`writeShellScriptBin` scripts run `set -euo pipefail`** (nomarchy-doctor,
|
||||
the menu, lifecycle CLIs). So a **no-match `grep` inside `$(…)`** (grep exits
|
||||
1 → command-sub fails → abort) and a **standalone `cond && action`** (false
|
||||
cond → abort) both kill the script mid-run — the tell is output that stops
|
||||
before the final/verdict line with no error. Guard: `… | grep … || true`
|
||||
inside `$()`, `cmd 2>/dev/null || echo 0` for captures, and `if` instead of
|
||||
`&& action`. (#77 doctor hibernate section; caught by the checks.doctor VM
|
||||
test on first run.)
|
||||
- A checks.* fixture CANNOT be a writeText/toFile state file read at
|
||||
eval time ("path … is not valid" — flake check's eval store won't
|
||||
realise it): extract the logic into a pure importable file and
|
||||
unit-test THAT (monitor-rules.nix / checks.display-profiles is the
|
||||
pattern).
|
||||
- **Hibernation reference (Latitude / Newton, BACKLOG #76):** LUKS whole
|
||||
root BTRFS; `@swap` → `/swap`; file `/swap/swapfile`;
|
||||
`boot.resumeDevice` = LUKS root UUID; `resume_offset` from
|
||||
`btrfs inspect-internal map-swapfile -r`. Swap is encrypted with root
|
||||
(not a cleartext partition). No zram on that box yet — zram is additive
|
||||
for live pressure only. Installer already creates this when swapSize>0.
|
||||
- CI (`.gitea/workflows/check.yml`) is **eval-tier only** (standing
|
||||
decision 2026-07-10): act_runner docker-compose on the Gitea VPS; no
|
||||
KVM there. Full VM suite is BACKLOG **FUTURE #20**, not NEXT — needs a
|
||||
separate nix+/dev/kvm runner. Container gotchas are in the workflow
|
||||
header (single-user Nix + nixbld users, `sandbox=false` for Stylix
|
||||
IFD, Nix pinned 2.31.5 vs lazy-trees, no JS actions past node20).
|
||||
- The Gitea instance is **1.25.4** — `on: schedule` workflows are
|
||||
supported; bump.yml assumes the Actions token can push to `main`
|
||||
(standard Gitea behaviour, but unconfirmed until the first run lands).
|
||||
- The git server is **Gitea** (gitea/act_runner via docker-compose), NOT
|
||||
Forgejo — workflows are read from `.gitea/workflows/` (or `.github/`),
|
||||
never `.forgejo/workflows/` (a whole push cycle was lost to that).
|
||||
- Reusable headless VM harness: `checks.*` via runNixOSTest — existing
|
||||
examples to crib from: `distro-id` (boots + `switch-to-configuration
|
||||
dry-activate`), `hardware-toggles` (kernel cmdline/PAM assertions),
|
||||
`battery-charge-limit` (fake Mains adapter via `test_power`, real udev
|
||||
event burst while the oneshot is active; clean inactive result plus an
|
||||
`InvocationID` change proves coalesced AC re-apply). AC udev hooks for a
|
||||
settling oneshot must use `start`, never `restart`: USB-C docks emit event
|
||||
bursts and restarts SIGTERM the in-flight pass into `start-limit-hit`.
|
||||
- Themed-desktop screenshots work headlessly: software-GL Hyprland
|
||||
(`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP
|
||||
dump — prototyped 2026-06-19, kept as the fallback for theme previews
|
||||
(§ Visual theme picker).
|
||||
- Hyprland needs guest GL (`virtio-vga-gl`, `gl=on`) in interactive QEMU
|
||||
or the session won't start; black screen ≈ missing GL
|
||||
(docs/TESTING.md § gotchas). Kitty is the sole terminal (no Ghostty).
|
||||
- No KVM = slow, not broken; don't read slowness as failure.
|
||||
|
||||
## Known-broken / watchlist
|
||||
- **btrfs-assistant "segfault" was unprivileged-only** (re-diagnosed
|
||||
2026-07-04): libbtrfsutil's unprivileged subvolume iteration crashes on
|
||||
btrfs-progs 6.17.1 (upstream-fixed after); **as root it works**, and the
|
||||
pkexec launcher runs it as root. The real distro bug was **no polkit
|
||||
agent in the session** (every pkexec failed silently) — hyprpolkitagent
|
||||
now ships (hyprland.nix exec-once). `checks.snapshot-gui` guards the
|
||||
root path. Lesson: before "app X is broken", check WHO it runs as — and
|
||||
whether polkit prompts can render at all (§ Snapshot browse/restore).
|
||||
- **NixOS release bump is a trap:** the discarded attempt
|
||||
(branch deleted 2026-06-22) hit a Hyprland OOM blocker; a redo is a
|
||||
deliberate `v2`, never part of routine lock bumps.
|
||||
- `state.json` is git-tracked inside an 86 MB flake tree, so every
|
||||
state write re-copies the source before eval — the wallpapers-artifact
|
||||
split (BACKLOG LATER) is the decided fix (§ Faster switches).
|
||||
- **Friendly theme-state load** (`modules/state-read.nix`, #66):
|
||||
`builtins.tryEval` does **not** catch `readFile`/`fromJSON` failures —
|
||||
gate with `pathExists` + empty/non-object checks before `fromJSON`.
|
||||
Subtle JSON syntax errors still surface from nlohmann (line/col);
|
||||
field schema stays in `theme.nix`. mkFlake must `builtins.seq` the
|
||||
check onto the whole return set or lazy attr access skips it.
|
||||
|
||||
## Design invariants
|
||||
- **Dock transitions are ordered safety operations (#100):** dock in one
|
||||
`hyprctl --batch` (external on → every internal workspace moved → focus
|
||||
external → internal off); undock enables internal *before* moving anything.
|
||||
The Hyprland watcher, not a shell-pipeline subshell, owns a low-level
|
||||
`handle-lid-switch` inhibitor until the lid is physically open; startup
|
||||
cleans a validated stale process group. HDMI availability may appear only
|
||||
as `change:sink`, so fresh `monitoradded` is the intent boundary for the
|
||||
settled PipeWire/WirePlumber reprobe + sink pick; generic audio changes must
|
||||
not override a manual in-dock speaker choice.
|
||||
- **Waybar status is never color-only** (item 28 sweep, iteration #69):
|
||||
every status module must distinguish its states by SHAPE (glyph) or
|
||||
presence (self-hide), never color alone — good/warn/bad collapse under
|
||||
color-blindness. When adding a state, give it a distinct glyph or gate
|
||||
the module on it; a new `class` that only recolors an existing glyph is
|
||||
a regression. Suppressed notification states (DND *and* app-inhibited)
|
||||
all use the bell-off glyph + @muted.
|
||||
- **Identity themes are not traffic lights** (#69): white, vantablack,
|
||||
lumon, hackerman, matte-black, miasma — monochrome / mono-hue / earthy
|
||||
status by design. `audit-theme-design.py` tags their hue/CVD/ANSI-family
|
||||
findings `[identity]`; do not "fix" them into R/Y/G.
|
||||
- **Import hierarchy ≠ ANSI** (#70): `import-palettes.py` must not set
|
||||
surface==overlay when color0==color8; light color0 is often ANSI black
|
||||
(not a chip). Roles are first-class — never bulk-reimport shipped JSON
|
||||
without a hierarchy pass.
|
||||
|
||||
## Gotchas (cost a debugging session once)
|
||||
- **#127 forensics/method lessons (three wrong diagnoses' worth):** drive the
|
||||
real code path or measure nothing (a hand-run `dpms off` has no wake path
|
||||
watching it, so "input didn't wake it" was guaranteed); do not read output
|
||||
lists as lid positions — `/proc/acpi/button/lid/*/state` is the evidence
|
||||
(a reload can light a panel inside a shut lid, #148); and anchor forensics
|
||||
on the right boot — an *unclean boot end* (journald corruption + dirty bit
|
||||
on the next boot) is itself the incident marker, and `--list-boots` comes
|
||||
before any grep.
|
||||
- **OVMF exposes /dev/fd0 as TYPE=disk (#112):** disk picker must exclude
|
||||
`/dev/fd*` and tiny sizes; sort largest-first or blind Enter picks floppy.
|
||||
- **Live ISO offline theme switch (#113):** only the *default/pinned* HM
|
||||
generation is offline-safe; other presets may try to build the world —
|
||||
document the contract and fail with a network-oriented message.
|
||||
- **Installer HM pre-activate needs XDG_RUNTIME_DIR (+ session bus) (#123):**
|
||||
`runuser … activate` inside `nixos-enter` has no user session. Without
|
||||
`mkdir -p /run/user/$UID` owned by the install user and
|
||||
`XDG_RUNTIME_DIR` (prefer `dbus-run-session -- activate`), dconf dies
|
||||
with Permission denied and the bake aborts mid-way — first boot looks
|
||||
unthemed / missing nm-applet even though the system installed.
|
||||
- **ISO filename is `image.baseName` (#125):** volumeID alone does not rename
|
||||
`result/iso/…`; force `image.baseName` to `nomarchy-…` or the artifact
|
||||
stays `nixos-live-….iso`.
|
||||
- **Install flake ref must match the ISO branch (#124):** while `v1` lags
|
||||
`main`, seed `?ref=main` (and compose-lock original) from main-built ISOs;
|
||||
`NOMARCHY_FLAKE_URL` must actually rewrite `inputs.nomarchy.url` (was set
|
||||
but unused until #124).
|
||||
- Gum `filter` returns unmatched typed text by default; catalog-only pickers
|
||||
require `--strict` plus an independent exact-membership validation boundary.
|
||||
- Waybar `layer: top` renders above **even real-fullscreen windows** — the
|
||||
bar draws over a fullscreen video. `layer: bottom` lets the fullscreen
|
||||
surface cover it while the exclusive zone still reserves the bar's space
|
||||
in normal tiling (trade-off: floating windows can now overlap the bar
|
||||
strip). Set in both waybar.nix and every whole-swap jsonc (item 30).
|
||||
- Hyprland binds match the exact modmask: a shifted keysym (`question`)
|
||||
needs SHIFT in `mods` or the bind never fires — the keypress falls
|
||||
through to the focused window (§ item 26; caught on hardware, invisible
|
||||
to eval-tier tests).
|
||||
- Never kill a Wayland session-lock client (hyprlock): its crash
|
||||
failsafe drops to a tty instead of unlocking (§ Hibernate
|
||||
double-unlock).
|
||||
- rofi `element-icon size` is one value = a square cell; `WxH` silently
|
||||
collapses and non-square icons letterbox — pre-crop images square at
|
||||
build (§ Visual theme picker).
|
||||
- WirePlumber 0.5 monitor rules can only early-match `device.api`;
|
||||
`device.product.name` etc. bind *after* the rule runs — surgical
|
||||
libcamera scoping is impossible (§ Webcam).
|
||||
- `hyprctl switchxkblayout` is a *global* layout flip; per-device isolation
|
||||
needs `device[<name>]:kb_layout` keywords (§ Keyboard layouts).
|
||||
- Waybar's clock captures the timezone at construction — a zone change
|
||||
needs SIGUSR2 (watcher in `timezone.nix`) (§ Automatic timezone).
|
||||
- Waybar `persistent_workspaces` (underscore) is dead syntax silently
|
||||
ignored; the hyphen form is honoured and renders phantom workspaces
|
||||
(§ Waybar shows non-existent workspaces).
|
||||
- GTK4/libadwaita/Qt6 read light/dark from the portal's
|
||||
`org.freedesktop.appearance color-scheme` (dconf), not Stylix polarity
|
||||
(§ GTK/Qt ignore the theme's mode).
|
||||
- Update order matters downstream: `nomarchy-pull` (lock) then
|
||||
`nomarchy-rebuild` then `nomarchy-home`, or desktop changes are silently
|
||||
skipped against the old
|
||||
lock (README § 3).
|
||||
- Hyprland 0.55 renames `stayfocused` → `stay_focused` (and similar
|
||||
underscore effects); `stayfocused 1` is `invalid field type` at parse
|
||||
(§ polkit workspace rules, 2026-07-10 hardware).
|
||||
- Never gate a safety listener behind the optional feature it also serves:
|
||||
the display menu existed with no profiles while its blackout rescue did
|
||||
not. Rofi defaults to mouse-pointer output (`monitor=-5`), which is stale
|
||||
in clamshell mode; use focused output (`-1`) for keyboard-launched UI.
|
||||
- Hyprland 0.53 rewrote window rules: `windowrulev2` is a hard error and
|
||||
the old rule-first `float, class:^…$` no longer parses — both surface a
|
||||
red config-error banner on the default desktop. Hyprlang legacy form is
|
||||
now `<effect> <value>, match:<prop> ^…$` (e.g. `float 1, match:class ^…$`);
|
||||
effects carry a value, matchers take `match:` (§ windowrule migration).
|
||||
- grub `loadfont`s every `.pf2` in a theme dir — reuse a bundled DejaVu
|
||||
rather than shipping fonts (§ Distro branding).
|
||||
- Agent instructions live vendor-neutrally in `agent/` (VERIFICATION,
|
||||
DELEGATION, THEME-DESIGN; entry AGENTS.md) — `.claude/` is a thin
|
||||
adapter (permissions + subagent defs only; skills were removed
|
||||
2026-07-11). Never `git add -A` blindly: check `git status --short`
|
||||
for genuine strangers first (`settings.local.json`, harness-dropped
|
||||
files) and commit with explicit pathspecs (§ loop hygiene).
|
||||
54
agent/README.md
Normal file
54
agent/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Agent instructions + loop state
|
||||
|
||||
Everything an AI agent needs to work on Nomarchy, vendor-neutral and
|
||||
git-tracked. Protocol: **[LOOP.md](LOOP.md)**. Entry point for every
|
||||
harness: repo-root **[AGENTS.md](../AGENTS.md)**.
|
||||
|
||||
## Instructions (how to work)
|
||||
|
||||
| File | Who writes | Role |
|
||||
|------|------------|------|
|
||||
| [LOOP.md](LOOP.md) | Human | One-iteration protocol (orient → pick → work → verify → commit → record) + the V0–V3 ladder |
|
||||
| [VERIFICATION.md](VERIFICATION.md) | Human (agents propose) | Enforcement: preflight, honesty rules, visual protocol, hardware-blocked checks, reporting |
|
||||
| [DELEGATION.md](DELEGATION.md) | Human (agents propose) | Capability tiers, scout/runner roles, token economy, parallel fan-out |
|
||||
| [GOALS.md](GOALS.md) | Human (agents propose) | Pillars, quality bars, non-goals |
|
||||
| [CONVENTIONS.md](CONVENTIONS.md) | Human (agents propose) | How to write code/menu/state while shipping |
|
||||
| [THEME-DESIGN.md](THEME-DESIGN.md) | Human (agents propose) | Theme/visual design instructions |
|
||||
|
||||
## State (what's happening)
|
||||
|
||||
| File | Who writes | Role |
|
||||
|------|------------|------|
|
||||
| [BACKLOG.md](BACKLOG.md) | Both | **Prioritized queue** — only executable work list |
|
||||
| [JOURNAL.md](JOURNAL.md) | Agents | Append-only iteration log (read last 3–5 entries; older → [JOURNAL-ARCHIVE.md](JOURNAL-ARCHIVE.md)) |
|
||||
| [MEMORY.md](MEMORY.md) | Agents | Curated durable gotchas |
|
||||
| [HARDWARE-QUEUE.md](HARDWARE-QUEUE.md) | Agents append, human checks | On-hardware V3 tests only Bernardo can run |
|
||||
|
||||
## Product / design docs (not a queue)
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| [../docs/VISION.md](../docs/VISION.md) | v1.0 product themes — agents slice into BACKLOG PROPOSED |
|
||||
| [../docs/ROADMAP.md](../docs/ROADMAP.md) | Design history + shipped log |
|
||||
| [../docs/README.md](../docs/README.md) | Full docs map |
|
||||
|
||||
## Harness adapters (vendor-specific, thin)
|
||||
|
||||
Shared content never lives in an adapter — adapters only register/route
|
||||
into the files above, in whatever format their harness requires.
|
||||
|
||||
| Path | Harness | Role |
|
||||
|------|---------|------|
|
||||
| [../AGENTS.md](../AGENTS.md) | any | Entry point (`CLAUDE.md` is a symlink to it) |
|
||||
| [../.claude/settings.json](../.claude/settings.json) | Claude Code | Tool permissions |
|
||||
| [../.claude/agents/](../.claude/agents/) | Claude Code | `nomarchy-scout` / `nomarchy-runner` role defs (contracts in [DELEGATION.md](DELEGATION.md)) |
|
||||
|
||||
Do **not** put backlog items, vision text, or policy under an adapter
|
||||
directory — it is not shared with other agent runners.
|
||||
|
||||
## Rules of thumb
|
||||
|
||||
1. **Execute** from BACKLOG only (NOW → NEXT; never PROPOSED without human triage).
|
||||
2. **Orient** with GOALS + CONVENTIONS + MEMORY + last journal + BACKLOG; when the task is product-shaped, also read the relevant **VISION §**.
|
||||
3. **Record** lasting design in ROADMAP ✓ when something ships that future humans should know; delete the BACKLOG line.
|
||||
4. **v1 branch** is human-only — never advance from an agent session.
|
||||
49
agent/THEME-DESIGN.md
Normal file
49
agent/THEME-DESIGN.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Theme design — instructions for visual work
|
||||
|
||||
For creating, updating, refining, or troubleshooting Nomarchy themes and
|
||||
visual design. Act as an experienced UI/UX designer and Linux ricing
|
||||
expert for Wayland environments: themes here must be cohesive,
|
||||
intentional, and visually striking — aesthetics are a load-bearing
|
||||
feature of this distro (`agent/GOALS.md` pillar 4).
|
||||
|
||||
## Context & architecture
|
||||
|
||||
All theme data lives in `themes/` at the repo root — one JSON palette per
|
||||
theme plus per-app assets (whole-swap `waybar.jsonc`/CSS, btop themes,
|
||||
wallpapers). Before generating any new configuration, read the existing
|
||||
files there: learn how current themes are structured, how the syntax is
|
||||
formatted for each application, and how they integrate into the NixOS/HM
|
||||
modules. Always match the established pattern — there is no second
|
||||
theming pipeline (`agent/GOALS.md` non-goals), so a new visual surface
|
||||
consumes the palette from the state JSON, never a side file.
|
||||
|
||||
## Design responsibilities
|
||||
|
||||
1. **Holistic design:** a theme spans Hyprland (borders, shadows,
|
||||
animations), Waybar, Kitty, btop, fastfetch, rofi, and wallpaper —
|
||||
one coherent identity, no unthemed corner.
|
||||
2. **Color theory:** create or adapt advanced palettes. Draw inspiration
|
||||
from established aesthetics (Everforest, Nord, Gruvbox) or r/unixporn
|
||||
trends, but innovate. Ensure harmony between background, foreground,
|
||||
accents, and warning/error colors.
|
||||
3. **Typography & iconography:** pair UI and monospace fonts and icon
|
||||
themes to the specific vibe of the palette.
|
||||
4. **Accessibility:** high contrast for text readability; status
|
||||
information is never color-only (see `agent/MEMORY.md` design
|
||||
invariants — glyph/shape carries state, and identity themes are
|
||||
deliberately not traffic-lights).
|
||||
|
||||
## Process
|
||||
|
||||
1. Read `themes/` to understand the current structure.
|
||||
2. State the "vibe", the primary palette (hex codes), and typography
|
||||
choices of the proposed design before implementing.
|
||||
3. Implement by generating or updating files within the `themes/`
|
||||
structure, following `agent/CONVENTIONS.md` (Waybar parity rule:
|
||||
whole-swap `waybar.jsonc` files stay in sync with the generated
|
||||
config).
|
||||
4. Verify per `agent/VERIFICATION.md` §3 — scripted checks first
|
||||
(`tools/check-theme-contrast.py`, `tools/audit-theme-design.py`), then
|
||||
headless before/after screenshots you actually view, under at least
|
||||
two themes. New themes are imported via `tools/import-palettes.py` and
|
||||
round-tripped through the theme switcher.
|
||||
181
agent/VERIFICATION.md
Normal file
181
agent/VERIFICATION.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Verification — the enforcement rules
|
||||
|
||||
Nomarchy's promise to its user: a rock-stable, fully functional, beautiful
|
||||
workstation that is reproducible, easy to recover, and never requires the
|
||||
user to become a Nix expert. Every rule below exists to protect that
|
||||
promise. A change that works but degrades stability, aesthetics, or
|
||||
user-simplicity is a regression, not a feature.
|
||||
|
||||
This document is an **enforcer**, not the workflow itself: the ladder and
|
||||
iteration protocol live in `agent/LOOP.md`, VM instructions and gotchas in
|
||||
`docs/TESTING.md`. It applies to **every** change to this repo — features,
|
||||
fixes, theming, module changes, lock bumps, docs, backlog grooming.
|
||||
"Small" or "obvious" changes are exactly where verification gets skipped,
|
||||
so they trigger it too. If this file and those docs ever disagree, fix the
|
||||
discrepancy in the same or a follow-up commit so they can't disagree
|
||||
twice.
|
||||
|
||||
## 1. Preflight (once per session)
|
||||
|
||||
Before starting work, establish what verification tier this environment
|
||||
can reach, so you never promise verification you can't deliver:
|
||||
|
||||
1. Linux x86_64 host? `/dev/kvm` present and readable?
|
||||
2. Enough free disk for an image/ISO build (multi-GB)?
|
||||
3. Network access for a cold Nix store?
|
||||
|
||||
If the environment cannot reach V2 (no KVM, no disk, etc.): say so
|
||||
immediately, do the V0/V1 work honestly, mark the change **"V2 pending"**
|
||||
exactly as you would mark a hardware-blocked change "V3 pending" (§4),
|
||||
and stop short of claiming the change is done. Never simulate, guess, or
|
||||
describe what a VM test "would" show.
|
||||
|
||||
## 2. The verification ladder (enforcement rules)
|
||||
|
||||
Climb the V0–V3 ladder as defined in `agent/LOOP.md`. Four non-negotiable
|
||||
enforcement rules on top:
|
||||
|
||||
1. **V2 is mandatory for anything user-visible.** If a user of the
|
||||
installed system could perceive the change — behavior, layout, colors,
|
||||
keybinds, timing, error messages — it must be exercised in the local
|
||||
VM before commit. Docs-only, comment-only, or agent-notes changes may
|
||||
stop at the tier LOOP.md assigns them; user-visible changes may not.
|
||||
2. **Every "done" report names the tier reached and shows the evidence.**
|
||||
Evidence means: the command run and its relevant output, the checklist
|
||||
items exercised, and for visual work the screenshots viewed (§3). "It
|
||||
builds" is a V1 claim, not a V2 claim. Never let a report imply a
|
||||
higher tier than was actually reached.
|
||||
3. **A failed or flaky test is a result, not an obstacle.** Distinguish
|
||||
real failures from environment flakes using the known-gotchas section
|
||||
of `docs/TESTING.md` (e.g. no-KVM slowness, missing guest GL). If you
|
||||
cannot confidently classify a failure, report it as unresolved — do
|
||||
not retry until green and report only the green run.
|
||||
4. **VM runs are headless and unattended.** Use the repo's headless
|
||||
harness — `tools/test-live-iso.sh` and `tools/test-install.sh` for
|
||||
boot/install runs, `tools/vm/qmp.py` for programmatic VM control and
|
||||
`tools/vm/vncshot.py` for screen capture — never a graphical VM window
|
||||
or any flow that needs a human at the console. The human is not part
|
||||
of the test loop: do not pause mid-run to ask them to look at the VM,
|
||||
click something, or confirm what is on screen. A run must complete on
|
||||
its own and leave auditable artifacts behind (logs, serial console
|
||||
output, exit codes, screenshots), with every wait bounded by a timeout
|
||||
so a hang becomes a recorded failure instead of a stalled session.
|
||||
Prefer scripted assertions (process up, file exists, service/D-Bus
|
||||
state, the checks in `tools/`) over eyeballing; where judgment is
|
||||
genuinely needed — visual quality — *you* view the captured
|
||||
screenshots (§3), not the human. The human reviews evidence in the
|
||||
final report, never the live run.
|
||||
|
||||
### Regression scope after a change
|
||||
|
||||
Re-running the full checklist for every change wastes VM time; running
|
||||
nothing invites regressions. Default rule:
|
||||
|
||||
- Always: the session-sanity items (boot to session, bar renders).
|
||||
- Plus: every checklist item touching the layer you changed.
|
||||
- Plus: the theming end-to-end item whenever theming plumbing changed,
|
||||
even indirectly (palette generation, symlinks, reload hooks).
|
||||
- Lock bumps and toolchain changes: run the full checklist — their blast
|
||||
radius is unknowable by construction.
|
||||
|
||||
## 3. Visual verification protocol
|
||||
|
||||
Visual quality is a core feature of Nomarchy, so "it probably looks fine"
|
||||
is never verification. A visual/UI change is not V2-verified until all of
|
||||
the following are true:
|
||||
|
||||
1. **Before/after screenshots** of the changed surface were captured
|
||||
headlessly — `tools/theme-shot.nix` for reproducible theme renders,
|
||||
`tools/vm/vncshot.py` (driven via `tools/vm/qmp.py`) for captures from
|
||||
a running VM. No VM window, no human interaction. Capture the "before"
|
||||
from the base branch or prior generation, not from memory.
|
||||
2. **Scripted checks first**: run `tools/check-theme-contrast.py` and
|
||||
`tools/audit-theme-design.py` against the affected theme(s) before any
|
||||
eyeballing — machine-checkable legibility/design violations should
|
||||
never survive to the judgment stage. Use `tools/vm/gap-analysis.py`
|
||||
where it applies.
|
||||
3. **Two themes**: repeat the "after" capture under at least two themes,
|
||||
one with a generated palette and one whole-swap theme (e.g.
|
||||
summer-night). These exercise different code paths in the bar/launcher
|
||||
theming; a change that looks right under one can silently break the
|
||||
other.
|
||||
4. **You actually viewed the images** — open the screenshot files and
|
||||
look at them. State concretely what you inspected: alignment, spacing,
|
||||
contrast/legibility against the palette, icon rendering, no clipped or
|
||||
overlapping elements, and that the change looks intentional next to
|
||||
the "before".
|
||||
5. Keep the screenshots in the run's working area and reference their
|
||||
paths in the report, so the human can audit the same evidence.
|
||||
|
||||
If the VM cannot render the surface faithfully (known GL/compositor gaps
|
||||
in the guest — see `docs/TESTING.md`), that specific visual aspect is
|
||||
hardware-blocked: verify everything the VM *can* show, and queue the rest
|
||||
per §4.
|
||||
|
||||
## 4. Hardware-blocked checks
|
||||
|
||||
Some checks genuinely require real hardware (GPU behavior, multi-monitor
|
||||
hotplug, audio devices, power/suspend, firmware). For those:
|
||||
|
||||
1. Add an entry to `agent/HARDWARE-QUEUE.md` with: what changed, **exact**
|
||||
reproduction steps a human can follow verbatim, the expected
|
||||
observation (what "pass" looks like), and the commit hash once known.
|
||||
2. Mark the commit body **"V3 pending: <one-line summary>"**.
|
||||
3. Say it plainly in your report. A hardware-blocked check is not a
|
||||
failure and not something to hide — hiding it is the failure.
|
||||
4. When the human reports back, close the queue entry in the next commit
|
||||
and record the outcome; if it failed on hardware, that's a new bug at
|
||||
the top of the backlog.
|
||||
|
||||
Do not use the hardware queue as an escape hatch: if a check *can* be
|
||||
done in the VM, it must be. "The VM is slow" does not qualify.
|
||||
|
||||
## 5. Maintenance work
|
||||
|
||||
Maintenance follows the same ladder:
|
||||
|
||||
- **Flake lock bumps**: treat as maximum-blast-radius changes. Build,
|
||||
boot the VM, run the full regression checklist, and do a visual
|
||||
spot-check of the session (themes can shift with upstream package
|
||||
changes). Never merge a lock bump on "it evaluates". Since #134 the
|
||||
lock carries **two channels** (the release pin and the
|
||||
`nixos-unstable` pin feeding `unstable.*`) — a bump moves both, and
|
||||
this checklist applies to both.
|
||||
- **Theme imports / new themes**: import via `tools/import-palettes.py`,
|
||||
then the full §3 visual protocol; additionally verify the theme-switch
|
||||
round trip (into the new theme and back out).
|
||||
- **Docs drift**: run `tools/check-option-docs.py` after any change that
|
||||
adds or modifies options, and fix drift in the same commit as the code
|
||||
change that created it. Doc-only fixes are V0 — but verify any command
|
||||
you document by actually running it.
|
||||
- **Backlog grooming / agent-notes**: V0; keep entries consistent with
|
||||
the conventions in `agent/`.
|
||||
|
||||
## 6. Guarding the philosophy
|
||||
|
||||
Before committing, check the change against the distro's promises
|
||||
(`agent/GOALS.md` is the full statement):
|
||||
|
||||
- **User is not a Nix expert.** If the change requires the user to write
|
||||
or read Nix to use the feature day-to-day, redesign it. Configuration
|
||||
the user touches must stay in the simple, documented surface the repo
|
||||
defines.
|
||||
- **Rock-stable and recoverable.** Prefer boring, reproducible mechanisms
|
||||
over clever ones. Any change that could break boot or the session must
|
||||
have an obvious rollback story (NixOS generations count, but say so).
|
||||
- **Aesthetics are load-bearing.** A functionally correct but visually
|
||||
regressive change fails review by definition — that's what §3 is for.
|
||||
|
||||
When a requested change conflicts with these promises, stop and raise the
|
||||
conflict instead of implementing it quietly.
|
||||
|
||||
## 7. Reporting format
|
||||
|
||||
End every unit of work with a short report containing:
|
||||
|
||||
1. What changed (one paragraph, plain language).
|
||||
2. Verification tier reached, with evidence (commands + key output,
|
||||
checklist items run, screenshot paths viewed).
|
||||
3. Anything pending: "V2 pending" (environment) or "V3 pending"
|
||||
(hardware, with queue entry reference).
|
||||
4. Follow-ups added to the backlog, if any.
|
||||
529
docs/HARDWARE.md
Normal file
529
docs/HARDWARE.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# Hardware support
|
||||
|
||||
How Nomarchy enables CPUs, GPUs, laptops, firmware, and peripherals — and
|
||||
what to do when your machine is not in the happy path.
|
||||
|
||||
**Minimum bar** (OpenGL, RAM, disk, UEFI): [`REQUIREMENTS.md`](REQUIREMENTS.md)
|
||||
— read that before calling a machine “unsupported”; this file is the
|
||||
enablement stack and quirk list.
|
||||
|
||||
> **Queue:** [`agent/BACKLOG.md`](../agent/BACKLOG.md) (PROPOSED › Hardware
|
||||
> product). Product framing: [`VISION.md`](VISION.md) § A. Design history:
|
||||
> [`ROADMAP.md`](ROADMAP.md). Docs map: [`README.md`](README.md).
|
||||
> Migration: [`MIGRATION.md`](MIGRATION.md).
|
||||
|
||||
## 1. Architecture (three layers)
|
||||
|
||||
Nomarchy does **not** reimplement [nixos-hardware](https://github.com/NixOS/nixos-hardware).
|
||||
It stacks:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ A. Always-on desktop floor │
|
||||
│ modules/nixos/default.nix + power.nix + audio/BT │
|
||||
│ redistributable firmware · fwupd · NM · PipeWire · PPD · ddcci │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────────▼─────────────────────────────────────┐
|
||||
│ B. nixos-hardware profiles (via mkFlake hardwareProfile) │
|
||||
│ common-cpu-* · common-gpu-* · common-pc(-laptop|-ssd) · model │
|
||||
│ microcode · GPU media · fstrim · vendor/model quirks │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────────▼─────────────────────────────────────┐
|
||||
│ C. nomarchy.hardware.* (modules/nixos/hardware.nix) │
|
||||
│ gap above commons: GuC · amd-pstate · VA-API env · fprintd · │
|
||||
│ IR-webcam hide · ROCm/NPU/latestKernel · I2C/DDC/CI │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Layer | Who turns it on | Where it lives |
|
||||
|-------|-----------------|----------------|
|
||||
| **A** | Distro defaults (`mkDefault`) | Any machine importing `nomarchy.nixosModules.nomarchy` |
|
||||
| **B** | Installer DMI/`lspci` → `hardwareProfile = [ … ]` | Downstream `flake.nix` (`mkFlake`) |
|
||||
| **C** | Installer probes → `system.nix`, or hand-edit | Downstream `system.nix` |
|
||||
|
||||
`hardware-configuration.nix` (from `nixos-generate-config`) still owns
|
||||
initrd modules, filesystems, and the usual generate-config flags. The
|
||||
installer writes a real one; the template ships a placeholder you must
|
||||
replace.
|
||||
|
||||
Unknown `hardwareProfile` names **fail at eval** with Levenshtein
|
||||
suggestions (`lib.nix`) — never silently ignored.
|
||||
|
||||
## 2. What works without thinking about it
|
||||
|
||||
These ship on every Nomarchy system unless you override them:
|
||||
|
||||
| Capability | Default | Notes |
|
||||
|------------|---------|--------|
|
||||
| Wi‑Fi / BT firmware blobs | `hardware.enableRedistributableFirmware` | iwlwifi, ath, rtw, brcm, SOF, … |
|
||||
| NetworkManager | on | Wi‑Fi UI: System › Network |
|
||||
| PipeWire + WirePlumber | on | |
|
||||
| Bluetooth + blueman | on | System › Bluetooth |
|
||||
| power-profiles-daemon | on | Menu + Waybar; TLP via `nomarchy.system.power.backend = "tlp"` |
|
||||
| External monitor brightness | `nomarchy.hardware.i2c.ddcci` **on** | DDC/CI → backlight devices |
|
||||
| Firmware updates (LVFS) | `services.fwupd.enable` | **Never auto-flashes** — see §4 |
|
||||
| SMART / UPower / pcscd (FIDO) | on | |
|
||||
| earlyoom | on | Process-level OOM, not cgroup kill of the whole session |
|
||||
|
||||
**Not** default-on (need detect or uncomment): fprintd, GuC/HuC,
|
||||
amd-pstate, ROCm, NPU, thermald, charge limit, snapper (installer enables
|
||||
snapper), `latestKernel`.
|
||||
|
||||
## 3. Install path (best experience)
|
||||
|
||||
`nomarchy-install` sources `pkgs/nomarchy-install/hardware-db.sh` and:
|
||||
|
||||
1. Probes CPU vendor, GPUs (`lspci`), battery, SSD, fingerprint USB VIDs,
|
||||
RGB+IR webcam names, NPU PCI class.
|
||||
2. Looks up DMI `sys_vendor` × `product_name` in a ~60-entry model table
|
||||
(Framework, Dell XPS/Latitude, ThinkPad, Surface, ASUS ROG, Apple T2,
|
||||
System76).
|
||||
3. Emits `MODULE …` lines → `hardwareProfile` list in `flake.nix`.
|
||||
4. Emits `NOMARCHY hardware.*=…` → active + commented blocks in
|
||||
`system.nix`.
|
||||
5. Asks you to confirm profiles (or pick manually from the full
|
||||
nixos-hardware attr list baked into the ISO). Override with
|
||||
`NOMARCHY_HW=auto|none|"mod1 mod2"`.
|
||||
|
||||
**Live ISO** is intentionally *generic* (no model profile): broad GPU
|
||||
modules as *available*, redistributable firmware on, no baked
|
||||
`nomarchy.hardware.*`. Tuning happens at install time.
|
||||
|
||||
Out of scope for the installer today: aarch64 (Pi, Snapdragon X), Steam
|
||||
Deck (Jovian), Apple Silicon (T2 Intel Macs only in the DB).
|
||||
|
||||
## 4. Firmware updates (deep dive)
|
||||
|
||||
### What we ship
|
||||
|
||||
```nix
|
||||
# modules/nixos/default.nix
|
||||
services.fwupd.enable = lib.mkDefault true;
|
||||
```
|
||||
|
||||
fwupd talks to the [LVFS](https://fwupd.org/): UEFI capsule (BIOS), some
|
||||
SSDs, docks, Thunderbolt controllers, peripherals. It **only refreshes
|
||||
metadata** on its own. Applying an update is always an explicit user
|
||||
action.
|
||||
|
||||
### What you do today (CLI)
|
||||
|
||||
```sh
|
||||
# After first boot (and occasionally later):
|
||||
fwupdmgr refresh # optional; daemon often has metadata
|
||||
fwupdmgr get-devices # what LVFS can see
|
||||
fwupdmgr get-updates # pending
|
||||
fwupdmgr update # apply (may need reboot / battery / AC)
|
||||
```
|
||||
|
||||
Disable on VMs/headless: `services.fwupd.enable = false;` in `system.nix`.
|
||||
|
||||
### Why this is under-discovered
|
||||
|
||||
| Present | Missing |
|
||||
|---------|---------|
|
||||
| Daemon + package | Doctor check (“updates available”) |
|
||||
| README one-liner | Waybar / updates panel integration |
|
||||
| **System ▸ Firmware menu** (`nomarchy-menu firmware`) | |
|
||||
| MOTD + first-boot tip (#43) | |
|
||||
|
||||
**System ▸ Firmware** (shipped): a self-gated System-submenu row (present
|
||||
whenever `fwupdmgr` is on PATH, i.e. `services.fwupd.enable`, default-on)
|
||||
that opens a terminal and runs `fwupdmgr refresh` → `get-updates` →
|
||||
confirm → `fwupdmgr update`. It never auto-flashes — `fwupdmgr update`
|
||||
confirms each device and prompts for the reboot a capsule needs; pillar 1
|
||||
(rock-stable) forbids silent BIOS writes.
|
||||
|
||||
**Hints (#43 / #73):** MOTD cheat-sheet line when `fwupdmgr` is on PATH.
|
||||
Fingerprint / doctor tips follow the same pattern (fingerprint MOTD only
|
||||
when `nomarchy.hardware.fingerprint.enable`). A one-shot toast in the
|
||||
first graphical session also points at System › Firmware when `fwupdmgr`
|
||||
is on PATH (`settings.hardwareHintsShown`, `nomarchy-first-boot`) — for
|
||||
people who never read the MOTD.
|
||||
|
||||
**Still queued:** a Doctor “updates available” check, and
|
||||
Waybar/updates-panel integration.
|
||||
|
||||
### Thunderbolt
|
||||
|
||||
Firmware for TB devices may appear via LVFS. There is **no** first-class
|
||||
`services.hardware.bolt` / security-level UI in Nomarchy — use plain
|
||||
NixOS options if you need bolt.
|
||||
|
||||
### Microcode / kernel firmware
|
||||
|
||||
- **CPU microcode:** from nixos-hardware `common-cpu-*` and
|
||||
`nixos-generate-config` once redistributable firmware is on — not a
|
||||
separate `nomarchy.*` toggle.
|
||||
- **GPU GuC/HuC (Intel i915):** `nomarchy.hardware.intel.guc` →
|
||||
`i915.enable_guc=3`. Installer turns this **off** when the bound driver
|
||||
is `xe` (GuC is default there; the param is ignored).
|
||||
- **`hardware.enableAllFirmware`:** not set. Oddball non-redistributable
|
||||
Wi‑Fi still needs a manual NixOS fix (or a broader policy decision).
|
||||
|
||||
## 5. Fingerprint (deep dive)
|
||||
|
||||
### Detect → enable
|
||||
|
||||
Installer scans USB vendor IDs common to libfprint (Goodix, Synaptics,
|
||||
Elan, …) via `lsusb` or `/sys/bus/usb/.../idVendor`. On hit:
|
||||
|
||||
```nix
|
||||
nomarchy.hardware.fingerprint.enable = true; # services.fprintd
|
||||
# nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)
|
||||
# nomarchy.hardware.fingerprint.parallel = false; # sequential prompt instead
|
||||
```
|
||||
|
||||
### Enroll (menu or CLI)
|
||||
|
||||
**Shipped #55:** System › Fingerprint (self-gated when `fprintd-list` is
|
||||
on PATH) — a single **Fingerprint (on/off)** switch, plus Enroll / List /
|
||||
Verify / Delete all (all usable while it's off — turning it on needs an
|
||||
enrolled finger). The switch writes `settings.fingerprint.pam`, the one
|
||||
state key behind *both* halves of "use my finger": login/sudo PAM here,
|
||||
and the lock-screen unlock in `modules/home/idle.nix`. It runs
|
||||
`nomarchy-fingerprint`, which sudos a system rebuild and then a home
|
||||
switch, because the two halves live in different configurations.
|
||||
|
||||
It does **not** decide whether login prompts at all: auto-login skips the
|
||||
greeter entirely, so this adds the finger to the prompts that actually
|
||||
happen — sudo, the lock screen, and the greeter only when
|
||||
[auto-login](#auto-login) is off.
|
||||
|
||||
**Hints (#73):** MOTD line when `fingerprint.enable` is on; first-boot
|
||||
tip when `fprintd-list` is on PATH (`SUPER+M → System › Fingerprint` /
|
||||
`fprintd-enroll`). No permanent nag without a reader. A one-shot toast in
|
||||
the first graphical session also points at System › Fingerprint when
|
||||
`fprintd-list` is on PATH (`settings.hardwareHintsShown`,
|
||||
`nomarchy-first-boot`) — for people who never read the MOTD.
|
||||
|
||||
```sh
|
||||
# CLI still works:
|
||||
fprintd-enroll
|
||||
fprintd-list "$USER"
|
||||
nomarchy-fingerprint toggle # or on | off | status — what the menu row runs
|
||||
```
|
||||
|
||||
PAM stays opt-in on purpose: password-only remains the cautious default
|
||||
until a finger is enrolled. Full enroll on a real reader is V3/hardware.
|
||||
|
||||
### Auto-login
|
||||
|
||||
`settings.greeter.autoLogin` (System › Auto-login, or `nomarchy-autologin
|
||||
[toggle|on|off|status]`) decides whether boot goes straight to the desktop
|
||||
or stops at the greeter. The installer seeds it ON for LUKS-encrypted
|
||||
machines — the disk passphrase already gates the machine, so a greeter
|
||||
password is a second prompt for the same thing — and leaves it off
|
||||
without LUKS, where the greeter is the only thing between power-on and the
|
||||
desktop. It's baked into greetd at system rebuild, so the change shows on
|
||||
the next boot.
|
||||
|
||||
Auto-login and fingerprint are independent, and auto-login wins at boot:
|
||||
with it on you are never asked for anything at startup, whatever the
|
||||
fingerprint switch says. Turn auto-login off and the greeter asks — for a
|
||||
password, or a password *or* finger when fingerprint is on.
|
||||
|
||||
Both are in-flake state rather than lines in `system.nix` on purpose: a
|
||||
hand-set `nomarchy.system.greeter.autoLogin` (or
|
||||
`nomarchy.hardware.fingerprint.pam`) outranks the state and pins the
|
||||
setting, leaving the menu toggle unable to move it. That's the escape
|
||||
hatch, not the default — leave those lines commented to use the menu.
|
||||
|
||||
### Parallel prompt (password *or* finger, whichever first)
|
||||
|
||||
With `fingerprint.pam` on, sudo/login/hyprlock show **one** prompt that
|
||||
accepts either factor — type the password or touch the sensor
|
||||
(2026-07-12; `pam-fprint-grosshack`, an fprintd fork, since stock PAM
|
||||
can't express parallel factors). This is the default;
|
||||
`fingerprint.parallel = false` restores stock pam_fprintd's sequential
|
||||
wait-for-the-reader-then-password. Lockout safety is structural: the
|
||||
module never validates passwords itself — a typed password is handed to
|
||||
the normal `pam_unix` rule, and every failure (no reader, fprintd hung,
|
||||
timeout) falls through to password, so finger auth can only ever *add* a
|
||||
way in. Verify the wiring with
|
||||
`grep pam_fprintd_grosshack /etc/pam.d/sudo`. Known cosmetic quirk: after
|
||||
a fingerprint win a leftover password prompt may linger on the console.
|
||||
|
||||
### Doctor
|
||||
|
||||
`nomarchy-doctor` reports fprintd unit + enroll status when present.
|
||||
|
||||
## 6. NVIDIA (deep dive)
|
||||
|
||||
### What happens at install
|
||||
|
||||
```
|
||||
lspci → NVIDIA ⇒ MODULE common-gpu-nvidia
|
||||
```
|
||||
|
||||
That is the **entire** Nomarchy surface. Unfree packages are allowed
|
||||
(`allowUnfree = true` in the module and in `mkFlake`), so the proprietary
|
||||
path from nixos-hardware can evaluate.
|
||||
|
||||
There is **no** `nomarchy.hardware.nvidia.*` for:
|
||||
|
||||
- hybrid / PRIME (Intel+NVIDIA, AMD+NVIDIA)
|
||||
- open vs closed kernel module
|
||||
- power management / udev / suspend quirks
|
||||
- CUDA / container toolkit packages
|
||||
|
||||
### What a hybrid laptop user must do today
|
||||
|
||||
1. Confirm `common-gpu-nvidia` (and often `common-gpu-intel` or
|
||||
`common-gpu-amd`) are in `hardwareProfile`.
|
||||
2. Read the relevant nixos-hardware module and [NixOS NVIDIA wiki](https://wiki.nixos.org/wiki/Nvidia).
|
||||
3. Add plain NixOS options in `system.nix`, for example (illustrative —
|
||||
hardware-specific):
|
||||
|
||||
```nix
|
||||
# Example only — verify against your generation and nixos-hardware module.
|
||||
# hardware.nvidia.prime = { ... };
|
||||
# hardware.nvidia.powerManagement.enable = true;
|
||||
# hardware.nvidia.open = false; # or true for open modules on newer cards
|
||||
```
|
||||
|
||||
4. Rebuild the system. Home Manager does not own the driver.
|
||||
|
||||
### Live ISO note
|
||||
|
||||
The ISO lists `nouveau` among *available* initrd modules and does **not**
|
||||
force-load every GPU driver (avoids multi-driver panics). Proprietary
|
||||
NVIDIA on the live session is not the product focus; installed systems
|
||||
use the profile.
|
||||
|
||||
**Product direction (shipped #59; first-class wrappers deferred for v1):**
|
||||
when `common-gpu-nvidia` is in `hardwareProfile`, the installer emits a
|
||||
**commented** `system.nix` block with PRIME / power / open-module pointers
|
||||
(same pattern as ROCm / NPU comments). No `nomarchy.hardware.nvidia.*`
|
||||
stack for v1 — hybrid PRIME remains wiki/plain NixOS until a maintainer
|
||||
commits to hardware testing.
|
||||
|
||||
## 7. Day-2: “my laptop is mostly fine, make it fully fine”
|
||||
|
||||
| Goal | How | Rebuild? |
|
||||
|------|-----|----------|
|
||||
| Power profile (perf/balanced/saver) | System menu / Waybar | No (PPD D-Bus) |
|
||||
| Charge limit 80% | Menu / CC — live sysfs + state; boot oneshot re-applies | No rebuild for live; rebuild bakes Nix option |
|
||||
| Thermald (Intel) | Installer on for Intel laptops; else `power.thermal.enable` | System |
|
||||
| Fingerprint enroll | `fprintd-enroll` | No |
|
||||
| Fingerprint login | `fingerprint.pam = true` | System |
|
||||
| ROCm / Intel compute | Uncomment opt-ins in `system.nix` | System (large) |
|
||||
| NPU driver | Uncomment `npu` (+ often `latestKernel`) | System; userspace BYO |
|
||||
| Newer kernel for brand-new silicon | `nomarchy.hardware.latestKernel = true` | System |
|
||||
| Hide IR “dark camera” | Installer or `camera.hideIrSensor` | System |
|
||||
| Firmware | `fwupdmgr update` | Reboot if capsule requires |
|
||||
| Model quirks missing | Set better `hardwareProfile` (see §8) | System |
|
||||
| OpenRGB / printing / Steam | `nomarchy.services.*` | System |
|
||||
|
||||
### Dual-sensor webcam / IR hide
|
||||
|
||||
Many dual-sensor modules (e.g. ThinkPad T14s) expose colour + IR as two
|
||||
identically named “Integrated Camera” nodes. Picking the IR node yields a
|
||||
black/dark greyscale frame — the classic “my webcam is dark” symptom.
|
||||
|
||||
`nomarchy.hardware.camera.hideIrSensor` (installer-on when RGB+IR names
|
||||
are detected; overridable `irMatch`) drops the IR node from **WirePlumber’s
|
||||
v4l2 monitor** so native PipeWire pickers only offer the colour camera.
|
||||
The kernel `/dev/video*` node stays open, so Howdy-style face unlock still
|
||||
works. Design history: [ROADMAP § Webcam](ROADMAP.md).
|
||||
|
||||
**What it does not cover:** apps that list cameras via **libcamera** or
|
||||
the **xdg-desktop-portal** / **Flatpak** camera path still see both
|
||||
sensors — a Flatpak Zoom (or similar) user can still pick the black IR
|
||||
“camera”. That is intentional: a blanket libcamera disable would risk
|
||||
external USB cams that need the libcamera path, and surgical internal-only
|
||||
libcamera rules could not match early enough (only `device.api` binds
|
||||
before the WirePlumber monitor rule).
|
||||
|
||||
**Workaround:** prefer the colour device in the picker (or any non-IR
|
||||
name). Apps that default to the first v4l2 colour source without a picker
|
||||
are fine.
|
||||
|
||||
**Further engineering (needs T14s-class hardware):** (b) a WirePlumber
|
||||
*libcamera* monitor rule disabling GREY-only nodes; (c) a libcamera/udev
|
||||
quirk at the libcamera layer. Neither is implemented — the recommended
|
||||
path is document-only until those can be verified on real dual-sensor
|
||||
hardware (see `agent/HARDWARE-QUEUE.md` › T14s).
|
||||
|
||||
Theme switches never touch drivers (Home Manager only).
|
||||
|
||||
## 8. Unsupported or unlisted machines
|
||||
|
||||
### Still good floor
|
||||
|
||||
Even with **no** DMI hit you usually get:
|
||||
|
||||
- `common-cpu-{intel,amd}`
|
||||
- `common-gpu-{intel,amd,nvidia}` as applicable
|
||||
- `common-pc` or `common-pc-laptop` + optional `common-pc-ssd`
|
||||
- `nomarchy.hardware.intel` or `.amd` when vendor matches
|
||||
- Layer A (firmware, audio, BT, PPD, fwupd)
|
||||
|
||||
You **lose** model-specific EC/suspend/keyboard/audio quirks that only
|
||||
exist in a named nixos-hardware module.
|
||||
|
||||
### Pick a profile after install
|
||||
|
||||
1. List candidates:
|
||||
|
||||
```sh
|
||||
nix eval github:NixOS/nixos-hardware#nixosModules \
|
||||
--apply builtins.attrNames
|
||||
# or: the ISO ships hardware-modules.txt for the same list
|
||||
```
|
||||
|
||||
2. Set in your flake (installer already uses a list):
|
||||
|
||||
```nix
|
||||
hardwareProfile = [
|
||||
"common-cpu-amd"
|
||||
"common-gpu-amd"
|
||||
"common-pc-laptop"
|
||||
"common-pc-ssd"
|
||||
"lenovo-thinkpad-t14-amd-gen3" # if it matches
|
||||
];
|
||||
```
|
||||
|
||||
3. `sudo nixos-rebuild switch --flake ~/.nomarchy#default`
|
||||
|
||||
### Template / migration (no installer)
|
||||
|
||||
See [MIGRATION.md](MIGRATION.md): reuse `hardware-configuration.nix`, set
|
||||
`hardwareProfile`, uncomment `nomarchy.hardware` / power in `system.nix`.
|
||||
Post-install re-probe (**shipped #58**):
|
||||
|
||||
```sh
|
||||
nomarchy-detect-hw # human report + suggested snippets
|
||||
nomarchy-detect-hw --raw # MODULE / NOMARCHY / DETAIL protocol lines
|
||||
```
|
||||
|
||||
Prints suggested `hardwareProfile` and `system.nix` lines; **does not**
|
||||
rewrite the flake. Paste after review, then `sudo nixos-rebuild switch`.
|
||||
|
||||
## 9. Install-tested machines (hall of fame)
|
||||
|
||||
Models that have seen a real Nomarchy session (live ISO, install, or
|
||||
day-to-day QA) — not a guarantee of every feature, just a public seed
|
||||
list. **DB coverage** (hardware-db.sh) is broader; this table is the
|
||||
smaller “someone actually sat at it” set.
|
||||
|
||||
| Model | DMI (`sys_vendor` × `product_name` / version) | Typical `hardwareProfile` | Last noted | Notes |
|
||||
|-------|-----------------------------------------------|---------------------------|------------|-------|
|
||||
| Lenovo ThinkPad T14s Gen 4 (AMD) | `LENOVO` × `21F8CTO1WW` / `ThinkPad T14s Gen 4` | common AMD laptop + SSD (plus `nomarchy.hardware.amd` / fingerprint / IR cam as probed) | 2026-07 | Maintainer day-to-day + HARDWARE-QUEUE (IR portal, fingerprint, NPU). Dual-sensor webcam is the motivating IR case. |
|
||||
| Dell Latitude 5410 | Dell Latitude 5410 (Intel) | common Intel laptop + SSD (+ GuC when enabled) | 2026-07 | Primary Intel hardware-QA host (session, themes, polkit, bar). |
|
||||
| Dell Latitude 5310 | Dell Latitude 5310 (Intel) | same family as 5410 | 2026-07 | Charge-limit / power QA sibling (see HARDWARE-QUEUE). |
|
||||
|
||||
### Send your DMI line
|
||||
|
||||
If you install Nomarchy on a machine and it works (or fails in an
|
||||
interesting way), open a PR that:
|
||||
|
||||
1. Adds a row to the table above (model, DMI, profile, date, short notes).
|
||||
2. Optionally adds a `HARDWARE_DB` line in
|
||||
`pkgs/nomarchy-install/hardware-db.sh` when nixos-hardware has a
|
||||
matching module (§10).
|
||||
|
||||
Quick DMI grab:
|
||||
|
||||
```sh
|
||||
printf '%s | %s | %s\n' \
|
||||
"$(cat /sys/class/dmi/id/sys_vendor)" \
|
||||
"$(cat /sys/class/dmi/id/product_name)" \
|
||||
"$(cat /sys/class/dmi/id/product_version)"
|
||||
nomarchy-detect-hw # suggested MODULE / NOMARCHY lines
|
||||
```
|
||||
|
||||
## 10. Adding your model to the distro
|
||||
|
||||
For contributors (and power users who will PR):
|
||||
|
||||
1. On the machine:
|
||||
|
||||
```sh
|
||||
cat /sys/class/dmi/id/sys_vendor
|
||||
cat /sys/class/dmi/id/product_name
|
||||
```
|
||||
|
||||
2. Find a matching attr in
|
||||
[nixos-hardware](https://github.com/NixOS/nixos-hardware) (or add one
|
||||
upstream first).
|
||||
|
||||
3. Append to `pkgs/nomarchy-install/hardware-db.sh` in `HARDWARE_DB`:
|
||||
|
||||
```bash
|
||||
"VendorRegex|Product regex|nixos-hardware-module-name"
|
||||
```
|
||||
|
||||
First match wins — put specific lines above broad fallbacks.
|
||||
|
||||
4. Verify the name exists in the **pinned** nixos-hardware input
|
||||
(`mkFlake` will throw if not). A CI check that every DB name ∈
|
||||
`nixosModules` is queued so lock bumps cannot silently break installs.
|
||||
|
||||
5. Optional: note on-hardware QA steps in `agent/HARDWARE-QUEUE.md`.
|
||||
Install-tested status goes in the hall-of-fame table (§9).
|
||||
|
||||
## 11. Doctor and hardware health (current vs target)
|
||||
|
||||
**Today** (`nomarchy-doctor`): failed units, disk space, state.json
|
||||
validity/git, generation age, snapper; hardware section self-gates per
|
||||
machine (NetworkManager, audio sink, GPU smoke, fprintd, fwupd, charge
|
||||
limit, battery health, hibernate/zram). All read-only; each ✖ prints one
|
||||
fix command.
|
||||
|
||||
| Check | Pass condition | Suggested fix line |
|
||||
|-------|----------------|--------------------|
|
||||
| NetworkManager | device connected or wifi radio on | open System › Network |
|
||||
| Audio sink | default sink exists | System › Audio |
|
||||
| GPU accel | `glxinfo`/`vainfo` smoke (if installed) | check `hardwareProfile` / drivers |
|
||||
| Fingerprint | if USB VID matched / fprintd unit | `fprintd-enroll` or enable option |
|
||||
| fwupd | daemon active; optional “updates pending” warn | `fwupdmgr get-updates` |
|
||||
| Battery threshold | if laptop + limit set, sysfs reports it | power docs / restart charge-limit unit |
|
||||
| Battery health | cycle_count and/or charge\|energy_full÷design % when sysfs exports them | report-only (warn if <70% of design) |
|
||||
| First-boot pre-activate | installer log present + no HM generation | `home-manager switch --flake ~/.nomarchy -b bak` |
|
||||
| Hibernate / zram | disk swap + resume=; zram active | docs/MIGRATION.md → Enabling hibernation |
|
||||
|
||||
## 12. Option quick reference
|
||||
|
||||
Full tables: [README § options](../README.md). Hardware-shaped surface:
|
||||
|
||||
| Option | Role |
|
||||
|--------|------|
|
||||
| `mkFlake.hardwareProfile` | nixos-hardware name or list |
|
||||
| `nomarchy.hardware.intel.enable` / `.guc` / `.computeRuntime` | Intel gap layer |
|
||||
| `nomarchy.hardware.amd.enable` / `.pstate` / `.vaapi` / `.rocm.*` | AMD gap layer |
|
||||
| `nomarchy.hardware.fingerprint.enable` / `.pam` / `.parallel` | fprintd + PAM (parallel password-or-finger prompt by default) |
|
||||
| `nomarchy.hardware.npu.enable` | in-kernel NPU only |
|
||||
| `nomarchy.hardware.latestKernel` | `linuxPackages_latest` |
|
||||
| `nomarchy.hardware.camera.hideIrSensor` / `.irMatch` | dual-sensor webcams (v4l2 only; §7) |
|
||||
| `nomarchy.hardware.i2c.enable` / `.ddcci` | I2C + external backlight |
|
||||
| `nomarchy.system.power.*` | PPD/TLP, laptop, thermald, charge limit |
|
||||
| `nomarchy.system.bluetooth.enable` | BT stack |
|
||||
| `services.fwupd.enable` | LVFS (native NixOS, default on) |
|
||||
|
||||
## 13. Ease summary
|
||||
|
||||
| Situation | Effort |
|
||||
|-----------|--------|
|
||||
| Known model in DB (Framework, many ThinkPads, …) | Low — install and go |
|
||||
| Unknown modern Intel/AMD laptop | Low–medium — commons cover a lot |
|
||||
| Firmware updates | Medium — CLI only until product work lands |
|
||||
| Fingerprint for login | Medium — enroll CLI + PAM uncomment |
|
||||
| Hybrid NVIDIA | High — plain NixOS/wiki territory |
|
||||
| ROCm / NPU userspace | High — opt-in + expert |
|
||||
| Contribute a new DMI line | Medium for someone who can PR |
|
||||
|
||||
## 14. Related files
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `modules/nixos/hardware.nix` | `nomarchy.hardware.*` |
|
||||
| `modules/nixos/power.nix` | PPD/TLP, charge limit, thermald |
|
||||
| `modules/nixos/default.nix` | firmware, fwupd, ddcci default |
|
||||
| `pkgs/nomarchy-install/hardware-db.sh` | DMI DB + probes |
|
||||
| `pkgs/nomarchy-install/nomarchy-install.sh` | writes flake + system.nix |
|
||||
| `lib.nix` | `hardwareProfile` resolution |
|
||||
| `templates/downstream/system.nix` | commented opt-ins |
|
||||
| `agent/HARDWARE-QUEUE.md` | on-machine V3 checks |
|
||||
539
docs/MIGRATION.md
Normal file
539
docs/MIGRATION.md
Normal file
@@ -0,0 +1,539 @@
|
||||
# Migrating an existing NixOS machine to Nomarchy (no reinstall)
|
||||
|
||||
If your machine already runs NixOS, you do **not** need the installer or a
|
||||
reformat to adopt Nomarchy. Nomarchy is a flake; "installing" it onto an
|
||||
existing NixOS box means pointing `nixos-rebuild` at a Nomarchy‑based flake
|
||||
that reuses your current `hardware-configuration.nix`. Nothing repartitions,
|
||||
and your `/home` is never written to by an activation.
|
||||
|
||||
This guide is written generically, with **TuringMachine** — a Lenovo AMD
|
||||
Ryzen 7840U / Radeon 780M laptop, LUKS + btrfs, systemd‑boot — as the
|
||||
concrete worked example. Substitute your own values where called out.
|
||||
|
||||
> **The promise:** every step below is reversible. You keep three
|
||||
> independent rollback nets (NixOS generations, a btrfs snapshot, and the
|
||||
> fact that `nixos-rebuild test` never changes the boot default), and your
|
||||
> files live on a separate subvolume that no config switch touches.
|
||||
|
||||
---
|
||||
|
||||
## 0. Is your machine a good candidate?
|
||||
|
||||
Migration is cleanest when your machine already matches Nomarchy's
|
||||
assumptions. Check each:
|
||||
|
||||
| Nomarchy expects | Check it | If it differs |
|
||||
|---|---|---|
|
||||
| **systemd‑boot** | `bootctl status` → "Product: systemd‑boot" | GRUB works too; keep your loader config in `system.nix` |
|
||||
| **btrfs** with `@`/`@home` subvolumes | `findmnt -t btrfs` | ext4/xfs boot fine — you just don't get snapshots |
|
||||
| `@snapshots` + `@home-snapshots` subvols | in `findmnt` output | snapper features need them; create them or skip snapshots |
|
||||
| **LUKS** (optional, themed prompt) | `lsblk -f` shows `crypto_LUKS` | none — LUKS is optional |
|
||||
| Already a **flake** config | `test -f /etc/nixos/flake.nix` | fine either way; you'll write a fresh flake regardless |
|
||||
|
||||
**Installer vs migration snapshot layout.** A fresh Nomarchy install
|
||||
(disko) creates a top-level `@snapshots` subvolume mounted at
|
||||
`/.snapshots`, then a first-boot oneshot makes a *nested*
|
||||
`/home/.snapshots` under `@home` for snapper's home timeline — it does
|
||||
**not** create a separate top-level `@home-snapshots`. Migration machines
|
||||
(e.g. TuringMachine) may already use top-level `@snapshots` **and**
|
||||
`@home-snapshots`; that is fine. Snapper only needs a `.snapshots` path
|
||||
under each tracked subvolume (`/` and `/home`), so either layout works —
|
||||
reuse what you have, or create the missing pieces if you want snapper
|
||||
without reformatting.
|
||||
|
||||
TuringMachine matches all of these, including the `@snapshots` /
|
||||
`@home-snapshots` subvolumes — so snapper works with zero disk work.
|
||||
|
||||
**Version note.** Nomarchy pins `nixos-26.05`. If you're on an older release
|
||||
(TuringMachine is on **25.11**), the migration folds a one‑release upgrade
|
||||
into the switch. That's normal and supported — read the
|
||||
[NixOS 26.05 release notes](https://nixos.org/manual/nixos/stable/release-notes)
|
||||
for option/package renames, and lean on generations if something regresses.
|
||||
|
||||
---
|
||||
|
||||
## 1. The two rules that protect your data
|
||||
|
||||
1. **Never bump `system.stateVersion`.** It is an on‑disk/service
|
||||
compatibility marker tied to when the machine was *first installed* — not
|
||||
the nixpkgs version. Nomarchy's template ships `26.05`; you **must** change
|
||||
it back to your machine's original value. Find yours:
|
||||
|
||||
```console
|
||||
$ nixos-option system.stateVersion # or: nix eval .#nixosConfigurations.<host>.config.system.stateVersion
|
||||
"24.11"
|
||||
```
|
||||
|
||||
*(TuringMachine: `24.11`.)*
|
||||
|
||||
2. **`/home` is never touched by an activation.** A `nixos-rebuild switch`
|
||||
swaps the system generation; your data subvolume is untouched. The Phase 0
|
||||
snapshot is belt‑and‑suspenders, not a necessity for file safety.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Safety net (nothing changes yet)
|
||||
|
||||
```bash
|
||||
# Read-only btrfs snapshots of root and home — instant rollback targets.
|
||||
sudo btrfs subvolume snapshot -r / /.snapshots/pre-nomarchy-root
|
||||
sudo btrfs subvolume snapshot -r /home /home/.snapshots/pre-nomarchy-home
|
||||
|
||||
# Freeze your current config as a clean git baseline.
|
||||
cd /etc/nixos && git add -A && git commit -m "pre-nomarchy baseline" || true
|
||||
```
|
||||
|
||||
You are currently booted in a known‑good generation; it remains in the
|
||||
systemd‑boot menu throughout. Worst case at any later step: reboot and pick
|
||||
it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Build the Nomarchy flake alongside (no switch)
|
||||
|
||||
Stand the new config up in a working directory and **build** it without
|
||||
activating. This is the real safety line: iterate here until it builds green
|
||||
before anything touches the running system.
|
||||
|
||||
```bash
|
||||
git clone https://git.bemagri.xyz/bernardo/nomarchy.git ~/nomarchy-migrate
|
||||
cd ~/nomarchy-migrate
|
||||
# Or start from the downstream template:
|
||||
# nix flake init -t "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1"
|
||||
# (produces flake.nix/system.nix/home.nix/…)
|
||||
```
|
||||
|
||||
A Nomarchy downstream flake owns exactly five files. Assemble them:
|
||||
|
||||
### `hardware-configuration.nix` — reuse yours unchanged
|
||||
|
||||
Copy your **existing** hardware config in verbatim. This is what preserves
|
||||
your disks, LUKS, btrfs subvolumes and swap — the reason no reinstall is
|
||||
needed.
|
||||
|
||||
```bash
|
||||
cp /etc/nixos/hosts/TuringMachine/hardware-configuration.nix ./hardware-configuration.nix
|
||||
```
|
||||
|
||||
### `flake.nix` — one `mkFlake` call (from the template)
|
||||
|
||||
```nix
|
||||
{
|
||||
description = "TuringMachine — Nomarchy";
|
||||
inputs.nomarchy.url = "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1";
|
||||
|
||||
outputs = { nomarchy, ... }:
|
||||
nomarchy.lib.mkFlake {
|
||||
src = ./.;
|
||||
username = "bernardo"; # <- your login name
|
||||
# Optional nixos-hardware profile(s) for your model. For an AMD laptop:
|
||||
# hardwareProfile = [ "common-cpu-amd-pstate" "common-gpu-amd" "common-pc-laptop-ssd" ];
|
||||
# Names: https://github.com/NixOS/nixos-hardware (verify before use)
|
||||
# Full hardware story (firmware, fingerprint, unsupported machines):
|
||||
# docs/HARDWARE.md in the Nomarchy repo
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### `system.nix` — machine specifics + your decisions
|
||||
|
||||
This is where your three migration decisions land: **PPD power (no
|
||||
ryzenadj)**, **no Secure Boot**, and the **stateVersion override**.
|
||||
|
||||
```nix
|
||||
{ pkgs, username, ... }:
|
||||
|
||||
{
|
||||
# Plain systemd-boot — no lanzaboote / Secure Boot.
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
|
||||
networking.hostName = "TuringMachine";
|
||||
time.timeZone = "Europe/London"; # your zone
|
||||
i18n.defaultLocale = "en_US.UTF-8";
|
||||
|
||||
users.users.${username} = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
||||
};
|
||||
|
||||
# ── Power: Nomarchy's PPD (drops all custom ryzenadj/TLP tuning) ──────
|
||||
nomarchy.system.power = {
|
||||
enable = true;
|
||||
backend = "ppd"; # power-profiles-daemon
|
||||
laptop = true;
|
||||
batteryChargeLimit = 80; # optional longevity cap
|
||||
};
|
||||
|
||||
# ── AMD 7840U / Radeon 780M ──────────────────────────────────────────
|
||||
nomarchy.hardware.amd.enable = true; # amd-pstate + radeonsi VA-API
|
||||
# nomarchy.hardware.amd.rocm.enable = true; # opt-in GPU compute (multi-GB)
|
||||
|
||||
# Auto-login is NOT set here: it lives in state.json
|
||||
# (settings.greeter.autoLogin) so System › Auto-login can move it — a line
|
||||
# here would outrank the state and pin it. Turn it on after the first boot
|
||||
# with `nomarchy-autologin on` (what nomarchy-install seeds on encrypted
|
||||
# installs: the LUKS passphrase already gates access).
|
||||
|
||||
# CRITICAL: keep your ORIGINAL install's value — never Nomarchy's 26.05.
|
||||
system.stateVersion = "24.11";
|
||||
}
|
||||
```
|
||||
|
||||
> **Dropped on purpose (decisions 1 & 2):** your old
|
||||
> `modules/services/power-management.nix` ryzenadj stack, the `lanzaboote`
|
||||
> input, and the `power-max`/`power-stealth` scripts. PPD's
|
||||
> performance/balanced/power‑saver profiles (switchable from the Waybar
|
||||
> battery/profile icons and `nomarchy-menu`) replace them.
|
||||
|
||||
### `home.nix` — your apps on top of Nomarchy's desktop
|
||||
|
||||
Start from the template's `home.nix` (it ships the default app set) and add
|
||||
your personal packages/config. Carry over anything you still want (e.g. your
|
||||
emacs setup).
|
||||
|
||||
```nix
|
||||
{ pkgs, lib, ... }:
|
||||
|
||||
{
|
||||
# Nomarchy hardcodes home.stateVersion = "26.05". Moving home-manager's
|
||||
# stateVersion is low-risk, but if you want to pin your original:
|
||||
home.stateVersion = lib.mkForce "24.11";
|
||||
|
||||
home.packages = with pkgs; [
|
||||
# your extras — e.g. emacs, language toolchains, …
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### `state.json`
|
||||
|
||||
Copy the template's `state.json` (or let `nomarchy-menu theme` write
|
||||
it after the switch). Your old `nomarchy-state.nix` prototype (schema
|
||||
`nomarchy.theme = "nord"` …) is **retired** — the current distro uses this
|
||||
JSON. `nord` is a shipped Nomarchy theme, so you lose nothing.
|
||||
|
||||
### The build gate
|
||||
|
||||
```bash
|
||||
nixos-rebuild build --flake ~/nomarchy-migrate#default
|
||||
```
|
||||
|
||||
Zero activation — this only evaluates and builds the system closure. Fix any
|
||||
eval/build error here, in isolation, before touching the running machine.
|
||||
Expect to resolve a few 25.11→26.05 option renames.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Reversible activation
|
||||
|
||||
```bash
|
||||
# Activates now, but does NOT become the boot default. If the session
|
||||
# breaks, REBOOT and you are back in your old generation, untouched.
|
||||
sudo nixos-rebuild test --flake ~/nomarchy-migrate#default
|
||||
|
||||
# Bring the desktop (home-manager) up BEFORE the first graphical login.
|
||||
# Hyprland without an HM generation shows the yellow "autogenerated
|
||||
# config" banner (and no Nomarchy theming) — finish this step first.
|
||||
home-manager switch --flake ~/nomarchy-migrate#bernardo -b bak
|
||||
```
|
||||
|
||||
Log into Hyprland and sanity‑check: Waybar renders, `SUPER+M` opens the
|
||||
menu, theming is coherent, `SUPER+?` shows the cheatsheet, **no** yellow
|
||||
Hyprland autogenerated banner. Confirm the machine‑specific things you
|
||||
care about still work — suspend/hibernate, the AMD GPU (`vainfo` →
|
||||
radeonsi), display brightness.
|
||||
|
||||
You should also get a one-shot **You're set** toast (menu / themes /
|
||||
keys). If it never appears: `systemctl --user status nomarchy-first-boot`
|
||||
and re-try with
|
||||
`nomarchy-state-sync set settings.firstBootShown false --no-switch`
|
||||
then log out/in.
|
||||
|
||||
If anything is wrong: **reboot → old generation.** Nothing is committed as
|
||||
default yet.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Reconcile
|
||||
|
||||
- **Power:** verify `powerprofilesctl get` works and the Waybar battery /
|
||||
power‑profile icons open the power menu. Your ryzenadj scripts are gone;
|
||||
if you miss a specific TDP behaviour, that's a follow‑up, not a blocker.
|
||||
- **Theme:** `nomarchy-menu theme` → pick a preset (writes
|
||||
`state.json`).
|
||||
- **Snapshots:** `nomarchy-menu` → System → Snapshots should see your
|
||||
existing `@snapshots` subvolume.
|
||||
- **Fingerprint:** `fingerprint.enable = true` only starts **fprintd**
|
||||
(enrollment). Login/sudo finger auth is **`fingerprint.pam`** and is
|
||||
opt-in — leave it commented until you've enrolled. NixOS defaults PAM
|
||||
on whenever fprintd is enabled; Nomarchy forces PAM to follow the
|
||||
`pam` flag, but only after a **system** rebuild. Verify with
|
||||
`grep pam_fprintd /etc/pam.d/sudo` (should be empty when pam is off).
|
||||
With pam on, the prompt accepts password *or* finger in parallel by
|
||||
default (`fingerprint.parallel = false` for stock sequential) — see
|
||||
HARDWARE.md §5.
|
||||
- **Browser profiles:** Nomarchy does not manage Chromium/Firefox state.
|
||||
Bookmarks/extensions live under `~/.config/chromium` (or
|
||||
`~/.config/google-chrome` / ungoogled paths if that was your previous
|
||||
browser). **If your old Home Manager config declared
|
||||
`programs.chromium.extensions`, carry that block into `home.nix`
|
||||
*before* first launch** — HM installs those via
|
||||
`External Extensions/*.json`, and when the JSONs vanish Chromium
|
||||
treats every extension as externally uninstalled and deletes it
|
||||
**together with its stored data** (`Local Extension Settings` —
|
||||
wallet vaults, password-manager pairings). Bookmarks survive, which
|
||||
makes it look minor; it isn't. Recovery: close the browser, copy
|
||||
`Default/Local Extension Settings/<id>` (plus any
|
||||
`Default/IndexedDB/chrome-extension_<id>_*`) back from your Phase-0
|
||||
`pre-nomarchy-home` snapshot, then reinstall each extension — from
|
||||
the Web Store (ids are stable, so the data reattaches) or by
|
||||
re-declaring the ids if you want them declarative again.
|
||||
- **Thunderbird / Firefox — “all my email is gone” (it isn’t):** Mozilla
|
||||
apps find your data through **one small text file**,
|
||||
`~/.thunderbird/profiles.ini` (and `~/.mozilla/firefox/profiles.ini`),
|
||||
which names the profile directory to open. Lose that file and the app
|
||||
does not error — it does what it does on a brand-new machine: creates
|
||||
an empty profile and cheerfully opens *that*. Every account, folder and
|
||||
message is still on disk, in the profile dir it stopped looking at.
|
||||
This bit us on a real migration (2026‑07‑16): a 30 GB profile with 19 GB
|
||||
of `ImapMail` went “missing”; the fix was eight lines of `profiles.ini`.
|
||||
**Before first launch,** check the file exists and names your real
|
||||
profile:
|
||||
```console
|
||||
$ ls ~/.thunderbird/ # bernardo/ ← the fat one is your profile
|
||||
$ cat ~/.thunderbird/profiles.ini
|
||||
```
|
||||
**If an app opens empty, do not restore a snapshot** — look first:
|
||||
```console
|
||||
$ du -sh ~/.thunderbird/*/ # a multi-GB dir = your data is fine
|
||||
```
|
||||
If a fat profile is sitting there, this is a pointer problem, not data
|
||||
loss. Close the app, then write (`Path=` is the directory name, relative
|
||||
to the `.thunderbird` dir):
|
||||
```ini
|
||||
[Profile0]
|
||||
Name=default
|
||||
IsRelative=1
|
||||
Path=<your-profile-dir>
|
||||
Default=1
|
||||
|
||||
[General]
|
||||
StartWithLastProfile=1
|
||||
Version=2
|
||||
```
|
||||
Keep the empty profile listed as `[Profile1]` (no `Default=`) if you
|
||||
want it out of the way rather than deleted. The **restore is the risk
|
||||
here**, not the bug: rolling a snapshot over a good 30 GB profile to
|
||||
“recover” data that was never lost can cost you the mail that arrived
|
||||
since. Same shape as the Chromium bullet above — a file HM used to
|
||||
manage disappears and the app reads its own absence as “first run” —
|
||||
but inverted: Chromium *deletes* quietly, Thunderbird *loses nothing*
|
||||
and looks catastrophic.
|
||||
- **VPN:** NetworkManager connections survive under
|
||||
`/etc/NetworkManager` and your home. System › VPN lists NM
|
||||
`vpn`/`wireguard` profiles; import any that lived outside NM.
|
||||
Tailscale is opt-in (`nomarchy.services.tailscale.enable`).
|
||||
- **Secrets/services:** if you relied on agenix‑managed secrets for a
|
||||
service, layer `agenix` back into `system.nix` as a machine‑specific
|
||||
import (Nomarchy doesn't manage secrets). If you don't need them, leave
|
||||
them out — this is a full cutover.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Cutover
|
||||
|
||||
Once a test boot is solid:
|
||||
|
||||
```bash
|
||||
# Move the flake to its canonical home and make it the boot default.
|
||||
mv ~/nomarchy-migrate ~/.nomarchy
|
||||
sudo nixos-rebuild switch --flake ~/.nomarchy#default
|
||||
home-manager switch --flake ~/.nomarchy#bernardo
|
||||
|
||||
# Point /etc/nixos at the flake (optional but conventional).
|
||||
sudo mv /etc/nixos /etc/nixos.pre-nomarchy
|
||||
sudo ln -s ~/.nomarchy /etc/nixos
|
||||
```
|
||||
|
||||
From here you're on the standard Nomarchy update flow: `sys-update` (lock +
|
||||
system) then `home-update` (desktop) — always in that order (a lock bump
|
||||
before the home switch, or desktop changes are skipped against the old
|
||||
lock).
|
||||
|
||||
---
|
||||
|
||||
## Rollback, at any point
|
||||
|
||||
| Net | How |
|
||||
|---|---|
|
||||
| **NixOS generation** | Reboot → pick the previous entry in the systemd‑boot menu. |
|
||||
| **`nixos-rebuild test`** | Never sets the boot default; a reboot reverts it. |
|
||||
| **btrfs snapshot** | Restore `/.snapshots/pre-nomarchy-root` (see `docs/RECOVERY.md`). |
|
||||
| **git baseline** | `/etc/nixos.pre-nomarchy` (and the pre‑nomarchy commit) is your old config verbatim. |
|
||||
|
||||
`/home` is untouched by all of the above.
|
||||
|
||||
---
|
||||
|
||||
## Post‑migration cleanup (once you're confident)
|
||||
|
||||
- Delete the safety snapshots: `sudo btrfs subvolume delete /.snapshots/pre-nomarchy-root` (and the home one).
|
||||
- Remove `/etc/nixos.pre-nomarchy` and the old per‑host modules you cut
|
||||
(ryzenadj power‑management, lanzaboote, the `nomarchy-state.nix`
|
||||
prototype).
|
||||
- Prune old generations: `sudo nix-collect-garbage -d`.
|
||||
|
||||
### `/var/lib` machine state survives migration
|
||||
|
||||
A migration reconciles the **flake** — packages, services, dotfiles — and
|
||||
nothing else. Everything under `/var/lib` is left exactly as the previous
|
||||
OS wrote it, including settings the flake can neither see nor express. That
|
||||
state doesn't show up in `git diff`, doesn't get touched by `sys-rebuild`,
|
||||
and can sit there for months quietly shaping how the machine behaves.
|
||||
|
||||
Worked example: user lingering. Run `loginctl show-user $USER -p Linger` —
|
||||
on a Nomarchy machine the answer should be `no`, because the flake never
|
||||
sets it. On our own QA box a leftover `Linger=yes` from a pre‑Nomarchy setup
|
||||
kept the user's systemd instance alive across logouts, and it took six
|
||||
months to trace a string of subtle session bugs back to that one marker.
|
||||
Fix: `sudo loginctl disable-linger <user>`.
|
||||
|
||||
`nomarchy-doctor` now flags this specific case as a warning. More broadly:
|
||||
after migrating, if the machine ever behaves in a way the flake can't
|
||||
explain, suspect inherited state under `/var/lib` before you suspect the
|
||||
flake.
|
||||
|
||||
---
|
||||
|
||||
## Enabling hibernation on an existing machine (no reinstall)
|
||||
|
||||
New Nomarchy installs are hibernation-ready out of the box — the installer
|
||||
defaults the swapfile to **= RAM** on its own `@swap` subvolume and wires the
|
||||
resume offset. This section is for machines that have **no hibernate swap**:
|
||||
one installed with `swap = 0`, one migrated here whose reused
|
||||
`hardware-configuration.nix` carries no swapfile, or an older install
|
||||
predating the default. If `swapon --show` already lists `/swap/swapfile`,
|
||||
you have nothing to do.
|
||||
|
||||
Hibernation writes RAM to disk, and zram (compressed *RAM*) can't hold that
|
||||
image across a power-off — so you need a real disk swap ≥ the RAM you use.
|
||||
Nomarchy puts it on an `@swap` subvolume *inside* the encrypted volume, so
|
||||
the image is encrypted at rest and the initrd LUKS unlock gates resume.
|
||||
|
||||
> **Reversible:** this is one subvolume plus four config lines. Remove them
|
||||
> and rebuild — or just boot the previous generation — to undo. `/home` is
|
||||
> never touched.
|
||||
|
||||
Worked example below is **this machine**: LUKS mapper `cryptroot`, btrfs `@`.
|
||||
Substitute your own device/UUID/offset where shown.
|
||||
|
||||
### 1. Size and locate
|
||||
|
||||
```bash
|
||||
# Swap = RAM, rounded up to whole GiB (matches the installer default).
|
||||
ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)
|
||||
|
||||
# The decrypted BTRFS device backing / (strip the [subvol] suffix) and its
|
||||
# filesystem UUID — the same UUID resolves to the mapper once initrd unlocks.
|
||||
dev=$(findmnt -no SOURCE / | sed 's/\[.*//') # e.g. /dev/mapper/cryptroot
|
||||
fsuuid=$(findmnt -no UUID /) # e.g. d8e2b02d-…
|
||||
echo "swap=${ram_gb}G dev=$dev uuid=$fsuuid"
|
||||
```
|
||||
|
||||
### 2. Create the `@swap` subvolume + swapfile
|
||||
|
||||
```bash
|
||||
# Create the subvolume at the BTRFS top level (subvolid=5), beside @, @home…
|
||||
sudo mkdir -p /mnt/btrfs-top
|
||||
sudo mount -o subvolid=5 "$dev" /mnt/btrfs-top
|
||||
sudo btrfs subvolume create /mnt/btrfs-top/@swap
|
||||
sudo umount /mnt/btrfs-top && sudo rmdir /mnt/btrfs-top
|
||||
|
||||
# Mount it and create the swapfile. `mkswapfile` applies the BTRFS NOCOW
|
||||
# requirements automatically (a copy-on-write swapfile would corrupt).
|
||||
sudo mkdir -p /swap
|
||||
sudo mount -o subvol=@swap,noatime "$dev" /swap
|
||||
sudo btrfs filesystem mkswapfile --size "${ram_gb}g" --uuid clear /swap/swapfile
|
||||
```
|
||||
|
||||
### 3. Read the resume offset
|
||||
|
||||
```bash
|
||||
sudo btrfs inspect-internal map-swapfile -r /swap/swapfile # prints the offset
|
||||
```
|
||||
|
||||
### 4. Wire it into `system.nix`
|
||||
|
||||
Add these to your machine's `system.nix`, substituting your `$fsuuid` and the
|
||||
offset from step 3. The `fileSystems."/swap"` mount is **required** on this
|
||||
path — a fresh install inherits it from disko-generated
|
||||
`hardware-configuration.nix`, but a hand edit must declare it so `/swap` is
|
||||
mounted before swap activates:
|
||||
|
||||
```nix
|
||||
# Hibernation: encrypted swapfile on the @swap subvolume.
|
||||
fileSystems."/swap" = {
|
||||
device = "/dev/disk/by-uuid/<fsuuid>";
|
||||
fsType = "btrfs";
|
||||
options = [ "subvol=@swap" "noatime" ];
|
||||
};
|
||||
swapDevices = [{ device = "/swap/swapfile"; }];
|
||||
boot.resumeDevice = "/dev/disk/by-uuid/<fsuuid>";
|
||||
boot.kernelParams = [ "resume_offset=<offset>" ];
|
||||
```
|
||||
|
||||
zram stays on by default (Nomarchy sets `zramSwap` at priority 100 in
|
||||
`modules/nixos/oom.nix`); this disk swapfile sits lower, so day-to-day
|
||||
paging stays in compressed RAM and the file is reserved for the hibernate
|
||||
image — exactly the intent.
|
||||
|
||||
### 5. Rebuild and test
|
||||
|
||||
```bash
|
||||
sudo nixos-rebuild switch --flake ~/.nomarchy#default
|
||||
systemctl hibernate # or nomarchy-menu → Power → Hibernate
|
||||
```
|
||||
|
||||
The machine powers off; on the next boot you unlock LUKS once and land back
|
||||
in your session. If you get a *fresh* boot instead, the usual cause is a
|
||||
wrong `resume_offset` or a swapfile smaller than in-use RAM — re-read the
|
||||
offset (step 3) and confirm `swap ≥ RAM`.
|
||||
|
||||
**No LUKS?** Same steps; `dev`/`$fsuuid` point at the plain BTRFS partition
|
||||
and the image is unencrypted at rest. **`swap = 0` opt-out** stays valid — if
|
||||
you don't want hibernation, skip all of this; the Power-menu Hibernate row
|
||||
just reports that no swap is configured.
|
||||
|
||||
---
|
||||
|
||||
## State file rename (`theme-state.json` → `state.json`, #107)
|
||||
|
||||
The machine flake's git-tracked state file is **`state.json`** (appearance +
|
||||
menu settings). Older checkouts may still have `theme-state.json`.
|
||||
|
||||
- **Eval:** `lib.mkFlake` and the reader accept either name (prefer
|
||||
`state.json`).
|
||||
- **Write:** `nomarchy-state-sync` (and the menu) always write `state.json`
|
||||
and remove a leftover `theme-state.json` so you never have two sources.
|
||||
- **CLI:** `nomarchy-state-sync` is the real name; `nomarchy-theme-sync`
|
||||
remains a symlink for scripts and muscle memory. Drop the alias after the
|
||||
next stable release notes call it out.
|
||||
|
||||
No action required on pull: the next theme apply or menu write migrates you.
|
||||
To migrate by hand: `mv theme-state.json state.json && git add state.json`
|
||||
(and `git rm theme-state.json` if it was tracked).
|
||||
|
||||
---
|
||||
|
||||
## TuringMachine — the decisions, at a glance
|
||||
|
||||
| Item | Choice |
|
||||
|---|---|
|
||||
| Power | Nomarchy **PPD** (`backend = "ppd"`); **all ryzenadj/TLP tuning dropped** |
|
||||
| Secure Boot | **Off** — plain systemd‑boot, `lanzaboote` dropped |
|
||||
| Scope | **Full cutover** to Nomarchy's structure |
|
||||
| `system.stateVersion` | **`24.11`** (preserved from original install) |
|
||||
| Hardware | `nomarchy.hardware.amd.enable = true` (7840U / Radeon 780M) |
|
||||
| Bootloader | systemd‑boot (unchanged — already matched) |
|
||||
| Filesystem | LUKS + btrfs, existing subvolumes reused (incl. snapshot subvols) |
|
||||
126
docs/OMARCHY.md
Normal file
126
docs/OMARCHY.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Coming from Omarchy
|
||||
|
||||
Nomarchy and [Omarchy](https://omarchy.org) are cousins: the same
|
||||
Hyprland + Waybar desktop feel, the same opinionated "beautiful out of the
|
||||
box," one-command theming, and a keyboard-driven menu. If you liked
|
||||
Omarchy, you'll be at home here in minutes.
|
||||
|
||||
The one real difference is the **foundation**:
|
||||
|
||||
| | Omarchy | Nomarchy |
|
||||
|------------|---------|----------|
|
||||
| Base | Arch Linux | NixOS |
|
||||
| Config | **imperative** dotfiles you edit in `~/.config`, live | **declarative** — built from a flake; changed via the menu or your `home.nix`/`system.nix` |
|
||||
| Packages | `pacman` / `yay` / AUR, anytime | `nixpkgs` — added to your flake (no AUR) |
|
||||
| Recovery | reinstall / restore dotfiles | **every rebuild is a bootable generation** — roll back from the boot menu |
|
||||
|
||||
This page maps the day-to-day deltas. Nothing about the *desktop* has to
|
||||
be relearned — only *how you change it*.
|
||||
|
||||
---
|
||||
|
||||
## The one mental shift: declarative, not dotfiles
|
||||
|
||||
In Omarchy you open a file under `~/.config`, save it, and the change is
|
||||
live. In Nomarchy the whole desktop is **built from your flake**, so you
|
||||
change it one of two ways:
|
||||
|
||||
1. **The menu** (`SUPER+M`) — the everyday path. It writes the change into
|
||||
your flake's state and rebuilds for you. You never hand-edit a config
|
||||
for theming, night light, wallpaper, keyboard layout, power, etc.
|
||||
2. **Your flake** — for anything the menu doesn't cover, edit
|
||||
`home.nix` / `system.nix` (a handful of `nomarchy.*` options and plain
|
||||
Home-Manager/NixOS), then run a rebuild.
|
||||
|
||||
The payoff for the extra indirection: **a bad change never bricks you.**
|
||||
Every `nomarchy-rebuild` is a new NixOS generation; if one misbehaves,
|
||||
reboot and pick the previous entry from the boot menu — your files
|
||||
(`/home`) are never touched. See [RECOVERY.md](RECOVERY.md).
|
||||
|
||||
> Editing `~/.config/hypr/*` by hand won't stick — Home-Manager owns those
|
||||
> files and rewrites them on the next rebuild. Change the source (menu or
|
||||
> flake) instead.
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Omarchy** | Run the bootstrap on a fresh Arch install. |
|
||||
| **Nomarchy** | Boot the ISO and run **`nomarchy-install`** (single-disk UEFI, optional LUKS, hibernation-ready swap). |
|
||||
| **Already on NixOS?** | Convert in place, no reformat — [MIGRATION.md](MIGRATION.md). |
|
||||
|
||||
---
|
||||
|
||||
## Keybindings (they'll feel familiar)
|
||||
|
||||
The full, always-current list is one keypress away: **`SUPER+?`** opens the
|
||||
live cheatsheet — you never have to memorize this table.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `SUPER+Return` | Terminal |
|
||||
| `SUPER+Space` | App launcher |
|
||||
| `SUPER+M` | Main menu (theme, network, audio, power, tools…) |
|
||||
| `SUPER+T` | Theme picker |
|
||||
| `SUPER+SHIFT+T` | Next wallpaper |
|
||||
| `SUPER+E` | File manager (yazi) |
|
||||
| `SUPER+X` | Power menu (lock / suspend / hibernate / reboot …) |
|
||||
| `SUPER+CTRL+L` | Lock screen |
|
||||
| `SUPER+?` | Keybindings cheatsheet |
|
||||
| `Print` | Screenshot region → clipboard |
|
||||
| `SUPER+CTRL+<key>` | Jump straight to a menu module (clipboard `V`, calc `C`, emoji `E`, capture `S`, …) |
|
||||
|
||||
---
|
||||
|
||||
## Theming
|
||||
|
||||
Both distros reskin everything from one place. In Nomarchy:
|
||||
|
||||
- **`SUPER+T`** (or `nomarchy-menu theme`) — pick from 24 presets with live
|
||||
previews; it rebuilds and applies to Hyprland, Waybar, the terminal,
|
||||
btop and GTK/Qt at once.
|
||||
- Or from a shell: `nomarchy-state-sync apply <slug>` (e.g. `gruvbox`,
|
||||
`rose-pine`, `nord`).
|
||||
- Tweak gaps / rounding / fonts / opacity / icon pack without leaving the
|
||||
theme system — see [OVERRIDES.md](OVERRIDES.md). One git-tracked JSON
|
||||
(`state.json`) is the single source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Installing software
|
||||
|
||||
There is no AUR. Two paths:
|
||||
|
||||
- **Keep it:** add the package to `home.packages` in your flake's
|
||||
`home.nix`, then `nomarchy-home`. It's now declared, reproducible, and
|
||||
part of your rollback history. A curated default app set already ships —
|
||||
you extend the list.
|
||||
- **Just this once:** `nix shell nixpkgs#<pkg>` drops you into a shell with
|
||||
it on `PATH`, gone when you exit — the equivalent of a throwaway install.
|
||||
|
||||
Search with `nsearch <name>` (`nix search nixpkgs`).
|
||||
|
||||
---
|
||||
|
||||
## Update & roll back
|
||||
|
||||
| | Omarchy | Nomarchy |
|
||||
|---|---|---|
|
||||
| **Update** | update command | `nomarchy-pull` (bump inputs), then `nomarchy-rebuild` (system) + `nomarchy-home` (desktop) |
|
||||
| **Roll back** | restore dotfiles | reboot → pick the previous **generation** in the boot menu (or a snapper snapshot) |
|
||||
| **Health check** | — | `nomarchy-doctor` (System › Doctor) — read-only pass/fail sheet, each ✖ shows its fix |
|
||||
|
||||
On a failed rebuild, `nomarchy-rebuild` / `nomarchy-home` print the last
|
||||
log lines and point you at `nomarchy-doctor` and [RECOVERY.md](RECOVERY.md)
|
||||
— you're never left guessing.
|
||||
|
||||
---
|
||||
|
||||
## What's exactly the same
|
||||
|
||||
Hyprland tiling and feel, Waybar, the rofi-driven menu, the blur/rounding
|
||||
aesthetic, keyboard-first everything, and strong opinionated defaults. The
|
||||
desktop is the part you already know — this page is only about the NixOS
|
||||
plumbing underneath.
|
||||
@@ -9,18 +9,74 @@ rule of thumb:
|
||||
|
||||
## 1. Appearance (gaps, colors, rounding, fonts, opacity) — use the CLI
|
||||
|
||||
Everything that defines the *look* flows from `theme-state.json`, the single
|
||||
source of truth. Change it with `nomarchy-theme-sync`, which writes the JSON
|
||||
and rebuilds — one generation, applied to Hyprland, Waybar, Ghostty, btop and
|
||||
Everything that defines the *look* flows from `state.json`, the single
|
||||
source of truth. Change it with `nomarchy-state-sync`, which writes the JSON
|
||||
and rebuilds — one generation, applied to Hyprland, Waybar, Kitty, btop and
|
||||
Stylix at once:
|
||||
|
||||
```sh
|
||||
nomarchy-theme-sync set ui.gapsOut 16 # gaps, borders, rounding, opacity
|
||||
nomarchy-theme-sync set ui.rounding 0
|
||||
nomarchy-theme-sync set fonts.mono "FiraCode Nerd Font"
|
||||
nomarchy-theme-sync apply gruvbox # whole palette
|
||||
nomarchy-state-sync set ui.gapsOut 16 # gaps, borders, rounding, opacity
|
||||
nomarchy-state-sync set ui.rounding 0
|
||||
nomarchy-state-sync set fonts.mono "FiraCode Nerd Font"
|
||||
nomarchy-state-sync apply gruvbox # whole palette
|
||||
```
|
||||
|
||||
### Icon pack
|
||||
|
||||
Icons follow the active theme's light/dark mode using **Papirus** — the only
|
||||
icon pack shipped by default (Papirus alone is ~1 GiB, so extra packs are
|
||||
opt-in rather than a cost every install pays). To switch to another pack:
|
||||
|
||||
```sh
|
||||
nomarchy-state-sync set icons "Tela-dark" # or "" to return to Papirus-by-mode
|
||||
```
|
||||
|
||||
Only the pack you name is pulled into your system (the first switch downloads
|
||||
it). Known packs and an example theme name from each:
|
||||
|
||||
| Set `icons` to… | Pack |
|
||||
|---|---|
|
||||
| `Papirus`, `Papirus-Dark`, `Papirus-Light` | Papirus (default; `""` auto-picks Dark/Light by mode) |
|
||||
| `Tela`, `Tela-dark`, `Tela-<color>[-dark]` | Tela (colors: blue, green, red, purple, nord, dracula, …) |
|
||||
| `Qogir`, `Qogir-Dark`, `Qogir-Light` | Qogir (note the capital D/L) |
|
||||
| `Reversal`, `Reversal-dark` | Reversal |
|
||||
| `Numix-Circle`, `Numix-Circle-Light` | Numix Circle |
|
||||
|
||||
The choice is a **sticky global override** — it survives `apply <palette>`
|
||||
switches (presets don't carry an icon field). To add a pack that isn't listed,
|
||||
append a row to `iconPacks` in `modules/home/theme.nix`. Set `icons` to `""`
|
||||
any time to drop back to Papirus with automatic Dark/Light.
|
||||
|
||||
### Auto theme (day/night)
|
||||
|
||||
Switch automatically between a light **day** theme and a dark **night** theme
|
||||
on a schedule — the same one engine as a manual `apply`, no second pipeline.
|
||||
Turn it on from **Look & Feel › Auto theme** (`SUPER+M`): toggle it, pick the
|
||||
day and night themes, and set the sunrise/sunset times. The same from a shell:
|
||||
|
||||
```sh
|
||||
nomarchy-state-sync set settings.autoTheme.day summer-day --no-switch
|
||||
nomarchy-state-sync set settings.autoTheme.night summer-night --no-switch
|
||||
nomarchy-state-sync set settings.autoTheme.sunset 20:00 --no-switch
|
||||
nomarchy-state-sync set settings.autoTheme.enable true --no-switch
|
||||
nomarchy-state-sync auto --force # one rebuild: installs the timer + applies now
|
||||
```
|
||||
|
||||
State lives in `settings.autoTheme`:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `enable` | on / off |
|
||||
| `day` / `night` | theme slugs (see `nomarchy-state-sync list`) — the exclusive pairs are made for this: `boreal-dawn`/`boreal`, `executive-ivory`/`executive-slate`, `kiln-clay`/`kiln`, `summer-day`/`summer-night` |
|
||||
| `sunrise` / `sunset` | switch times, `"HH:MM"` (24-hour) |
|
||||
|
||||
A timer fires exactly at the configured sunrise and sunset (plus once shortly
|
||||
after login, and it catches up on a transition missed while the machine was
|
||||
off or asleep) — it only rebuilds when the active theme actually needs to
|
||||
change. The times are baked into the timer, so editing them (or enabling)
|
||||
rebuilds once; disabling is instant. Preview the current decision without
|
||||
switching with `nomarchy-state-sync auto --which`.
|
||||
|
||||
These values are deliberately kept at normal priority in the modules, so they
|
||||
stay owned by the theme system. If you *insist* on pinning one in `home.nix`
|
||||
regardless of the active theme, use `lib.mkForce` (see §4) — but then the CLI
|
||||
@@ -39,17 +95,21 @@ in your `home.nix` wins — no `mkForce` needed:
|
||||
input.follow_mouse = 0; # was 1
|
||||
input.touchpad.natural_scroll = false;
|
||||
misc.disable_splash_rendering = false;
|
||||
monitor = [ "DP-1,2560x1440@144,0x0,1" ]; # replaces the default rule
|
||||
monitor = [ "DP-1,2560x1440@144,0x0,1" ]; # raw rule — replaces the default
|
||||
animations.enabled = false;
|
||||
};
|
||||
|
||||
programs.ghostty.settings = {
|
||||
window-padding-x = 4; # was 12
|
||||
window-decoration = true;
|
||||
programs.kitty.settings = {
|
||||
window_padding_width = 4; # was 12
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
For monitor layout, prefer the friendlier **`nomarchy.monitors`** (a list of
|
||||
per-output submodules — resolution/position/scale/rotation — turned into
|
||||
Hyprland rules and applied on hotplug; run `nwg-displays` to find the values
|
||||
interactively). Assigning `settings.monitor` directly, as above, replaces it.
|
||||
|
||||
### Adding vs. overriding lists
|
||||
|
||||
`bind`, `bindel`, `bindl`, `bindm` and `exec-once` are lists kept at normal
|
||||
@@ -61,7 +121,7 @@ autostarts run *alongside* the defaults:
|
||||
wayland.windowManager.hyprland.settings = {
|
||||
bind = [
|
||||
"$mod, B, exec, firefox"
|
||||
"$mod SHIFT, S, exec, grim -g \"$(slurp)\" - | swappy -f -"
|
||||
"$mod SHIFT, S, exec, grim -g \"$(slurp)\" - | satty --filename -"
|
||||
];
|
||||
exec-once = [ "nm-applet --indicator" ];
|
||||
};
|
||||
@@ -138,8 +198,9 @@ value is theme-owned at normal priority — either change it via the CLI (§1) o
|
||||
|
||||
| You want to… | Do this |
|
||||
|---|---|
|
||||
| Change gaps / colors / rounding / fonts | `nomarchy-theme-sync set …` or `apply` |
|
||||
| Change gaps / colors / rounding / fonts | `nomarchy-state-sync set …` or `apply` |
|
||||
| Change input / misc / monitor / animations / terminal chrome | plain assignment in `home.nix` |
|
||||
| Arrange monitors declaratively | `nomarchy.monitors` (values via `nwg-displays`) |
|
||||
| Add keybinds / autostarts | add to the `bind` / `exec-once` list (concatenates) |
|
||||
| Replace all keybinds | `bind = lib.mkForce [ … ]` |
|
||||
| Hardcode an appearance value against the theme | `lib.mkForce` in `home.nix` |
|
||||
|
||||
42
docs/README.md
Normal file
42
docs/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Docs map
|
||||
|
||||
Where human and agent documentation lives. **Do not** invent a third
|
||||
tree for the same facts.
|
||||
|
||||
| Path | Audience | Role |
|
||||
|------|----------|------|
|
||||
| [../README.md](../README.md) | Everyone | What Nomarchy is, install, options tables |
|
||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | Users + agents | Minimum system requirements (GPU/OpenGL, RAM, disk, UEFI) |
|
||||
| [VISION.md](VISION.md) | Maintainers + agents | Product north star toward **v1.0** and beyond — themes, not a task queue |
|
||||
| [ROADMAP.md](ROADMAP.md) | Maintainers + agents | Design/decision records + shipped log (historical ✓) |
|
||||
| [HARDWARE.md](HARDWARE.md) | Users + agents | Firmware, profiles, drivers, unsupported machines |
|
||||
| [TESTING.md](TESTING.md) | Maintainers + agents | Verification ladder, honesty rule, ISO/VM recipes |
|
||||
| [RECOVERY.md](RECOVERY.md) | Users | Broken theme/desktop/boot → undo |
|
||||
| [OVERRIDES.md](OVERRIDES.md) | Users | Downstream Nix overrides |
|
||||
| [MIGRATION.md](MIGRATION.md) | Users | Existing NixOS → Nomarchy without reinstall |
|
||||
| [OMARCHY.md](OMARCHY.md) | Users | Coming from Omarchy — bindings/theme/install/config map |
|
||||
|
||||
## Related (not under `docs/`)
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| [../AGENTS.md](../AGENTS.md) | Agent entry point, any vendor/harness (`CLAUDE.md` symlinks to it) |
|
||||
| [../agent/README.md](../agent/README.md) | Agent instructions + **executable** loop state: BACKLOG, LOOP, VERIFICATION, … |
|
||||
| [../.claude/](../.claude/) | Claude Code adapter only: permissions + subagent defs |
|
||||
|
||||
## How work flows
|
||||
|
||||
```
|
||||
VISION (what we want the product to feel like)
|
||||
│
|
||||
▼ human triages slices into…
|
||||
BACKLOG (what's next, ordered — agents execute only this)
|
||||
│
|
||||
▼ lasting design notes after ship →
|
||||
ROADMAP ✓ entries
|
||||
```
|
||||
|
||||
Agents **do not** implement directly from VISION or ROADMAP. They take
|
||||
the top actionable item in `agent/BACKLOG.md` (see `agent/LOOP.md`).
|
||||
They **may** append PROPOSED pitches that reference `VISION § …` or
|
||||
`ROADMAP § …`.
|
||||
136
docs/RECOVERY.md
Normal file
136
docs/RECOVERY.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Recovery runbook — when something breaks
|
||||
|
||||
Ordered from "the desktop looks wrong" to "the machine won't boot".
|
||||
Everything here already ships on an installed machine — you don't need a
|
||||
live USB until the last resort. The theme is always the same: **nothing
|
||||
in Nomarchy is destroyed by a bad change** — every rebuild is a NixOS /
|
||||
Home Manager generation you can step back to, and (on BTRFS installs)
|
||||
snapper keeps file-level history on top.
|
||||
|
||||
If the graphical session is unusable, a text console is one keystroke
|
||||
away: **Ctrl+Alt+F2** gives a TTY login (the session itself runs on
|
||||
tty1); log in with your normal user.
|
||||
|
||||
## 1. A theme or desktop change broke the session
|
||||
|
||||
Theme applies and `home-update` are Home Manager switches — one
|
||||
generation each, so the previous desktop is still on disk:
|
||||
|
||||
```sh
|
||||
home-manager generations # newest first, one per theme/HM change
|
||||
/nix/store/…-home-manager-generation/activate # run the one you want
|
||||
```
|
||||
|
||||
The same picker lives in the menu: **System › Recovery › Desktop
|
||||
generation** lists the recent Home Manager generations — pick one and
|
||||
it activates. System-level undo is **System › Recovery › System boot
|
||||
generation** (boot menu) or **Files (BTRFS)** (snapper).
|
||||
|
||||
Or simply apply a theme you know is good: `nomarchy-state-sync apply
|
||||
boreal` (or any preset). If a switch failed halfway, the state file is written
|
||||
*before* the rebuild — fix the cause and re-run
|
||||
`home-manager switch --flake ~/.nomarchy` (or `nomarchy-home`).
|
||||
|
||||
Your flake checkout is a git repo, and with auto-commit enabled every
|
||||
apply is a commit — and every `nomarchy-pull`/`-rebuild`/`-home` first
|
||||
sweeps pending hand edits into one, so history mirrors your generations:
|
||||
`git -C ~/.nomarchy log` to see what changed, `git revert` the culprit,
|
||||
then `nomarchy-home`.
|
||||
|
||||
## 2. The desktop won't start at all
|
||||
|
||||
Greeter loops, black screen after the password, session exits straight
|
||||
back to tuigreet — from the Ctrl+Alt+F2 TTY:
|
||||
|
||||
```sh
|
||||
journalctl -b -u greetd # did the session command launch?
|
||||
journalctl --user -b # Hyprland + the user services
|
||||
```
|
||||
|
||||
- Rolling back the *desktop* half is §1 (works from the TTY).
|
||||
- If greetd/tuigreet itself is broken, that's system-side → §3.
|
||||
- Password rejected on an external keyboard but definitely correct? The
|
||||
greeter uses the system keyboard layout, not a remembered per-device one
|
||||
— see the "Greeter keyboard layout" note in README.md § options.
|
||||
- In a **VM**, a black screen is almost always missing guest OpenGL,
|
||||
not your config — see docs/TESTING.md §5.
|
||||
- First boot after an install came up *unthemed*: read
|
||||
`/var/log/nomarchy-hm-preactivate.log` on the installed system.
|
||||
|
||||
## 3. A system change broke it — boot an older generation
|
||||
|
||||
Reboot and pick an older **NixOS generation** in the systemd-boot menu
|
||||
(hold a key during firmware handoff if the menu doesn't linger). That
|
||||
boots yesterday's system unchanged.
|
||||
|
||||
Booting an old generation is temporary — the default entry is still the
|
||||
broken one. Make the fix stick from the working boot: revert the change
|
||||
in `~/.nomarchy` (`git -C ~/.nomarchy revert …` or edit `system.nix`
|
||||
back), then `nomarchy-rebuild`.
|
||||
|
||||
How long generations stick around (and how cleanup works) is §4 below.
|
||||
|
||||
## 4. How generations are kept (and cleaned up)
|
||||
|
||||
Every `nomarchy-rebuild` and `nomarchy-home` (and every theme switch) leaves
|
||||
a **generation** — a full previous system or desktop you can roll back to
|
||||
(§1 and §3, and **System › Recovery**). Those take disk space until they
|
||||
are removed.
|
||||
|
||||
Nomarchy prunes them **automatically once a week** with a policy designed
|
||||
not to strand you without rollback:
|
||||
|
||||
| Rule | Meaning |
|
||||
|------|---------|
|
||||
| **Age** | Generations older than **14 days** are eligible for removal. |
|
||||
| **Safety floor** | Always keep the **current** generation and at least **3 past** ones — even if those three are older than 14 days. |
|
||||
| **Both halves** | The same rule applies independently to the **system** (boot menu) and to **Home Manager** (desktop) profiles. |
|
||||
|
||||
So a busy machine that rebuilds often can still free old junk after two
|
||||
weeks, while a rarely rebuilt laptop never drops below four usable
|
||||
system gens (current + three past) or four desktop gens.
|
||||
|
||||
**What you do not need to do:** run aggressive `nix-collect-garbage -d`
|
||||
day to day — that can delete *all* old generations and wipe the floor.
|
||||
The weekly timer already reclaims store paths after a careful prune.
|
||||
|
||||
**Manual control:**
|
||||
|
||||
```sh
|
||||
sudo nomarchy-gen-prune --dry-run # list what would be deleted
|
||||
sudo nomarchy-gen-prune # prune now (same rules as the timer)
|
||||
systemctl status nomarchy-gen-prune.timer
|
||||
```
|
||||
|
||||
After a system prune the boot menu is refreshed so removed generations
|
||||
no longer appear as boot entries.
|
||||
|
||||
## 5. Files went missing or wrong — snapshots (BTRFS installs)
|
||||
|
||||
With `nomarchy.system.snapper.enable` (the installer's default on
|
||||
BTRFS), the root filesystem has hourly/daily history, and
|
||||
`nixos-rebuild-snap` leaves a snapshot right before a rebuild:
|
||||
|
||||
- **GUI:** menu › System › Recovery › Files (BTRFS) (btrfs-assistant;
|
||||
expects a polkit password prompt).
|
||||
- **Terminal/SSH:** `sudo nomarchy-snapshots` — browse a snapshot's
|
||||
diff, **restore changed files** (snapper `undochange`), or **roll the
|
||||
whole root back** to a snapshot and reboot. Both destructive actions
|
||||
sit behind a typed-`yes` confirmation.
|
||||
|
||||
Snapshots are the undo for *data on disk*; the Nix config model is
|
||||
undone by generations (§1/§3) — use each for its half.
|
||||
|
||||
## 6. Last resort — from the outside
|
||||
|
||||
Boot the Nomarchy ISO (any NixOS ISO works), then:
|
||||
|
||||
```sh
|
||||
sudo mount /dev/<root> /mnt # + /mnt/boot; LUKS: cryptsetup open first
|
||||
sudo nixos-enter --root /mnt # chroot with nix available
|
||||
```
|
||||
|
||||
From there you have the full toolbox: `nixos-rebuild boot --flake
|
||||
/home/<you>/.nomarchy#default` after fixing the flake, or snapper from
|
||||
§5. If you get this far with something Nomarchy shipped broken, please
|
||||
file it.
|
||||
160
docs/REQUIREMENTS.md
Normal file
160
docs/REQUIREMENTS.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Minimum system requirements
|
||||
|
||||
What Nomarchy expects of a machine. This is a **living floor**, not a
|
||||
marketing sheet — numbers come from measured installs and known software
|
||||
floors (NixOS, the ISO, desktop GPU). When a real machine pushes a bound, update
|
||||
this file in the same change.
|
||||
|
||||
Related: [HARDWARE.md](HARDWARE.md) (profiles/drivers),
|
||||
[TESTING.md](TESTING.md) (ISO/VM), [README](../README.md) § install.
|
||||
|
||||
---
|
||||
|
||||
## Supported platform (hard)
|
||||
|
||||
| Requirement | Floor | Why |
|
||||
|-------------|-------|-----|
|
||||
| Architecture | **x86_64** | Only target the flake builds today |
|
||||
| Firmware | **UEFI** | Installer uses systemd-boot; BIOS/legacy is LATER |
|
||||
| Install path | Whole disk (disko) | No dual-boot / partial-disk path yet |
|
||||
|
||||
---
|
||||
|
||||
## Graphics (default desktop)
|
||||
|
||||
The sole terminal is **Kitty** (GPU-accelerated OpenGL, themed from
|
||||
state.json). It runs on older integrated GPUs that reject Ghostty’s OpenGL
|
||||
**4.3** floor — notably **Intel HD 4000 / Ivy Bridge** (Acer Aspire M5-481T
|
||||
reported OpenGL **4.2** and could not start Ghostty). That is why Nomarchy
|
||||
standardized on Kitty only: one terminal to theme, one set of classed windows
|
||||
(doctor / calendar), no dual-stack or GL probe at install.
|
||||
|
||||
| Generation (examples) | Notes |
|
||||
|-----------------------|--------|
|
||||
| Ivy Bridge / HD 4000 (~2012) | Supported with Kitty (verified path on Acer M5-481T) |
|
||||
| Haswell+ Intel, modern AMD, discrete GPUs | Supported |
|
||||
| SoftGL / virtio-gpu VMs | Hyprland needs GL for the session; Kitty is best-effort under softGL |
|
||||
|
||||
Other Wayland clients vary; Hyprland itself is usually fine on these GPUs.
|
||||
A working GPU (or software GL for VMs) is still required for the desktop
|
||||
session — only the *terminal* no longer demands OpenGL 4.3.
|
||||
|
||||
---
|
||||
|
||||
## CPU and memory
|
||||
|
||||
| Resource | Practical minimum | Comfortable |
|
||||
|----------|-------------------|-------------|
|
||||
| CPU | 64-bit x86_64, dual-core | Quad-core or better (rebuilds) |
|
||||
| RAM | **8 GiB** | **16 GiB+** |
|
||||
|
||||
NixOS + Home Manager rebuilds and browser/Electron apps are memory-hungry.
|
||||
Hibernation (default installer path) sizes a **swapfile ≈ RAM**, so low RAM
|
||||
also means less disk eaten by swap — but also less headroom when building.
|
||||
|
||||
---
|
||||
|
||||
## Storage (the important one)
|
||||
|
||||
Nomarchy is a full NixOS desktop with:
|
||||
|
||||
1. **The Nix store** — every generation keeps package closures; updates add
|
||||
new paths until you GC. Many packages appear once per generation when
|
||||
inputs move.
|
||||
2. **Home Manager generations** — desktop switches and `nomarchy-home` leave
|
||||
profiles behind until cleaned.
|
||||
3. **BTRFS + snapper** — `@snapshots` timeline (hourly/daily) and
|
||||
pre-rebuild snaps. Excellent for recovery; **they retain data** and grow
|
||||
with how full the live filesystem is.
|
||||
4. **Optional hibernation swapfile** — default size = **RAM** (e.g. 16 GiB
|
||||
RAM → 16 GiB swapfile on disk).
|
||||
|
||||
Closure size is **not** the same as disk use after hardlink optimisation
|
||||
and is **not** the ISO size (see ROADMAP § chromium / #121 measurements).
|
||||
Still, plan for **growth**, not for a single install footprint.
|
||||
|
||||
### Measured / known artifacts (order of magnitude)
|
||||
|
||||
| Artifact | Size (approx.) | Source |
|
||||
|----------|----------------|--------|
|
||||
| Offline live ISO | **~8.1 GiB** | Built image, 2026-07 (#103 era) |
|
||||
| Template HM closure (nominal) | **~9.4 GiB** | `nix path-info` style figures in ROADMAP |
|
||||
| Desktop store weight (uncompressed, pre-dedupe order) | **tens of GiB** | ISO pin / offline pin analysis (#120) |
|
||||
|
||||
A **fresh** install on empty disk is smaller than a machine that has been
|
||||
updated and snapshotted for months.
|
||||
|
||||
### Recommended free disk (whole-disk install)
|
||||
|
||||
These are **planning floors** for the target SSD/HDD the installer will wipe
|
||||
and use entirely — not “free space beside Windows.”
|
||||
|
||||
| Use | Capacity | Notes |
|
||||
|-----|----------|--------|
|
||||
| Absolute minimum to finish install + first boot | **≥ 40 GiB** | Tight; little room for GC delay or snapshots |
|
||||
| Realistic daily driver | **≥ 64 GiB** | Short GC/snap retention still required |
|
||||
| Comfortable (updates + snapper + apps) | **≥ 128 GiB** | Default recommendation |
|
||||
| Power user / many generations / big apps | **256 GiB+** | Dev toolchains, Steam, local datasets |
|
||||
|
||||
**Plus swap:** if you keep hibernation (default), add **~RAM** on top of the
|
||||
table (installer creates `/swap/swapfile` sized to RAM unless you set 0).
|
||||
|
||||
Example: 16 GiB RAM laptop → plan **≥ 128 GiB disk**, of which ~16 GiB is
|
||||
swapfile, leaving room for store + snapshots.
|
||||
|
||||
### Why Nix “duplicates everything”
|
||||
|
||||
- Each **system** and **home** generation points at a closure of store
|
||||
paths. Unchanged paths are shared (same `/nix/store/…` hash); changed
|
||||
inputs pull **new** paths. After several updates you hold old + new until
|
||||
`nix-collect-garbage` / generation deletion.
|
||||
- **Snapper** snapshots the BTRFS subvolumes. A snapshot is cheap until you
|
||||
rewrite a lot of data; then it retains the old blocks. Timeline + rebuild
|
||||
snaps mean “I deleted that 2 GB download” may not free space until snaps
|
||||
expire.
|
||||
- The live **ISO** is large because it pins an offline install closure; that
|
||||
is a download/USB cost, not permanent free space on the installed machine
|
||||
after install (the installed system has its own store).
|
||||
|
||||
**Hygiene (automatic on Nomarchy):** a weekly `nomarchy-gen-prune` removes
|
||||
system and Home Manager generations that are **older than 14 days** and
|
||||
**beyond the three most recent past gens** (current + ≥3 past always kept),
|
||||
then reclaims the store. Details and manual commands:
|
||||
[RECOVERY.md §4](RECOVERY.md#4-how-generations-are-kept-and-cleaned-up).
|
||||
Also tune snapper retention for BTRFS file history (System › Recovery ›
|
||||
Files). This requirements page only states why the disk fills up.
|
||||
|
||||
---
|
||||
|
||||
## What we explicitly do **not** require
|
||||
|
||||
| Non-requirement | Note |
|
||||
|-----------------|------|
|
||||
| NVIDIA as first-class | Deferred past v1 (PROPOSED); community/docs only |
|
||||
| Secure Boot enrolled out of the box | Not the current install path |
|
||||
| Touch / tablet as primary input | Untested as a bar |
|
||||
| < 8 GiB RAM as a supported target | May boot; rebuilds and browsers will thrash |
|
||||
|
||||
---
|
||||
|
||||
## Machines that defined these bounds
|
||||
|
||||
| Machine | Role |
|
||||
|---------|------|
|
||||
| **Acer Aspire M5-481T** (~2012, Intel HD 4000) | Old-HW QA; drove Kitty-only terminal (#95) |
|
||||
| **Dell XPS 9350** (Skylake) | Install bake (#123) |
|
||||
| AMD dev box / Latitude / T14s | Ongoing V3 (HARDWARE-QUEUE) |
|
||||
|
||||
When you install on something older or smaller than the table, file what
|
||||
broke in `agent/BACKLOG.md` and tighten this page.
|
||||
|
||||
---
|
||||
|
||||
## Related work
|
||||
|
||||
| Item | Topic |
|
||||
|------|--------|
|
||||
| BACKLOG **#95** | Kitty as sole terminal (shipped) |
|
||||
| BACKLOG **#120** | Lighter netinstall ISO (download size ≠ installed size) |
|
||||
| BACKLOG **#123** | Installer HM pre-activate (first-boot polish) |
|
||||
| BACKLOG **#124** | Flake pin vs main/v1 lag after install |
|
||||
1730
docs/ROADMAP.md
1730
docs/ROADMAP.md
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@ than claiming success. "All Nix files parse" is not "the bar renders."
|
||||
```sh
|
||||
nix flake check --no-build # full module-system evaluation, no builds
|
||||
nix-instantiate --parse <file> # syntax-only, works even on macOS
|
||||
python3 -m py_compile pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
|
||||
git ls-files '*.py' | xargs python3 -m py_compile # all tracked Python
|
||||
bash -n <script>.sh # shell syntax (tools/, installer bits)
|
||||
```
|
||||
|
||||
`nix flake check` needs a Linux machine (or a Linux builder) since all
|
||||
@@ -22,6 +23,17 @@ bad merges — most breakage stops here. It also evaluates the downstream
|
||||
template through `lib.mkFlake` (including a real nixos-hardware profile),
|
||||
so template/wrapper drift fails fast too.
|
||||
|
||||
## 1b. CI (automatic on push)
|
||||
|
||||
Every push to `main`/`v1` runs `.gitea/workflows/check.yml`: the §1
|
||||
cheap checks (flake eval, Python + shell syntax) on the Gitea instance.
|
||||
That's the **eval tier only** — the runner is a docker container without
|
||||
KVM, so the `checks.*` VM suite and real builds stay local (this file)
|
||||
until a KVM-capable runner is registered; the workflow carries a
|
||||
commented `vm-checks` job ready for that day. A green CI run is *not* "it
|
||||
renders" (the honesty rule below still applies) — it means "nobody broke
|
||||
evaluation".
|
||||
|
||||
## 2. Build and boot the live ISO
|
||||
|
||||
```sh
|
||||
@@ -35,7 +47,7 @@ nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage
|
||||
|
||||
The script prefers UEFI (OVMF) with a legacy-BIOS fallback, uses KVM when
|
||||
`/dev/kvm` is readable, and boots with `virtio-vga-gl` + `gl=on` —
|
||||
**Hyprland and Ghostty need real OpenGL in the guest**; without it the
|
||||
**Hyprland (and the desktop session) need real OpenGL in the guest**; without it the
|
||||
session may not start.
|
||||
|
||||
### What the live environment gives you
|
||||
@@ -44,8 +56,11 @@ session may not start.
|
||||
Hyprland via `initial_session`; logging out lands on tuigreet.
|
||||
- The flake is seeded writable at `~/.nomarchy` (from the read-only
|
||||
`/etc/nomarchy` copy) — `$NOMARCHY_PATH` already points there.
|
||||
- Locked flake inputs are pinned into the ISO store, so
|
||||
`home-manager switch` works **without a network**.
|
||||
- Locked flake inputs are pinned into the ISO store, so a
|
||||
`home-manager switch` for the **already-active / default theme** works
|
||||
**without a network**. Switching to an arbitrary other preset can still
|
||||
need downloads (that preset's HM generation is not in the pin) — the
|
||||
tool should fail with an offline-oriented message (#113).
|
||||
|
||||
## 3. Verification checklist
|
||||
|
||||
@@ -53,14 +68,14 @@ Work through these in order; each one exercises a different layer.
|
||||
|
||||
| # | Check | Verifies |
|
||||
|---|---|---|
|
||||
| 1 | Boots to Hyprland with Tokyo Night wallpaper visible | greetd autologin, awww, session start |
|
||||
| 1 | Boots to Hyprland with the default theme wallpaper visible (Boreal) | greetd autologin, awww, session start |
|
||||
| 2 | Waybar shows at top, themed (blue accent on dark) | HM waybar module, palette baking |
|
||||
| 3 | `SUPER+Return` opens Ghostty with Tokyo Night colors | terminal default, ANSI palette |
|
||||
| 3 | `SUPER+Return` opens Kitty with the default theme colors (Boreal) | terminal default, ANSI palette |
|
||||
| 4 | `btop` in the terminal is themed | per-theme asset baking |
|
||||
| 5 | `nomarchy-theme-sync list` prints 21 presets | package, baked themes dir |
|
||||
| 6 | `nomarchy-theme-sync apply gruvbox` → state written, `home-manager switch` runs, desktop re-themes, wallpaper changes | the whole engine: state write, pure eval, HM rebuild, wallpaper hook |
|
||||
| 7 | `SUPER+SHIFT+T` cycles wallpapers instantly (try `tokyo-night`: 4 of them) | the runtime wallpaper path |
|
||||
| 8 | `nomarchy-theme-sync apply summer-night` → after the switch the bar has its own identity (light bar, different styling) | whole-swap waybar.css assets |
|
||||
| 5 | `nomarchy-state-sync list` prints 24 presets | package, baked themes dir |
|
||||
| 6 | `nomarchy-state-sync apply boreal` (or re-apply the **current** theme) offline → switch succeeds. Applying a *different* preset may need a network — the ISO pins the default theme's generation, not every preset (#113) | state write + offline rebuild contract |
|
||||
| 7 | `SUPER+SHIFT+T` cycles wallpapers instantly (try `tokyo-night` for 4, or `boreal` for its set) | the runtime wallpaper path |
|
||||
| 8 | `nomarchy-state-sync apply summer-night` → after the switch the bar has its own identity (light bar, different styling) | whole-swap waybar.css assets |
|
||||
| 9 | Open a GTK app — dark theme matching the palette | Stylix layer |
|
||||
| 10 | `home-manager generations` lists one generation per theme change; activating an older one rolls the theme back | atomicity / rollback story |
|
||||
|
||||
@@ -93,6 +108,27 @@ unattended env it uses is in the script, and the same flow works
|
||||
interactively from the live terminal. If the desktop comes up unthemed,
|
||||
read `/var/log/nomarchy-hm-preactivate.log` on the installed system.
|
||||
|
||||
`tools/plymouth-preview.sh` renders the **boot splash** — including the LUKS
|
||||
password dialog — into X11 windows in about ten seconds: no reboot, no root,
|
||||
and no DRM. It runs plymouthd inside `unshare -rm` (a user + mount namespace),
|
||||
binds a writable dir over `/run/plymouth` (which is both the daemon's socket
|
||||
dir *and* its compiled-in plugin path), masks `/dev/dri` so a preview can never
|
||||
mode-set a real output, and sets `PLY_CREATE_FAKE_MULTI_HEAD_SETUP` for plymouth's
|
||||
built-in fake dual head. Pass it a theme store path to iterate on the script
|
||||
without a rebuild. Use it before touching `modules/nixos/plymouth/`: that script
|
||||
draws the passphrase box, a failure there is silent (the plugin logs
|
||||
`starting boot animation` and no error), and "type your LUKS password blind" is
|
||||
a bad way to find out. Its limit: the fake heads exist from the start, so it
|
||||
cannot reproduce a head arriving *mid-splash* (#137).
|
||||
|
||||
Two full-desktop VM harnesses (softGL Hyprland, too heavy for `checks.*`)
|
||||
live next to the scripts: `tools/theme-shot.nix` (themed-desktop
|
||||
screenshots) and `tools/monitor-fallback.nix` (KVM display transitions:
|
||||
atomic dock handoff, internal-first undock, and low-level lid-inhibitor
|
||||
lifecycle). The latter restores the DRM output before deleting QEMU's final
|
||||
synthetic headless output; physical cable-removal timing is a V3 check because
|
||||
Hyprland's headless backend otherwise aborts in a VM-only zero-output state.
|
||||
|
||||
## 5. VM-specific gotchas
|
||||
|
||||
- **No KVM** (e.g. nested without acceleration): everything works but
|
||||
@@ -102,14 +138,37 @@ read `/var/log/nomarchy-hm-preactivate.log` on the installed system.
|
||||
script's qemu flags; on real hardware check `journalctl -b -u greetd`.
|
||||
- **Tiny resolution**: the live config forces `monitor = ,highres,auto,1`;
|
||||
if QEMU still picks 1024×768, resize the window — Hyprland follows.
|
||||
- **Display menu + QEMU**: System › Display mode list is sorted by **pixel
|
||||
area** (highest first). After a pick, the toast shows hyprctl’s reported
|
||||
size. In a **fixed-size** QEMU/viewer window, a higher guest mode still
|
||||
*looks* smaller (more pixels in the same window) — that is viewer scaling,
|
||||
not an inverted apply. Trust hyprctl / the toast numbers.
|
||||
- **First theme switch is the slowest**: it evaluates the flake on a RAM
|
||||
disk. Subsequent switches reuse the eval cache.
|
||||
- **Menu geometry does not survive the guest**: the rofi picker draws with
|
||||
icons at roughly a quarter of `ui.iconSize` and rows short enough that a
|
||||
6-row root clips its last entry behind a scrollbar (`lines = 8` should
|
||||
fit it) — the guest's font/icon environment, not a regression. Menu
|
||||
screenshots are evidence for **which rows appear**, never for spacing,
|
||||
icon size or label truncation.
|
||||
**And do not "just measure" instead** — that is how #131 stayed wrong for
|
||||
weeks. Measuring needs a width and a font, and both are per-theme: text
|
||||
menus render through `themes/<slug>/rofi.rasi`, where boreal and
|
||||
neon-glass pinned a fixed `620px` while the rest used `40%`, in fonts from
|
||||
Inter 11 to JetBrainsMono 14. The item measured one combination that no
|
||||
theme actually shipped, concluded "only truncates below 1920", and the
|
||||
truncation was live on a 2560 panel. Since #131 the width is
|
||||
`calc( 84ch min 65% )` — font-relative by design, so the arithmetic is
|
||||
even less predictable from a spec sheet. **Judge it by rendering**: on
|
||||
hardware, or with `rofi -dmenu -theme <the built rasi>` plus
|
||||
`hyprctl layers` for the real width. `checks.rofi-text-width` guards the
|
||||
invariant that makes it fit; nothing guards how it looks.
|
||||
|
||||
## 6. When something fails
|
||||
|
||||
- Session/login problems: `journalctl -b -u greetd`, then `journalctl
|
||||
--user -b`.
|
||||
- Theme switch failures: run `nomarchy-theme-sync apply <x>` from a
|
||||
- Theme switch failures: run `nomarchy-state-sync apply <x>` from a
|
||||
terminal — the home-manager output streams there. The state file is
|
||||
written *before* the rebuild, so after fixing you can just re-run
|
||||
`home-manager switch --flake ~/.nomarchy`.
|
||||
|
||||
165
docs/VISION.md
Normal file
165
docs/VISION.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Product vision — toward Nomarchy v1.0
|
||||
|
||||
North star for **what the product should feel like**, not a task queue.
|
||||
Pillars stay in [`agent/GOALS.md`](../agent/GOALS.md). Executable work
|
||||
lives only in [`agent/BACKLOG.md`](../agent/BACKLOG.md).
|
||||
|
||||
**How agents use this file**
|
||||
|
||||
1. Read the relevant section when orienting on product work.
|
||||
2. Slice concrete work into **PROPOSED** (or wait for human triage into
|
||||
NOW/NEXT) with a one-paragraph pitch and `VISION § <heading>`.
|
||||
3. Do **not** implement multi-week themes in one iteration — split.
|
||||
4. When a slice ships, leave a ✓ note here or in ROADMAP if the design
|
||||
decision should outlive the backlog line.
|
||||
|
||||
**Philosophy reminder:** opinionated and stable over option sprawl. Prefer
|
||||
safer defaults, one golden path, and menu/doctor surfaces over install-time
|
||||
questionnaires and `nomarchy.apps.*` toggles for bare packages.
|
||||
|
||||
---
|
||||
|
||||
## North-star user (v1 window)
|
||||
|
||||
> **Framework / modern AMD-or-Intel laptop, little or no Nix, wants a
|
||||
> beautiful desktop that never bricks, configured from the menu.**
|
||||
|
||||
Work that does not serve this user for the v1.0 window should stay LATER
|
||||
or PROPOSED unless it unblocks stability.
|
||||
|
||||
---
|
||||
|
||||
## v1.0 release bar
|
||||
|
||||
Ship the **v1** branch pointer only when these are honestly true (human
|
||||
judgment; agents prepare, do not advance `v1`):
|
||||
|
||||
| Bar | Done looks like |
|
||||
|-----|-----------------|
|
||||
| **Install golden path** | Offline (or documented online) install → first boot themed desktop; swap=0 and unattended LUKS contracts correct |
|
||||
| **Default identity** | Boreal is the seed theme (shipped 2026-07-09); picker should have `preview.png` |
|
||||
| **Day-2 confidence** | Doctor covers real failure modes; firmware and fingerprint are discoverable without reading the README |
|
||||
| **Hardware story** | `docs/HARDWARE.md` matches code; option-docs green; i2c/ddcci documented |
|
||||
| **Honesty** | No “done” claims past verified tier; HARDWARE-QUEUE cleared or consciously deferred |
|
||||
| **Docs map** | README + `docs/README.md` + agent README agree on where things live |
|
||||
|
||||
Not required for v1.0: dual-boot, Secure Boot, multi-disk RAID, aarch64,
|
||||
Steam Deck, docs website, binary cache.
|
||||
|
||||
---
|
||||
|
||||
## Theme A — Day-2 confidence (highest product ROI)
|
||||
|
||||
The install is already strong. The gap is **after** first boot.
|
||||
Most of this theme **shipped** mid-2026; residual is V3 hardware QA +
|
||||
optional polish.
|
||||
|
||||
| Idea | Intent | Status |
|
||||
|------|--------|--------|
|
||||
| **System › Firmware** ✓ | fwupd menu: refresh → list → confirm → update (never auto-flash) | ✓ shipped #43 (+ V2 #43 render) |
|
||||
| **Fingerprint menu** ✓ | Enroll/list when fprintd present; optional PAM + rebuild note | ✓ shipped #55; V3 enroll HARDWARE-QUEUE |
|
||||
| **Doctor hardware section** ✓ | NM, sink, GPU smoke, fprintd, fwupd, charge, battery health, hibernate | ✓ shipped #44 + #77 + #80 + #83 |
|
||||
| **Machine health entry** ✓ | One System row → doctor | ✓ System › Doctor + Waybar tripwire |
|
||||
| **Human rebuild errors** ✓ | Failed switch → last log lines + `nomarchy-doctor` | ✓ shipped #56 |
|
||||
| **HM pre-activate fail flag** ✓ | Recovery one-liner if theme bake failed | ✓ shipped #83 |
|
||||
|
||||
Reference: [HARDWARE.md](HARDWARE.md) §4–§5, §11 (doctor).
|
||||
|
||||
---
|
||||
|
||||
## Theme B — First week experience
|
||||
|
||||
| Idea | Intent | Notes |
|
||||
|------|--------|-------|
|
||||
| **Silent first-week card** ✓ | One dismissible “you’re set” (menu, themes, keys, network) — not a wizard | ✓ shipped #81 — `settings.firstBootShown` + notify; live ISO keeps its own toast |
|
||||
| **Boreal as default** ✓ | Identity on first boot | ✓ seed `state.json` + install path use Boreal (2026-07-09) |
|
||||
| **Generation readability** ✓ | “What changed last rebuild” in plain language | ✓ shipped #82 — nvd toast + System › What changed? |
|
||||
| **Post-install hints** ✓ | One-shot MOTD/notify for fwupd / fprintd when relevant | ✓ shipped 2026-07-17 — self-gated “Hardware tips” toast after the #81 card (`settings.hardwareHintsShown`), fwupd/fprintd lines when the tooling is on PATH |
|
||||
|
||||
---
|
||||
|
||||
## Theme C — Laptop daily driver
|
||||
|
||||
| Idea | Intent | Notes |
|
||||
|------|--------|-------|
|
||||
| **Dock life** ✓ | Lid closed + external only, wake, default audio sink on undock | ✓ #86 clamshell logind; ✓ #87 WirePlumber HDMI/USB priority (V3 hotplug QA) |
|
||||
| **Hibernate/sleep confidence** ✓ | Doctor: resume device, swap size, clean suspend journal | ✓ shipped #77 (+ #76 agent V0–V2; V3 power-cycle HARDWARE-QUEUE) |
|
||||
| **Battery health readout** ✓ | Cycles + design capacity % in doctor where sysfs allows | ✓ shipped #80 (report-only; charge limit is a separate row) |
|
||||
| **Charge-limit instant apply** ✓ | Live sysfs write without rebuild | ✓ shipped menu live-write path 2026-07-10; residual V3 Dell Adaptive / non-BAT* in HARDWARE-QUEUE |
|
||||
|
||||
---
|
||||
|
||||
## Theme D — Beauty without theme sprawl
|
||||
|
||||
| Idea | Intent | Notes |
|
||||
|------|--------|-------|
|
||||
| **Fewer themes, sharper** ✓ | Finish or quarantine neon-glass; invest in whole-swap quality | ✓ #88 finish — glass rofi (proper @colors), btop, preview; bar stays generated (no broken waybar.css) |
|
||||
| **Time-of-day pair** ✓ | Auto light/dark (e.g. summer-day ↔ summer-night) from schedule | ✓ shipped #79 (2026-07-10) — `settings.autoTheme` + timer; still one engine |
|
||||
| **Theme switch speed** | Wallpapers artifact split (LATER) if switches still feel slow | GOALS: no second pipeline |
|
||||
|
||||
---
|
||||
|
||||
## Theme E — Menu as the product
|
||||
|
||||
| Idea | Intent | Notes |
|
||||
|------|--------|-------|
|
||||
| Root stays six entries | New features go Tools › / System › | CONVENTIONS |
|
||||
| Secrets module | Only if a vault story is adopted (rofi-rbw deferred) | Don’t tease |
|
||||
| Look & Feel group | Night-light, wallpaper, blur — when enough toggles exist | ROADMAP optional |
|
||||
|
||||
---
|
||||
|
||||
## Theme F — Narrative & community
|
||||
|
||||
| Idea | Intent | Notes |
|
||||
|------|--------|-------|
|
||||
| **Omarchy migrant one-pager** ✓ | Bindings/theme/install map | ✓ shipped `docs/OMARCHY.md` (#78, 2026-07-10) |
|
||||
| **Hardware hall of fame** ✓ | Install-tested models; invite DMI PRs | ✓ shipped #85 — HARDWARE.md §9 table + DMI PR invite |
|
||||
| **60s demo** | Live → install → theme → menu | Outside repo OK |
|
||||
|
||||
---
|
||||
|
||||
## Theme G — Deliberate non-sprawl
|
||||
|
||||
| Idea | Intent |
|
||||
|------|--------|
|
||||
| Starter apps: curate yearly, don’t grow `nomarchy.apps.*` | Template SoT already |
|
||||
| Gaming/creator: comment blocks in template, not new modules | Steam/OBS already services |
|
||||
| Installer stays golden-path | No dual-boot/partition wizard for v1 |
|
||||
|
||||
---
|
||||
|
||||
## Theme H — Quality bar when ideas run dry
|
||||
|
||||
1. Burn down [HARDWARE-QUEUE.md](../agent/HARDWARE-QUEUE.md) (V3 only you can close).
|
||||
2. Fix install contracts (swap=0, unattended LUKS fail-closed).
|
||||
3. Visual ritual: default-theme screenshots before each `v1` fast-forward.
|
||||
4. option-docs + theme-contrast always green on `main`.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (reaffirm)
|
||||
|
||||
From GOALS non-goals and installer audits — do not “fill the roadmap” with:
|
||||
|
||||
- Binary cache as a product
|
||||
- Multi-DE / GTK4 launcher / second theming pipeline
|
||||
- Repo-wide formatter without a Decision
|
||||
- nixpkgs major bump on `main` (that’s a deliberate `v2`)
|
||||
- Option sprawl for bare package installs
|
||||
|
||||
---
|
||||
|
||||
## Suggested agent slices (promote via PROPOSED → NEXT)
|
||||
|
||||
**Still open** (as of 2026-07-17). Shipped items above are *not* open work.
|
||||
|
||||
1. `VISION § D` / LATER — wallpapers artifact split (decided deferred;
|
||||
promote only if theme-switch latency still hurts after measurement)
|
||||
2. `VISION § H` — HARDWARE-QUEUE burn-down (human-only V3; agents only
|
||||
append exact steps)
|
||||
3. `VISION § v1.0` — install P0 re-verify before any `v1` fast-forward
|
||||
(swap=0 / unattended LUKS contracts; human ships the pointer)
|
||||
|
||||
Do **not** re-open Theme A firmware/fingerprint/doctor/rebuild rows —
|
||||
those shipped. Dock/hibernate agent work is done; residual is V3 hardware.
|
||||
59
flake.lock
generated
59
flake.lock
generated
@@ -145,11 +145,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1781063120,
|
||||
"narHash": "sha256-1UIF/mDJluwJQjmmcZ2j1L2+mjYsefe82QCLj0TYSOg=",
|
||||
"lastModified": 1783740085,
|
||||
"narHash": "sha256-qajyHfZY29G2oEQk+uHxmsJcRoBUBXP9maTpFlwP/dI=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "baa46aeb6d02e0ba13de67cd35e3d57aedfacf01",
|
||||
"rev": "3cd22efe6471dc7365c822bd9ad73a21e55f38fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -166,11 +166,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1781020964,
|
||||
"narHash": "sha256-fS7xTi2j2iso5Hj7RNZLv/acDlCT+fgMVkVk40A7Uco=",
|
||||
"lastModified": 1783792734,
|
||||
"narHash": "sha256-50rvY9GdFvpYDcMLcD/4cWSi0hVxArT5wsGlVsHy8eY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixos-hardware",
|
||||
"rev": "32c2cd9e46286c4eced3dc6b613c659126bf3cca",
|
||||
"rev": "8efb4337e857949f4cfac86d12ef1066f417f31f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -182,11 +182,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1780902259,
|
||||
"narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=",
|
||||
"lastModified": 1783703440,
|
||||
"narHash": "sha256-O3/YajjWo001VUIgD8BwaRdSNLUFe7nZ1qV5TwhRBcw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
|
||||
"rev": "8f0500b9660505dc3cb647775fe9a978a74b5283",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -196,6 +196,39 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1784120854,
|
||||
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nomarchy-wallpapers": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1784377824,
|
||||
"narHash": "sha256-XPzT9w+xr0bEn3W2R+pYo4z10hKT1E10QB5TAu/UrIM=",
|
||||
"ref": "main",
|
||||
"rev": "d4f5fbfff2228c7725be5a1adbc9b325621d3c94",
|
||||
"revCount": 4,
|
||||
"type": "git",
|
||||
"url": "https://git.bemagri.xyz/bernardo/Nomarchy-Wallpapers.git"
|
||||
},
|
||||
"original": {
|
||||
"ref": "main",
|
||||
"type": "git",
|
||||
"url": "https://git.bemagri.xyz/bernardo/Nomarchy-Wallpapers.git"
|
||||
}
|
||||
},
|
||||
"nur": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
@@ -226,6 +259,8 @@
|
||||
"home-manager": "home-manager",
|
||||
"nixos-hardware": "nixos-hardware",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"nomarchy-wallpapers": "nomarchy-wallpapers",
|
||||
"stylix": "stylix"
|
||||
}
|
||||
},
|
||||
@@ -249,11 +284,11 @@
|
||||
"tinted-zed": "tinted-zed"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1780702455,
|
||||
"narHash": "sha256-+srjPGNy67nKytYwdlepycL51IG6S34sS4MKRZXK8G0=",
|
||||
"lastModified": 1783359251,
|
||||
"narHash": "sha256-HUiCnEVlJ4n+qJlZojiz/zv+P0cqM5zsg1dxpz2J7Mg=",
|
||||
"owner": "nix-community",
|
||||
"repo": "stylix",
|
||||
"rev": "54fa19702f4f2c7f6a981a92850678933588af9a",
|
||||
"rev": "e602ad042f00409f33c8ad2829cd8d59ba345c7e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
120
hosts/live.nix
120
hosts/live.nix
@@ -1,36 +1,102 @@
|
||||
# Live ISO host — boot the full Nomarchy desktop from a USB stick or QEMU
|
||||
# without touching the disk. No installer yet (see roadmap); this target
|
||||
# exists to test the distro end-to-end on real hardware.
|
||||
{ lib, pkgs, username, nomarchySrc, ... }:
|
||||
# without touching the disk. The live session bundles nomarchy-install
|
||||
# (gum + disko + mkFlake) for a real install; this target also lets you
|
||||
# test the distro end-to-end on real hardware before committing to disk.
|
||||
{ lib, pkgs, config, username, nomarchySrc, ... }:
|
||||
|
||||
let
|
||||
# ISO boot splash: the Nomarchy monogram recolored to the palette accent,
|
||||
# ISO boot branding: the Nomarchy monogram recolored to the palette accent,
|
||||
# centred on the theme base. Built from the vendored vector logo and the
|
||||
# live theme-state.json (tokyo-night by default). Shows on the isolinux
|
||||
# (BIOS) boot menu; UEFI/GRUB still uses the stock theme (see roadmap).
|
||||
state = builtins.fromJSON (builtins.readFile ../theme-state.json);
|
||||
# live state.json (Boreal by default). The same composed image
|
||||
# backs both the isolinux (BIOS) splash and the GRUB (UEFI) theme below, so
|
||||
# the two boot paths match.
|
||||
state = builtins.fromJSON (builtins.readFile ../state.json);
|
||||
isoColor = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
|
||||
accent = isoColor "accent" "B79BE8";
|
||||
base = isoColor "base" "21272F";
|
||||
subtext = isoColor "subtext" "97A3B2";
|
||||
|
||||
isoSplash = pkgs.runCommand "nomarchy-iso-splash.png"
|
||||
{ nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg ]; } ''
|
||||
rsvg-convert -h 320 ${../modules/nixos/branding/logo.svg} > logo.png
|
||||
magick logo.png -fill "#${isoColor "accent" "7aa2f7"}" -colorize 100 logo-c.png
|
||||
magick -size 1920x1080 xc:"#${isoColor "base" "1a1b26"}" \
|
||||
magick logo.png -fill "#${accent}" -colorize 100 logo-c.png
|
||||
magick -size 1920x1080 xc:"#${base}" \
|
||||
logo-c.png -gravity center -composite $out
|
||||
'';
|
||||
|
||||
# GRUB (UEFI) theme matched to the BIOS splash: the same accent-logo-on-base
|
||||
# image as the background, plus a palette-coloured boot menu in the lower
|
||||
# third (clear of the centred logo). Derived from nixos-grub2-theme only to
|
||||
# reuse its bundled DejaVu .pf2 font — we overwrite the background and
|
||||
# theme.txt and drop the stock NixOS logo (ours is in the background). grub
|
||||
# loads every .pf2 in the dir, so "DejaVu Regular" resolves.
|
||||
grubThemeTxt = pkgs.writeText "nomarchy-grub-theme.txt" ''
|
||||
title-text: ""
|
||||
desktop-image: "background.png"
|
||||
desktop-color: "#${base}"
|
||||
|
||||
message-font: "DejaVu Regular"
|
||||
message-color: "#${subtext}"
|
||||
terminal-font: "DejaVu Regular"
|
||||
|
||||
+ boot_menu {
|
||||
left = 50%-300
|
||||
width = 600
|
||||
top = 64%
|
||||
height = 26%
|
||||
item_font = "DejaVu Regular"
|
||||
item_color = "#${subtext}"
|
||||
item_height = 36
|
||||
item_spacing = 6
|
||||
selected_item_font = "DejaVu Regular"
|
||||
selected_item_color = "#${accent}"
|
||||
scrollbar = false
|
||||
}
|
||||
|
||||
+ progress_bar {
|
||||
id = "__timeout__"
|
||||
left = 50%-300
|
||||
top = 92%
|
||||
width = 600
|
||||
height = 16
|
||||
show_text = true
|
||||
text = "@TIMEOUT_NOTIFICATION_MIDDLE@"
|
||||
font = "DejaVu Regular"
|
||||
text_color = "#${subtext}"
|
||||
border_color = "#${accent}"
|
||||
bg_color = "#${base}"
|
||||
fg_color = "#${accent}"
|
||||
}
|
||||
'';
|
||||
|
||||
nomarchyGrubTheme = pkgs.runCommand "nomarchy-grub-theme" { } ''
|
||||
cp -r ${pkgs.nixos-grub2-theme} $out
|
||||
chmod -R u+w $out
|
||||
cp ${isoSplash} $out/background.png
|
||||
cp ${grubThemeTxt} $out/theme.txt
|
||||
rm -f $out/logo.png
|
||||
'';
|
||||
in
|
||||
{
|
||||
networking.hostName = "nomarchy-live";
|
||||
|
||||
isoImage.volumeID = lib.mkForce "NOMARCHY_LIVE";
|
||||
isoImage.edition = lib.mkForce "live";
|
||||
isoImage.splashImage = isoSplash;
|
||||
isoImage.splashImage = isoSplash; # isolinux / BIOS
|
||||
isoImage.grubTheme = nomarchyGrubTheme; # GRUB / UEFI
|
||||
# Filename: nixpkgs defaults image.baseName to "nixos-<edition>-…". Force
|
||||
# nomarchy so result/iso/ is nomarchy-live-….iso, not nixos-live-….iso (#125).
|
||||
image.baseName = lib.mkForce (
|
||||
"nomarchy-${config.isoImage.edition}-${config.system.nixos.label}-${pkgs.stdenv.hostPlatform.system}"
|
||||
);
|
||||
|
||||
# The minimal-CD profile slims the image for a CONSOLE installer; this
|
||||
# ISO is the desktop, so re-enable what it strips. Above all
|
||||
# fontconfig (upstream forces it off at mkOverride 500): without it
|
||||
# no configured family resolves — Waybar icons render as tofu and
|
||||
# Ghostty silently falls back to the wrong font (seen on the
|
||||
# Latitude 5410). Normal priority (100) beats the override.
|
||||
# Kitty silently falls back to the wrong font (seen on the
|
||||
# Latitude 5410 with Ghostty; same fontconfig footgun). Normal priority
|
||||
# (100) beats the override.
|
||||
fonts.fontconfig.enable = true;
|
||||
|
||||
# No boot splash on the install medium: the installer ISO boots its
|
||||
@@ -88,6 +154,16 @@ in
|
||||
if [ ! -e "$home/.nomarchy" ]; then
|
||||
cp -r ${nomarchySrc} "$home/.nomarchy"
|
||||
chmod -R u+w "$home/.nomarchy"
|
||||
# Committed git repo, exactly like nomarchy-install produces —
|
||||
# otherwise nomarchy-doctor (and its Waybar badge) false-alarms
|
||||
# "state.json is NOT git-tracked" in the live session.
|
||||
(
|
||||
cd "$home/.nomarchy"
|
||||
${pkgs.git}/bin/git init -q
|
||||
${pkgs.git}/bin/git add -A
|
||||
${pkgs.git}/bin/git -c user.name="Nomarchy Live" -c user.email="live@nomarchy" \
|
||||
commit -qm "Nomarchy live session"
|
||||
)
|
||||
chown -R ${username}:users "$home/.nomarchy"
|
||||
fi
|
||||
'';
|
||||
@@ -98,8 +174,8 @@ in
|
||||
Welcome to the Nomarchy live environment.
|
||||
|
||||
The graphical session autologins as '${username}' (no password).
|
||||
Theme switching: nomarchy-theme-sync apply <name> (or SUPER+T)
|
||||
Wallpapers: nomarchy-theme-sync bg next (or SUPER+SHIFT+T)
|
||||
Theme switching: nomarchy-state-sync apply <name> (or SUPER+T)
|
||||
Wallpapers: nomarchy-state-sync bg next (or SUPER+SHIFT+T)
|
||||
Install to disk: nomarchy-install
|
||||
The flake lives at ~/.nomarchy.
|
||||
'';
|
||||
@@ -112,6 +188,20 @@ in
|
||||
# regression). Installed systems keep idle management.
|
||||
nomarchy.idle.enable = false;
|
||||
|
||||
# Durable install affordance (BACKLOG #57): always-visible desktop
|
||||
# entry, not only the 3s toast / getty helpLine / tribal knowledge.
|
||||
# Tools › Install Nomarchy is self-gated on this package in rofi.nix.
|
||||
xdg.desktopEntries.nomarchy-install = {
|
||||
name = "Install Nomarchy";
|
||||
genericName = "Installer";
|
||||
comment = "Install Nomarchy to this machine's disk";
|
||||
exec = "nomarchy-install";
|
||||
terminal = true;
|
||||
icon = "system-software-install";
|
||||
categories = [ "System" "Settings" ];
|
||||
startupNotify = true;
|
||||
};
|
||||
|
||||
wayland.windowManager.hyprland.settings = {
|
||||
# QEMU (and some panels) report a tiny "preferred" mode; ask for
|
||||
# the highest resolution instead.
|
||||
@@ -119,7 +209,7 @@ in
|
||||
# Welcome toast once the session is up (concatenated onto the
|
||||
# base exec-once list).
|
||||
exec-once = [
|
||||
"sh -c 'sleep 3; notify-send -a Nomarchy \"Welcome to Nomarchy\" \"SUPER+Return terminal · SUPER+T themes · install with nomarchy-install\"'"
|
||||
"sh -c 'sleep 3; notify-send -a Nomarchy \"Welcome to Nomarchy\" \"SUPER+Return terminal · SUPER+T themes · Install Nomarchy app or nomarchy-install\"'"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
39
lib.nix
39
lib.nix
@@ -1,11 +1,20 @@
|
||||
# Downstream sugar: one call wires a whole machine flake.
|
||||
# Users own ONLY system.nix, home.nix and theme-state.json; their flake.nix
|
||||
# Users own ONLY system.nix, home.nix and state.json; their flake.nix
|
||||
# is generated once (by `nix flake init -t` or the future installer) and
|
||||
# never hand-edited. The raw exports (nixosModules/homeModules/overlays)
|
||||
# in flake.nix remain the escape hatch for power users.
|
||||
{ nixpkgs, home-manager, nixos-hardware, nomarchy }:
|
||||
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
|
||||
# Shared pure reader (modules/state-read.nix) — re-exported so
|
||||
# power users composing without mkFlake can call it too.
|
||||
readThemeState = import ./modules/state-read.nix { inherit lib; };
|
||||
in
|
||||
{
|
||||
inherit readThemeState;
|
||||
|
||||
mkFlake =
|
||||
{ src # the downstream flake directory (./.)
|
||||
, username # login name; also the homeConfigurations attr
|
||||
@@ -13,8 +22,6 @@
|
||||
, system ? "x86_64-linux"
|
||||
}:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
|
||||
# One profile or several (the installer's autodetection emits a few
|
||||
# common-* modules alongside the model-specific one).
|
||||
profileNames =
|
||||
@@ -51,8 +58,22 @@
|
||||
# and the standalone HM desktop see the same package set.
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
|
||||
# Early fail-closed gate: missing/empty/non-object state.json
|
||||
# throws here with a template + validate pointer, before module
|
||||
# evaluation buries a raw readFile/fromJSON stack. Field-level
|
||||
# schema still runs in modules/home/theme.nix after defaults merge.
|
||||
# Forced via seq on the whole return set — attrNames alone must not
|
||||
# skip the check (Nix is lazy on unused let bindings and attr values).
|
||||
# #107: prefer state.json; accept legacy theme-state.json until
|
||||
# the user's next menu write migrates them.
|
||||
statePath =
|
||||
if builtins.pathExists (src + "/state.json") then src + "/state.json"
|
||||
else if builtins.pathExists (src + "/theme-state.json") then src + "/theme-state.json"
|
||||
else src + "/state.json";
|
||||
_themeState = readThemeState statePath;
|
||||
in
|
||||
{
|
||||
builtins.seq _themeState {
|
||||
# System layer — rebuilt rarely:
|
||||
# sudo nixos-rebuild switch --flake .#default
|
||||
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
|
||||
@@ -62,12 +83,12 @@
|
||||
[
|
||||
nomarchy.nixosModules.nomarchy
|
||||
# The standalone HM CLI ships with the system so theme switching
|
||||
# (`nomarchy-theme-sync apply` → `home-manager switch`) works
|
||||
# (`nomarchy-state-sync apply` → `home-manager switch`) works
|
||||
# out of the box — same pinned input as the desktop modules.
|
||||
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
|
||||
# System-side theme consumers (Plymouth splash background)
|
||||
# read the same JSON the desktop does.
|
||||
{ nomarchy.system.stateFile = src + "/theme-state.json"; }
|
||||
{ nomarchy.system.stateFile = statePath; }
|
||||
]
|
||||
++ hardwareModules
|
||||
++ [
|
||||
@@ -78,16 +99,16 @@
|
||||
|
||||
# Desktop layer — every theme change, no sudo:
|
||||
# home-manager switch --flake .#<username>
|
||||
# (`nomarchy-theme-sync apply` runs this for you.)
|
||||
# (`nomarchy-state-sync apply` runs this for you.)
|
||||
homeConfigurations.${username} = home-manager.lib.homeManagerConfiguration {
|
||||
inherit pkgs;
|
||||
modules = [
|
||||
nomarchy.homeModules.nomarchy
|
||||
(src + "/home.nix")
|
||||
{
|
||||
# Written by nomarchy-theme-sync; reading it is pure — the
|
||||
# Written by nomarchy-state-sync; reading it is pure — the
|
||||
# file is part of the downstream flake's source.
|
||||
nomarchy.stateFile = src + "/theme-state.json";
|
||||
nomarchy.stateFile = statePath;
|
||||
home = {
|
||||
inherit username;
|
||||
homeDirectory = "/home/${username}";
|
||||
|
||||
10
modules/home/airplane.nix
Normal file
10
modules/home/airplane.nix
Normal file
@@ -0,0 +1,10 @@
|
||||
# Airplane mode — runtime kill-switch for Wi-Fi + Bluetooth together.
|
||||
# Implementation: pkgs/nomarchy-airplane. Session state under
|
||||
# $XDG_RUNTIME_DIR; Waybar plane glyph self-hides when off.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
config = {
|
||||
home.packages = [ pkgs.nomarchy-airplane ];
|
||||
};
|
||||
}
|
||||
58
modules/home/autotheme.nix
Normal file
58
modules/home/autotheme.nix
Normal file
@@ -0,0 +1,58 @@
|
||||
# Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer
|
||||
# runs `nomarchy-state-sync auto` at the configured sunrise and sunset,
|
||||
# which reads settings.autoTheme = { enable, day, night, sunrise, sunset }
|
||||
# from the state file and applies the day or night preset for the current
|
||||
# clock — through the SAME one engine as a manual `apply` (no second
|
||||
# pipeline).
|
||||
#
|
||||
# The trigger times are BAKED into OnCalendar at rebuild (originally this
|
||||
# was a 15-min poll; exact times won on wasted wakeups — Bernardo
|
||||
# 2026-07-18), so a time edit must rebuild: the menu's Sunrise/Sunset
|
||||
# writes run `auto --force`, whose apply is that rebuild. Missed
|
||||
# transitions are covered without polling: Persistent=true catches ones
|
||||
# that pass while powered off, systemd fires elapsed OnCalendar timers on
|
||||
# resume from suspend, and OnStartupSec settles the theme just after
|
||||
# login. The command self-gates (no-op when disabled) and is idempotent
|
||||
# (it only rebuilds when the active theme actually needs to change), so
|
||||
# every extra firing is cheap.
|
||||
#
|
||||
# Install is gated on the state flag (like nomarchy.updates gates on its
|
||||
# enable), so a machine not using the feature carries no timer; enabling
|
||||
# it from the menu (slice 3) writes the flag + rebuilds, which is what
|
||||
# brings the timer into being.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
at = config.nomarchy.settings.autoTheme or { };
|
||||
# "HH:MM" or the fallback — mirrors _hhmm_to_min in nomarchy-state-sync,
|
||||
# so a hand-edited state can't bake an OnCalendar systemd rejects.
|
||||
hhmm = v: fallback:
|
||||
if builtins.isString v
|
||||
&& builtins.match "([01][0-9]|2[0-3]):[0-5][0-9]" v != null
|
||||
then v else fallback;
|
||||
in
|
||||
lib.mkIf (at.enable or false) {
|
||||
systemd.user.services.nomarchy-auto-theme = {
|
||||
Unit.Description = "Apply the day/night theme for the current time";
|
||||
Service = {
|
||||
Type = "oneshot";
|
||||
# The rebuild (`home-manager switch`) and its tools resolve from the
|
||||
# system + per-user profiles — same PATH shape nomarchy-updates uses.
|
||||
Environment = "PATH=/run/current-system/sw/bin:/etc/profiles/per-user/${config.home.username}/bin";
|
||||
ExecStart = "${pkgs.nomarchy-state-sync}/bin/nomarchy-state-sync auto";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.user.timers.nomarchy-auto-theme = {
|
||||
Unit.Description = "Day/night theme switch at sunrise and sunset";
|
||||
Timer = {
|
||||
OnStartupSec = "1min"; # settle on the right theme shortly after login
|
||||
OnCalendar = [
|
||||
"*-*-* ${hhmm (at.sunrise or null) "07:00"}:00" # sunrise → day
|
||||
"*-*-* ${hhmm (at.sunset or null) "20:00"}:00" # sunset → night
|
||||
];
|
||||
Persistent = true; # catch a transition missed while powered off
|
||||
};
|
||||
Install.WantedBy = [ "timers.target" ];
|
||||
};
|
||||
}
|
||||
29
modules/home/battery-notify.nix
Normal file
29
modules/home/battery-notify.nix
Normal file
@@ -0,0 +1,29 @@
|
||||
# Low-battery notifications (nomarchy.batteryNotify) — the bar colors the
|
||||
# battery @warn/@bad at 25/10% (waybar.nix battery.states) but nothing
|
||||
# *notified*; this watcher fires a toast at those same thresholds. Session-
|
||||
# side and self-gating on a battery being present (the powerprofile-script
|
||||
# pattern — no system→home coupling), so it's a silent no-op on desktops.
|
||||
# swaync shows critical toasts until dismissed (timeout-critical = 0), so
|
||||
# the 10% one can't slip by unseen. Logic lives in pkgs/nomarchy-battery-
|
||||
# notify (overlay), where checks.battery-notify exercises it.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
config = lib.mkIf config.nomarchy.batteryNotify.enable {
|
||||
systemd.user.services.nomarchy-battery-notify = {
|
||||
Unit = {
|
||||
Description = "Low-battery notifications (25/10%, the bar's thresholds)";
|
||||
PartOf = [ "graphical-session.target" ];
|
||||
After = [ "graphical-session.target" ];
|
||||
};
|
||||
Service = {
|
||||
# The script resolves notify-send from PATH (that's what the VM
|
||||
# check shims); here libnotify provides the real one.
|
||||
Environment = "PATH=${lib.makeBinPath [ pkgs.libnotify ]}";
|
||||
ExecStart = "${pkgs.nomarchy-battery-notify}/bin/nomarchy-battery-notify";
|
||||
Restart = "on-failure";
|
||||
};
|
||||
Install.WantedBy = [ "graphical-session.target" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
# Nomarchy — Home Manager entry point.
|
||||
# Consume this via homeModules.nomarchy (flake.nix), which also pulls in
|
||||
# the stylix home module that stylix.nix configures.
|
||||
{ config, pkgs, ... }:
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
./options.nix # the nomarchy.* option surface
|
||||
./theme.nix # ingests theme-state.json, owns the live-sync hooks
|
||||
./theme.nix # ingests state.json, owns the live-sync hooks
|
||||
./stylix.nix # GTK/Qt/cursor/fonts from the same JSON
|
||||
./hyprland.nix
|
||||
./waybar.nix
|
||||
./ghostty.nix
|
||||
./kitty.nix # sole terminal (themed); doctor/calendar/--class load-bearing
|
||||
./btop.nix
|
||||
./rofi.nix # launcher theming + the nomarchy-menu dispatcher
|
||||
./swaync.nix # notification daemon, themed from the same JSON
|
||||
@@ -18,36 +18,107 @@
|
||||
./yazi.nix # flagship TUI file manager, themed + plugins
|
||||
./osd.nix # swayosd volume/brightness OSD, themed
|
||||
./nightlight.nix # scheduled blue-light filter (hyprsunset), opt-in
|
||||
./airplane.nix # runtime Wi-Fi+BT airplane mode + Waybar glyph (#104)
|
||||
./autotheme.nix # auto day/night theme switch (settings.autoTheme), opt-in
|
||||
./timezone.nix # keep the Waybar clock in step with auto-timezone changes
|
||||
./updates.nix # passive update-awareness indicator + notification, opt-in
|
||||
./shell.nix # zsh + starship + bat/eza/zoxide, themed
|
||||
./keys.nix # gpg-agent (fronts SSH) + pinentry-qt
|
||||
./fastfetch.nix # system info with the themed Nomarchy logo
|
||||
./viewers.nix # zathura (Stylix-themed) + imv — PDF/image viewing
|
||||
./mime.nix # default applications (mimeapps.list), degrades with the suite
|
||||
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
|
||||
./battery-notify.nix # low-battery toasts at the bar's 25/10% thresholds
|
||||
./dock-audio.nix # default sink follows dock/monitor audio on hotplug (#87)
|
||||
./first-boot.nix # one-shot "you're set" toast on first session (#81)
|
||||
./satty.nix # satty screenshot annotation tool, themed
|
||||
];
|
||||
|
||||
# Clipboard history (wl-paste watcher); browsed via the SUPER+CTRL+V
|
||||
# menu module.
|
||||
services.cliphist.enable = true;
|
||||
|
||||
# Automount removable media (USB drives) and provide safe-removal notifications.
|
||||
services.udiskie.enable = true;
|
||||
|
||||
# Microphone noise cancellation (rnnoise) and audio EQ.
|
||||
# Tray icon is built into EasyEffects 8 (Qt StatusNotifierItem); the
|
||||
# daemon must start after a tray HOST is live or the icon is missing
|
||||
# for the whole session (wwmm/easyeffects#4636). After=waybar.service
|
||||
# was inert — Nomarchy's waybar runs from the nomarchy-waybar
|
||||
# supervisor (Hyprland exec-once), not a systemd unit, so the race was
|
||||
# a boot-order coin flip (lost on TuringMachine 2026-07-11). Gate on
|
||||
# the watcher's IsStatusNotifierHostRegistered instead; the `-` prefix
|
||||
# lets EasyEffects still start after the timeout on tray-less setups —
|
||||
# audio processing must not hinge on an icon.
|
||||
services.easyeffects.enable = true;
|
||||
systemd.user.services.easyeffects = {
|
||||
Unit = {
|
||||
After = [ "graphical-session.target" "tray.target" ];
|
||||
Wants = [ "tray.target" ];
|
||||
};
|
||||
Service.ExecStartPre = "-${pkgs.writeShellScript "wait-for-tray-host" ''
|
||||
timeout 30 ${pkgs.bash}/bin/sh -c '
|
||||
until ${pkgs.systemd}/bin/busctl --user get-property \
|
||||
org.kde.StatusNotifierWatcher /StatusNotifierWatcher \
|
||||
org.kde.StatusNotifierWatcher IsStatusNotifierHostRegistered \
|
||||
2>/dev/null | ${pkgs.gnugrep}/bin/grep -q true; do
|
||||
sleep 0.5
|
||||
done'
|
||||
''}";
|
||||
};
|
||||
|
||||
# Wifi from the bar: nm-applet lives in waybar's tray (SNI flag via
|
||||
# preferStatusNotifierItems — without it there is no tray icon).
|
||||
services.network-manager-applet.enable = true;
|
||||
xsession.preferStatusNotifierItems = true;
|
||||
|
||||
home.stateVersion = "26.05";
|
||||
# Standard XDG user directories (Downloads, Documents, Pictures, Music,
|
||||
# Videos, Desktop, Public, Templates): written to user-dirs.dirs so file
|
||||
# pickers/browsers resolve them, and created on activation so a fresh
|
||||
# install lands with them present (not just on first app use). mkDefault
|
||||
# so a downstream home.nix can flip it off or remap individual paths.
|
||||
xdg.userDirs = {
|
||||
enable = lib.mkDefault true;
|
||||
createDirectories = lib.mkDefault true;
|
||||
# Pinned: HM 26.05 flips the default to false (and warns on every eval
|
||||
# for older stateVersions). Keep exporting XDG_*_DIR into the session —
|
||||
# scripts and non-glib apps read the vars, and pinning gives every
|
||||
# downstream the same behavior regardless of its stateVersion.
|
||||
setSessionVariables = lib.mkDefault true;
|
||||
};
|
||||
|
||||
home.stateVersion = lib.mkDefault "26.05";
|
||||
|
||||
home.packages = with pkgs; [
|
||||
awww # wallpaper daemon with animated transitions (the swww fork)
|
||||
libnotify
|
||||
hyprpicker
|
||||
# Lifecycle CLIs (pull/rebuild/home). HM profile usually precedes
|
||||
# /run/current-system on PATH, so nomarchy-home can replace a broken
|
||||
# system-generation nomarchy-pull without a full sys rebuild.
|
||||
nomarchy-lifecycle
|
||||
];
|
||||
|
||||
home.sessionVariables = {
|
||||
TERMINAL = config.nomarchy.terminal;
|
||||
NIXOS_OZONE_WL = "1"; # Electron/Chromium native Wayland
|
||||
# Same gate as modules/nixos: CLI nix-shell/shell/run for unfree pkgs
|
||||
# (system nixpkgs.config alone does not cover those entry points).
|
||||
NIXPKGS_ALLOW_UNFREE = "1";
|
||||
|
||||
# Where the Nomarchy flake (and therefore theme-state.json) lives on
|
||||
# disk. nomarchy-theme-sync writes its state here; rebuilds read from
|
||||
# Where the Nomarchy flake (and therefore state.json) lives on
|
||||
# disk. nomarchy-state-sync writes its state here; rebuilds read from
|
||||
# here. Clone/symlink your flake to this path.
|
||||
NOMARCHY_PATH = "$HOME/.nomarchy";
|
||||
};
|
||||
|
||||
# Classic nix-shell / nix-env read this; pairs with NIXPKGS_ALLOW_UNFREE
|
||||
# above so "allow unfree" is the default desktop experience, not a
|
||||
# per-command export the user has to remember.
|
||||
xdg.configFile."nixpkgs/config.nix".text = ''
|
||||
{ allowUnfree = true; }
|
||||
'';
|
||||
|
||||
programs.home-manager.enable = true;
|
||||
}
|
||||
|
||||
103
modules/home/display-tools.nix
Normal file
103
modules/home/display-tools.nix
Normal file
@@ -0,0 +1,103 @@
|
||||
# Shared display recovery helpers (#127). Used by hyprland.nix (PATH +
|
||||
# undock dump) and idle.nix (hypridle on-resume / after_sleep absolute paths).
|
||||
#
|
||||
# dump — evidence to ~/nomarchy-display-dump-*.txt without SSH
|
||||
# wake — dpms on + zero-output undock rescue + dump if still dark
|
||||
# Pass displayTransition so wake pins the same binary hyprland ships.
|
||||
# Call once with only pkgs to get dump (transition not ready yet), then
|
||||
# again with displayTransition for wake.
|
||||
{ pkgs, displayTransition ? null }:
|
||||
|
||||
let
|
||||
dump = pkgs.writeShellScriptBin "nomarchy-display-dump" ''
|
||||
set -u
|
||||
reason="''${1:-manual}"
|
||||
stamp=$(date +%Y%m%d-%H%M%S)
|
||||
out="''${HOME:-/tmp}/nomarchy-display-dump-''${stamp}.txt"
|
||||
{
|
||||
echo "=== nomarchy-display-dump reason=$reason ==="
|
||||
echo "date: $(date -Is 2>/dev/null || date)"
|
||||
echo "user: $(id -un 2>/dev/null || echo ?)"
|
||||
echo "HYPRLAND_INSTANCE_SIGNATURE=''${HYPRLAND_INSTANCE_SIGNATURE:-}"
|
||||
echo
|
||||
echo "=== hyprctl monitors (enabled) ==="
|
||||
hyprctl monitors -j 2>&1 || true
|
||||
echo
|
||||
echo "=== hyprctl monitors all ==="
|
||||
hyprctl monitors all -j 2>&1 || true
|
||||
echo
|
||||
echo "=== hyprctl workspaces ==="
|
||||
hyprctl workspaces -j 2>&1 || true
|
||||
echo
|
||||
echo "=== hyprctl devices (keyboards) ==="
|
||||
hyprctl devices -j 2>/dev/null | ${pkgs.jq}/bin/jq '.keyboards // .' 2>&1 || true
|
||||
echo
|
||||
echo "=== systemd-inhibit --list ==="
|
||||
${pkgs.systemd}/bin/systemd-inhibit --list 2>&1 || true
|
||||
echo
|
||||
echo "=== DRM connector status ==="
|
||||
for s in /sys/class/drm/*/status; do
|
||||
[ -r "$s" ] || continue
|
||||
echo "$s: $(cat "$s" 2>/dev/null)"
|
||||
done
|
||||
echo
|
||||
echo "=== journal user display/idle (last 100) ==="
|
||||
journalctl --user -t nomarchy-display-watch -t nomarchy-display-transition \
|
||||
-t nomarchy-display-wake -t hypridle -t hyprlock -n 100 --no-pager 2>&1 || true
|
||||
echo
|
||||
echo "=== journal -b suspend/sleep/dpms (last 80 matching) ==="
|
||||
journalctl -b --no-pager 2>/dev/null \
|
||||
| ${pkgs.gnugrep}/bin/grep -iE 'Suspending|PM: suspend|PM: hibernate|dpms|sleep\.target|systemd-sleep' \
|
||||
| ${pkgs.coreutils}/bin/tail -n 80 || true
|
||||
} >"$out" 2>&1
|
||||
${pkgs.util-linux}/bin/logger -t nomarchy-display-dump -- "reason=$reason wrote $out"
|
||||
command -v notify-send >/dev/null 2>&1 \
|
||||
&& notify-send "Display dump" "$out" 2>/dev/null || true
|
||||
printf '%s\n' "$out"
|
||||
'';
|
||||
|
||||
wake =
|
||||
if displayTransition == null then null
|
||||
else pkgs.writeShellScriptBin "nomarchy-display-wake" ''
|
||||
set -u
|
||||
LOGGER=${pkgs.util-linux}/bin/logger
|
||||
log() { "$LOGGER" -t nomarchy-display-wake -- "$*"; }
|
||||
TRANSITION=${displayTransition}/bin/nomarchy-display-transition
|
||||
DUMP=${dump}/bin/nomarchy-display-dump
|
||||
hyprctl dispatch dpms on >/dev/null 2>&1 || true
|
||||
enabled=$(hyprctl monitors -j 2>/dev/null | ${pkgs.jq}/bin/jq 'length' 2>/dev/null || echo 0)
|
||||
case "$enabled" in *[!0-9]*|"") enabled=0 ;; esac
|
||||
if [ "$enabled" -eq 0 ]; then
|
||||
log "zero-enabled-outputs: attempting internal enable"
|
||||
internal=$(hyprctl monitors all -j 2>/dev/null \
|
||||
| ${pkgs.jq}/bin/jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)")) | .name' 2>/dev/null \
|
||||
| ${pkgs.coreutils}/bin/head -n1)
|
||||
if [ -n "$internal" ]; then
|
||||
if "$TRANSITION" undock "$internal" 2>/dev/null; then
|
||||
log "rescued via undock internal=$internal"
|
||||
elif "$TRANSITION" enable "$internal" 2>/dev/null; then
|
||||
log "rescued via enable internal=$internal"
|
||||
else
|
||||
log "rescue failed internal=$internal"
|
||||
fi
|
||||
else
|
||||
log "no internal output in monitors all"
|
||||
fi
|
||||
hyprctl dispatch dpms on >/dev/null 2>&1 || true
|
||||
enabled=$(hyprctl monitors -j 2>/dev/null | ${pkgs.jq}/bin/jq 'length' 2>/dev/null || echo 0)
|
||||
case "$enabled" in *[!0-9]*|"") enabled=0 ;; esac
|
||||
if [ "$enabled" -eq 0 ]; then
|
||||
log "still zero-enabled: dumping"
|
||||
"$DUMP" auto-zero-after-wake >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
first=$(hyprctl monitors -j 2>/dev/null \
|
||||
| ${pkgs.jq}/bin/jq -r '.[0].name // empty' 2>/dev/null || true)
|
||||
[ -n "$first" ] && hyprctl dispatch focusmonitor "$first" >/dev/null 2>&1 || true
|
||||
log "done enabled=$enabled first=''${first:-none}"
|
||||
'';
|
||||
in
|
||||
{
|
||||
displayDumpTool = dump;
|
||||
displayWakeTool = wake;
|
||||
}
|
||||
261
modules/home/dock-audio.nix
Normal file
261
modules/home/dock-audio.nix
Normal file
@@ -0,0 +1,261 @@
|
||||
# Automatic audio-output follow for docks / external monitors (#100).
|
||||
# WirePlumber's stored default outranks priority.session, so the Hyprland
|
||||
# monitor watcher calls `reprobe monitoradded` for the unambiguous physical-
|
||||
# plug event and we explicitly select an available dock-class sink. Ordinary
|
||||
# sink changes never select anything, so a manual speaker choice sticks until
|
||||
# a fresh monitor plug.
|
||||
#
|
||||
# `reprobe` escalates, cheapest first, and only as far as it must (#138):
|
||||
# select → repair a parked card's profile → restart the graph. The restart
|
||||
# is last because it is not free — it drops every client's connection to the
|
||||
# server, and clients that never reconnect (Chromium's audio service, hence
|
||||
# "Meet says I have no microphone") stay broken until the app itself is
|
||||
# restarted. It ran first here for one session-day because the old working
|
||||
# flake used it as a recovery; nothing had shown it was *needed*. Keep it as
|
||||
# the floor for the codec that only publishes its route after a re-probe, and
|
||||
# leave the common path — the sink is simply there to be chosen — untouched.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy;
|
||||
rules = import ../nixos/dock-audio-rules.nix;
|
||||
dockSinkRe = lib.concatStringsSep "|"
|
||||
(map (m: lib.removePrefix "~" m."node.name")
|
||||
(lib.concatMap (r: r.matches) rules."monitor.alsa.rules"));
|
||||
|
||||
# Cards driven by ALSA UCM expose speakers and headphones as separate
|
||||
# sinks (HiFi__Headphones__sink), the headphone one existing only while
|
||||
# the jack is occupied — so on those machines a jack plug is a sink
|
||||
# appearing, not the route switch WirePlumber handles by itself.
|
||||
headphoneSinkRe = "alsa_output\\..*[Hh]eadphone.*";
|
||||
|
||||
tool = pkgs.writeShellScriptBin "nomarchy-dock-audio" ''
|
||||
set -u
|
||||
PACTL=${pkgs.pulseaudio}/bin/pactl
|
||||
JQ=${pkgs.jq}/bin/jq
|
||||
SYSTEMCTL=${pkgs.systemd}/bin/systemctl
|
||||
LOGGER=${pkgs.util-linux}/bin/logger
|
||||
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
lock="$rt/nomarchy-dock-audio-reprobe.lock"
|
||||
TAB=$(printf '\t')
|
||||
|
||||
log() { "$LOGGER" -t nomarchy-dock-audio -- "$*"; }
|
||||
wait_for_pulse() {
|
||||
tries=0
|
||||
while [ "$tries" -lt 40 ]; do
|
||||
"$PACTL" info >/dev/null 2>&1 && return 0
|
||||
tries=$((tries+1)); sleep 0.25
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Highest-priority sink matching $1 whose active port is not explicitly
|
||||
# unavailable. With pro-audio profiles a sink may have no ports; its
|
||||
# existence is then the only availability signal PipeWire exposes.
|
||||
sinks_matching() {
|
||||
"$PACTL" --format=json list sinks 2>/dev/null \
|
||||
| "$JQ" -r --arg re "^($1)$" '
|
||||
[ .[] as $sink
|
||||
| $sink
|
||||
| select(.name | test($re))
|
||||
| select(
|
||||
((.ports // []) | length) == 0
|
||||
or .active_port == null
|
||||
or ([.ports[]?
|
||||
| select(.name == $sink.active_port)
|
||||
| (.availability // "unknown")][0]
|
||||
// "unknown") != "not available"
|
||||
) ]
|
||||
| sort_by(.priority // 0) | reverse[]
|
||||
| "\(.name)\t\(.description // .name)"'
|
||||
}
|
||||
dock_sinks() { sinks_matching '${dockSinkRe}'; }
|
||||
headphone_sinks() { sinks_matching '${headphoneSinkRe}'; }
|
||||
default_sink() { "$PACTL" get-default-sink 2>/dev/null; }
|
||||
|
||||
# A dock sink appears some short time after the plug, not with it. Poll
|
||||
# before concluding there is nothing to select: "not yet" and "not ever"
|
||||
# look identical in one shot, and treating the first as the second is what
|
||||
# would drive an ordinary plug down to the graph restart.
|
||||
wait_for_dock_sink() {
|
||||
tries=0
|
||||
while [ "$tries" -lt 20 ]; do
|
||||
[ -n "$(dock_sinks)" ] && return 0
|
||||
tries=$((tries+1)); sleep 0.25
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
select_first() { # $1 = sink lister, $2 = observable trigger
|
||||
lister="$1"; trigger="$2"
|
||||
candidate=$("$lister" | ${pkgs.coreutils}/bin/head -n 1) || candidate=
|
||||
if [ -z "$candidate" ]; then
|
||||
log "trigger=$trigger result=no-available-sink"
|
||||
return 1
|
||||
fi
|
||||
name=''${candidate%%"$TAB"*}
|
||||
desc=''${candidate#*"$TAB"}
|
||||
if "$PACTL" set-default-sink "$name" 2>/dev/null; then
|
||||
log "trigger=$trigger selected=$name description=$desc"
|
||||
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Audio" \
|
||||
"Output → $desc" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
log "trigger=$trigger result=set-default-failed candidate=$name"
|
||||
return 1
|
||||
}
|
||||
# A card parked on `pro-audio` (or `off`) publishes no sink we can route:
|
||||
# pro-audio exposes raw `pro-output-N` nodes with no ports, no routing and
|
||||
# no volume, so a monitor's audio is unreachable however it is chosen —
|
||||
# and WirePlumber stores that profile per card, so it survives forever
|
||||
# once set. When a dock plug finds nothing to select, repair the cards
|
||||
# that carry an available HDMI/DisplayPort output by moving them to their
|
||||
# best real profile. Deliberately narrow: an internal analog card is
|
||||
# never touched, and a card already on a routable profile is left alone,
|
||||
# so a considered pro-audio setup on anything else survives.
|
||||
repair_dock_cards() { # $1 = observable trigger
|
||||
trigger="$1"
|
||||
cards=$("$PACTL" --format=json list cards 2>/dev/null \
|
||||
| "$JQ" -r '
|
||||
.[]
|
||||
| select(.active_profile == "pro-audio" or .active_profile == "off")
|
||||
# An available HDMI/DP port is the monitor itself asking for
|
||||
# audio over the cable — that is what makes this card a dock.
|
||||
| select([.ports[]? | select((.type // "") == "HDMI")
|
||||
| select((.availability // "unknown") == "available")]
|
||||
| length > 0)
|
||||
| . as $card
|
||||
| [ $card.profiles // {} | to_entries[]
|
||||
| select(.key != "pro-audio" and .key != "off")
|
||||
| select((.value.sinks // 0) > 0)
|
||||
| select((.value.available // true) != false) ]
|
||||
| sort_by(-(.value.priority // 0))
|
||||
| if length == 0 then empty else "\($card.name)\t\(.[0].key)" end') || cards=
|
||||
[ -n "$cards" ] || return 1
|
||||
printf '%s\n' "$cards" | while IFS= read -r row; do
|
||||
card=''${row%%"$TAB"*}
|
||||
prof=''${row#*"$TAB"}
|
||||
if "$PACTL" set-card-profile "$card" "$prof" 2>/dev/null; then
|
||||
log "trigger=$trigger repaired-card=$card profile=$prof"
|
||||
else
|
||||
log "trigger=$trigger repair-failed=$card profile=$prof"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
select_first_dock() { # $1 = observable trigger
|
||||
select_first dock_sinks "$1" && return 0
|
||||
# Nothing routable to select is the symptom a parked card produces, so
|
||||
# it is also the only moment worth touching profiles. Retry once the
|
||||
# repaired card has had time to publish its sinks.
|
||||
repair_dock_cards "$1" || return 1
|
||||
sleep 1
|
||||
select_first dock_sinks "$1"
|
||||
}
|
||||
|
||||
case "''${1:-watch}" in
|
||||
candidates)
|
||||
dock_sinks ;;
|
||||
select)
|
||||
wait_for_pulse || { log "trigger=''${2:-manual} result=pulse-unavailable"; exit 1; }
|
||||
select_first_dock "''${2:-manual}" ;;
|
||||
reprobe)
|
||||
trigger="''${2:-monitoradded}"
|
||||
# mkdir is the debounce/lock: simultaneous monitoradded events from
|
||||
# an MST dock collapse into one graph restart. Runtime-only state,
|
||||
# removed on every exit path.
|
||||
if ! ${pkgs.coreutils}/bin/mkdir "$lock" 2>/dev/null; then
|
||||
log "trigger=$trigger result=debounced"
|
||||
exit 0
|
||||
fi
|
||||
trap '${pkgs.coreutils}/bin/rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
|
||||
log "trigger=$trigger action=reprobe-start"
|
||||
sleep 2
|
||||
wait_for_pulse || { log "trigger=$trigger result=pulse-unavailable"; exit 1; }
|
||||
|
||||
# Rung 1 + 2: the sink is there to be chosen, or a parked card needs
|
||||
# its profile repaired first (select_first_dock does both). This is
|
||||
# the whole job on a healthy plug — and it keeps every audio client's
|
||||
# connection alive, which is the point.
|
||||
wait_for_dock_sink || log "trigger=$trigger result=no-dock-sink-yet"
|
||||
select_first_dock "$trigger" && exit 0
|
||||
|
||||
# Rung 3: nothing routable even after the card repair. Restart the
|
||||
# graph — the inherited recovery, now confined to the case that has
|
||||
# actually run out of cheaper options — and try once more.
|
||||
log "trigger=$trigger action=graph-restart-fallback"
|
||||
"$SYSTEMCTL" --user restart \
|
||||
pipewire.service pipewire-pulse.service wireplumber.service \
|
||||
>/dev/null 2>&1 || log "trigger=$trigger action=graph-restart-returned-error"
|
||||
if wait_for_pulse; then
|
||||
sleep 0.75
|
||||
select_first_dock "$trigger" || true
|
||||
else
|
||||
log "trigger=$trigger result=pulse-did-not-return"
|
||||
fi ;;
|
||||
headphones)
|
||||
# Follow the jack, but only ever *towards* the headphones. On unplug
|
||||
# the sink disappears and any pin naming it goes stale, which makes
|
||||
# WirePlumber fall back by priority on its own — to the dock when
|
||||
# docked, to the speakers otherwise — so there is nothing to undo.
|
||||
wait_for_pulse || exit 0
|
||||
hp=$(headphone_sinks | ${pkgs.coreutils}/bin/head -n 1) || hp=
|
||||
[ -n "$hp" ] || exit 0
|
||||
name=''${hp%%"$TAB"*}
|
||||
[ "$(default_sink)" = "$name" ] && exit 0
|
||||
select_first headphone_sinks "''${2:-jack}" || true ;;
|
||||
watch)
|
||||
# Dock selection is deliberately NOT done at service startup: a
|
||||
# restart or relogin while docked must not erase a manual speaker
|
||||
# choice. Only the compositor's fresh monitoradded event calls
|
||||
# `reprobe`.
|
||||
#
|
||||
# The jack is different, and is why this loop exists. A sink that
|
||||
# WirePlumber has been told to prefer (by us on a dock plug, or by
|
||||
# the user in the Audio menu / a mixer) is stored as the configured
|
||||
# default, and that outranks every priority rule — so on UCM cards,
|
||||
# where the headphones are a sink of their own rather than a route,
|
||||
# plugging them in could no longer steal the audio back. Watch for
|
||||
# that sink appearing and select it explicitly.
|
||||
wait_for_pulse || true
|
||||
# A graph restart closes `pactl subscribe`, sometimes with status 0.
|
||||
# Always resubscribe; log one concise closure line for diagnosis.
|
||||
while :; do
|
||||
if LC_ALL=C "$PACTL" subscribe 2>/dev/null \
|
||||
| while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*"'new' on sink"*|*"'change' on card"*)
|
||||
# Re-entering the tool keeps the decision out of this
|
||||
# read loop, which must never block on the graph.
|
||||
"$0" headphones jack || true ;;
|
||||
esac
|
||||
done
|
||||
then status=0; else status=$?; fi
|
||||
log "subscription-closed status=$status; retrying"
|
||||
sleep 1
|
||||
wait_for_pulse || sleep 1
|
||||
done ;;
|
||||
*)
|
||||
echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]|headphones [trigger]]" >&2
|
||||
exit 64 ;;
|
||||
esac
|
||||
'';
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.dockAudio.enable {
|
||||
home.packages = [ tool ];
|
||||
systemd.user.services.nomarchy-dock-audio = {
|
||||
Unit = {
|
||||
Description = "Reprobe and select dock/monitor audio on display hotplug";
|
||||
After = [ "graphical-session.target" ];
|
||||
PartOf = [ "graphical-session.target" ];
|
||||
};
|
||||
Service = {
|
||||
ExecStart = "${tool}/bin/nomarchy-dock-audio watch";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 2;
|
||||
};
|
||||
Install.WantedBy = [ "graphical-session.target" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -9,11 +9,15 @@ let
|
||||
cfg = config.nomarchy;
|
||||
c = cfg.theme.colors;
|
||||
|
||||
# #122: sextants (2×3 subcells) give ~6× the detail of half/full blocks.
|
||||
# Size and raster width stepped up together so the mark still fills the
|
||||
# logo column without looking sparse. Diagonals still staircase a little
|
||||
# (the SVG is an angled N); that is a logo-design limit, not chafa's.
|
||||
logo = pkgs.runCommand "nomarchy-fastfetch-logo"
|
||||
{ nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg pkgs.chafa ]; } ''
|
||||
rsvg-convert -w 220 ${../nixos/branding/logo.svg} > logo.png
|
||||
rsvg-convert -w 360 ${../nixos/branding/logo.svg} > logo.png
|
||||
magick logo.png -fill "${c.accent}" -colorize 100 logo-c.png
|
||||
chafa --format symbols --symbols block --size 20x10 --colors full --polite on logo-c.png > $out
|
||||
chafa --format symbols --symbols sextant --size 24x12 --colors full --polite on logo-c.png > $out
|
||||
'';
|
||||
in
|
||||
{
|
||||
|
||||
47
modules/home/first-boot.nix
Normal file
47
modules/home/first-boot.nix
Normal file
@@ -0,0 +1,47 @@
|
||||
# First-session welcome toast (nomarchy.firstBootWelcome) — one dismissible
|
||||
# "you're set" notification pointing at SUPER+M / SUPER+T / SUPER+? and
|
||||
# Network, then writes settings.firstBootShown into the flake checkout
|
||||
# (GOALS: no state outside the checkout). Live ISO keeps its own toast
|
||||
# (hosts/live.nix); the script self-skips on hostname nomarchy-live.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
config = lib.mkIf config.nomarchy.firstBootWelcome.enable {
|
||||
systemd.user.services.nomarchy-first-boot = {
|
||||
Unit = {
|
||||
Description = "Nomarchy first-session welcome toast";
|
||||
PartOf = [ "graphical-session.target" ];
|
||||
# After the session AND the notification daemon. Without
|
||||
# After=swaync, first login races D-Bus Notifications and the
|
||||
# toast times out (migration: journal "Timeout was reached") —
|
||||
# or worse, a later success writes firstBootShown without the
|
||||
# user ever seeing the toast. Wants= so we still run if swaync
|
||||
# is disabled (notify-send will fail and leave the marker unset).
|
||||
After = [
|
||||
"graphical-session.target"
|
||||
"graphical-session-pre.target"
|
||||
"swaync.service"
|
||||
];
|
||||
Wants = [ "swaync.service" ];
|
||||
};
|
||||
Service = {
|
||||
Type = "oneshot";
|
||||
# PATH: real notify-send + theme-sync; VM check shims both via PATH.
|
||||
# NOMARCHY_PATH matches home.sessionVariables so the marker lands
|
||||
# in the user's flake checkout.
|
||||
Environment = [
|
||||
"PATH=${lib.makeBinPath [
|
||||
pkgs.libnotify
|
||||
pkgs.nomarchy-state-sync
|
||||
pkgs.coreutils
|
||||
]}"
|
||||
"NOMARCHY_PATH=%h/.nomarchy"
|
||||
];
|
||||
# Brief settle after swaync is up; the script also retries.
|
||||
ExecStartPre = "${pkgs.coreutils}/bin/sleep 1";
|
||||
ExecStart = "${pkgs.nomarchy-first-boot}/bin/nomarchy-first-boot";
|
||||
};
|
||||
Install.WantedBy = [ "graphical-session.target" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
# Ghostty — Nomarchy's default terminal, themed from theme-state.json.
|
||||
# Colors, fonts and the full 16-color ANSI palette are baked from the
|
||||
# JSON at eval time.
|
||||
{ config, lib, ... }:
|
||||
|
||||
let
|
||||
t = config.nomarchy.theme;
|
||||
c = t.colors;
|
||||
in
|
||||
{
|
||||
programs.ghostty = lib.mkIf config.nomarchy.ghostty.enable {
|
||||
enable = true;
|
||||
enableBashIntegration = true;
|
||||
|
||||
settings = {
|
||||
# ── Typography (from theme-state.json) ────────────────────────
|
||||
font-family = t.fonts.mono;
|
||||
font-size = t.fonts.size;
|
||||
|
||||
# ── Colors (from theme-state.json) ────────────────────────────
|
||||
background = c.base;
|
||||
foreground = c.text;
|
||||
cursor-color = c.accent;
|
||||
selection-background = c.overlay;
|
||||
selection-foreground = c.text;
|
||||
split-divider-color = c.surface;
|
||||
|
||||
# "N=#rrggbb" entries; Ghostty accepts repeated `palette` keys,
|
||||
# which the HM module renders from this list.
|
||||
palette = lib.imap0 (i: color: "${toString i}=${color}") t.ansi;
|
||||
|
||||
# ── Chrome ────────────────────────────────────────────────────
|
||||
# background-opacity is theme-driven (normal priority — use the
|
||||
# CLI); the rest are mkDefault so a plain home.nix value wins.
|
||||
background-opacity = t.ui.terminalOpacity;
|
||||
window-padding-x = lib.mkDefault 12;
|
||||
window-padding-y = lib.mkDefault 12;
|
||||
window-decoration = lib.mkDefault false;
|
||||
gtk-single-instance = lib.mkDefault true;
|
||||
confirm-close-surface = lib.mkDefault false;
|
||||
};
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
# hyprlock + hypridle — screen locking and idle management, themed from
|
||||
# theme-state.json. One concern, one file: hypridle drives WHEN (idle
|
||||
# state.json. One concern, one file: hypridle drives WHEN (idle
|
||||
# lock, display off, suspend, lock-before-sleep), hyprlock is the
|
||||
# themed lock screen itself (also behind the power menu's Lock entry).
|
||||
{ config, lib, pkgs, ... }:
|
||||
@@ -19,9 +19,44 @@ let
|
||||
done
|
||||
exit 1
|
||||
'';
|
||||
|
||||
# Absolute store paths for hypridle (thin PATH). Wake reuses the shared
|
||||
# helper; a mini transition avoids a circular import on hyprland.nix.
|
||||
displayWake =
|
||||
let
|
||||
miniTransition = pkgs.writeShellScriptBin "nomarchy-display-transition" ''
|
||||
set -u
|
||||
case "''${1:-}" in
|
||||
undock|enable)
|
||||
internal="''${2:-}"
|
||||
[ -n "$internal" ] || exit 64
|
||||
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true
|
||||
if ! hyprctl monitors -j 2>/dev/null \
|
||||
| ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \
|
||||
>/dev/null 2>&1; then
|
||||
hyprctl reload >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
fi
|
||||
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true
|
||||
hyprctl dispatch dpms on >/dev/null 2>&1 || true
|
||||
hyprctl monitors -j 2>/dev/null \
|
||||
| ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \
|
||||
>/dev/null 2>&1
|
||||
;;
|
||||
*) exit 64 ;;
|
||||
esac
|
||||
'';
|
||||
in
|
||||
(import ./display-tools.nix {
|
||||
inherit pkgs;
|
||||
displayTransition = miniTransition;
|
||||
}).displayWakeTool;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.idle.enable {
|
||||
# Smart suspend on PATH for manual use; hypridle uses the store path.
|
||||
home.packages = [ pkgs.nomarchy-suspend ];
|
||||
|
||||
programs.hyprlock = {
|
||||
enable = true;
|
||||
settings = {
|
||||
@@ -43,9 +78,21 @@ in
|
||||
check_color = rgb c.warn;
|
||||
fail_color = rgb c.bad;
|
||||
rounding = t.ui.rounding;
|
||||
placeholder_text = "<i>password…</i>";
|
||||
# Sentence case, matching the $FPRINTPROMPT label below: both can be
|
||||
# on screen at once, so they must not read as two different voices.
|
||||
placeholder_text = "<i>Password…</i>";
|
||||
}];
|
||||
|
||||
# The lock screen is deliberately just the clock: the input field
|
||||
# fades out while empty (hyprlock's fade_on_empty default) and only
|
||||
# appears once you type. So the fingerprint hint CANNOT live in the
|
||||
# field's placeholder — $FPRINTPROMPT renders faithfully into a widget
|
||||
# nobody sees until they have already given up on the reader and
|
||||
# started typing. It needs a surface that is visible at rest, and a
|
||||
# label is the one that keeps the clock-only look. (Confirmed on
|
||||
# hardware 2026-07-14: labels do expand $FPRINTPROMPT, so the line is
|
||||
# live — the ready message, then the present message on touch — and
|
||||
# not static text. Both live in auth.fingerprint below.)
|
||||
label = [{
|
||||
monitor = "";
|
||||
text = "$TIME";
|
||||
@@ -55,7 +102,32 @@ in
|
||||
position = "0, 120";
|
||||
halign = "center";
|
||||
valign = "center";
|
||||
}];
|
||||
}] ++ lib.optional cfg.idle.fingerprint {
|
||||
monitor = "";
|
||||
text = "$FPRINTPROMPT";
|
||||
# subtext-on-base is the palette's secondary-text role and is held
|
||||
# to a 3.0 contrast floor on every theme by checks.theme-contrast —
|
||||
# the same guard that exists because two themes once shipped
|
||||
# subtext == base and made hint text invisible.
|
||||
color = rgb c.subtext;
|
||||
font_size = 16;
|
||||
font_family = t.fonts.ui;
|
||||
position = "0, -160";
|
||||
halign = "center";
|
||||
valign = "center";
|
||||
};
|
||||
} // lib.optionalAttrs cfg.idle.fingerprint {
|
||||
# hyprlock does NOT take a fingerprint through PAM: its PAM stack runs
|
||||
# only on submit, so a parallel module never gets to poll the reader.
|
||||
# This is a separate backend of its own, talking to fprintd over
|
||||
# D-Bus, and `nomarchy.hardware.fingerprint.pam` does not reach it —
|
||||
# which is why finger-unlock at the lock screen did nothing at all
|
||||
# until this option existed, while sudo took a finger happily.
|
||||
auth.fingerprint = {
|
||||
enabled = true;
|
||||
ready_message = "Enter password or scan your finger";
|
||||
present_message = "Scanning your finger…";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -71,7 +143,10 @@ in
|
||||
# can't be safely dropped after the fact — killing the locker trips
|
||||
# its "go to a tty" crash failsafe). See nomarchy-lock-before-sleep
|
||||
# in modules/nixos/default.nix.
|
||||
after_sleep_cmd = "hyprctl dispatch dpms on";
|
||||
# #127: not only dpms on — if dock mode left zero enabled
|
||||
# outputs (eDP disabled + external gone/black), re-enable the
|
||||
# internal. Absolute store path: hypridle's PATH is thin.
|
||||
after_sleep_cmd = "${lib.getExe displayWake}";
|
||||
};
|
||||
listener = [
|
||||
# Lock and screen-off are the same on either power source —
|
||||
@@ -79,15 +154,28 @@ in
|
||||
{ timeout = 300; on-timeout = "loginctl lock-session"; }
|
||||
{
|
||||
timeout = 600;
|
||||
# Blanks in every dock/lid state — the #127 clamshell skip is
|
||||
# gone: it was a cure for a cause that does not exist (proven on
|
||||
# hardware 2026-07-16, see ROADMAP). The wake no longer depends
|
||||
# on this daemon surviving — misc:{key_press,mouse_move}_enables_dpms
|
||||
# in hyprland.nix means input wakes the screen compositor-side.
|
||||
on-timeout = "hyprctl dispatch dpms off";
|
||||
on-resume = "hyprctl dispatch dpms on";
|
||||
# Same rescue path as after_sleep (#127): re-enables a disabled
|
||||
# internal, which plain `dpms on` cannot do.
|
||||
on-resume = "${lib.getExe displayWake}";
|
||||
}
|
||||
# Suspend only on battery, and sooner than the old fixed 30 min
|
||||
# (it now only fires unplugged). Plugged in, the machine stays
|
||||
# up — long builds, media, presentations aren't killed mid-idle.
|
||||
# Closing the lid still suspends on AC (logind's default): that's
|
||||
# an explicit "I'm done", distinct from sitting idle.
|
||||
{ timeout = 900; on-timeout = "${onAc} || systemctl suspend"; }
|
||||
# nomarchy-suspend (#115): on battery + hibernate wired + toggle
|
||||
# on → suspend-then-hibernate (1h → disk); else plain suspend.
|
||||
# Lid close is logind's job (not hypridle): undocked lid still
|
||||
# suspends / s2h (HandleLidSwitch); docked/clamshell lid is ignore
|
||||
# (HandleLidSwitchDocked — modules/nixos/power.nix, #86).
|
||||
{
|
||||
timeout = 900;
|
||||
on-timeout = "${onAc} || ${lib.getExe pkgs.nomarchy-suspend}";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,62 +7,107 @@
|
||||
# key — the key name
|
||||
# action — the Hyprland dispatcher (+args) that the bind runs
|
||||
# desc — human label, shown in the cheatsheet
|
||||
# group — cheatsheet section: Window | Workspace | Menu | Media (#108)
|
||||
#
|
||||
# `extra` holds cheatsheet-only rows for binds generated elsewhere
|
||||
# (per-workspace numbers, mouse drags) so they still show up under SUPER+?.
|
||||
{
|
||||
binds = [
|
||||
{ mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; }
|
||||
{ mods = "$mod"; key = "Space"; action = "exec, rofi -show drun"; desc = "Quick launch (apps)"; }
|
||||
{ mods = "$mod"; key = "D"; action = "exec, rofi -show drun"; desc = "App launcher"; }
|
||||
{ mods = "$mod"; key = "M"; action = "exec, nomarchy-menu"; desc = "Main menu"; }
|
||||
{ mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; }
|
||||
{ mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; }
|
||||
{ mods = "$mod"; key = "F"; action = "fullscreen"; desc = "Fullscreen"; }
|
||||
{ mods = "$mod"; key = "V"; action = "togglefloating"; desc = "Toggle floating"; }
|
||||
{ mods = "$mod SHIFT"; key = "E"; action = "exit"; desc = "Exit Hyprland"; }
|
||||
{ mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; group = "Window"; }
|
||||
{ mods = "$mod"; key = "Space"; action = "exec, rofi -show drun -theme launcher"; desc = "App launcher"; group = "Menu"; }
|
||||
{ mods = "$mod"; key = "M"; action = "exec, nomarchy-menu"; desc = "Main menu"; group = "Menu"; }
|
||||
{ mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; group = "Window"; }
|
||||
{ mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; group = "Window"; }
|
||||
{ mods = "$mod"; key = "F"; action = "fullscreen"; desc = "Fullscreen"; group = "Window"; }
|
||||
{ mods = "$mod"; key = "V"; action = "togglefloating"; desc = "Toggle floating"; group = "Window"; }
|
||||
{ mods = "$mod SHIFT"; key = "E"; action = "exit"; desc = "Exit Hyprland"; group = "Window"; }
|
||||
|
||||
# Theme picker (menu dispatcher): apply writes the state and runs
|
||||
# home-manager switch (progress via notify-send).
|
||||
{ mods = "$mod"; key = "T"; action = "exec, nomarchy-menu theme"; desc = "Theme picker"; }
|
||||
{ mods = "$mod"; key = "T"; action = "exec, nomarchy-menu theme"; desc = "Theme picker"; group = "Menu"; }
|
||||
# Cycle the current theme's wallpapers (instant, no rebuild).
|
||||
{ mods = "$mod SHIFT"; key = "T"; action = "exec, nomarchy-theme-sync bg next"; desc = "Next wallpaper"; }
|
||||
{ mods = "$mod SHIFT"; key = "T"; action = "exec, nomarchy-state-sync bg next"; desc = "Next wallpaper"; group = "Menu"; }
|
||||
|
||||
# Power menu via the dispatcher. Not Escape: Super+Escape gets
|
||||
# swallowed before reaching the dispatcher on some setups.
|
||||
{ mods = "$mod"; key = "X"; action = "exec, nomarchy-menu power"; desc = "Power menu"; }
|
||||
{ mods = "$mod"; key = "X"; action = "exec, nomarchy-menu power"; desc = "Power menu"; group = "Menu"; }
|
||||
|
||||
{ mods = "$mod"; key = "N"; action = "exec, swaync-client -t"; desc = "Notification centre"; }
|
||||
# SUPER+? (the "question" keysym already implies Shift on most layouts).
|
||||
{ mods = "$mod"; key = "question"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; }
|
||||
{ mods = "$mod"; key = "N"; action = "exec, swaync-client -t"; desc = "Notification centre"; group = "Media"; }
|
||||
# SUPER+? — ? is Shift+/. SHIFT stays in the modmask (you hold it), but
|
||||
# the keysym must be the BASE key `slash`, not `question`: Hyprland
|
||||
# resolves the sym with Shift consumed, so `question` never matches while
|
||||
# Shift is down — same as the `$mod SHIFT, 1` workspace binds. (item 26
|
||||
# fixed the modmask but kept the shifted keysym → still dead; item 32.)
|
||||
# The cheatsheet still renders this row as SUPER + ? (rofi.nix).
|
||||
{ mods = "$mod SHIFT"; key = "slash"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; group = "Menu"; }
|
||||
|
||||
# Menu functions — SUPER+CTRL+<mnemonic> jumps straight to a
|
||||
# nomarchy-menu module (all also reachable from the SUPER+M picker).
|
||||
{ mods = "$mod CTRL"; key = "V"; action = "exec, nomarchy-menu clipboard"; desc = "Clipboard history"; }
|
||||
{ mods = "$mod CTRL"; key = "C"; action = "exec, nomarchy-menu calc"; desc = "Calculator"; }
|
||||
{ mods = "$mod CTRL"; key = "W"; action = "exec, nomarchy-menu web"; desc = "Web search"; }
|
||||
{ mods = "$mod CTRL"; key = "F"; action = "exec, nomarchy-menu files"; desc = "File search"; }
|
||||
{ mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; }
|
||||
{ mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (nmtui)"; }
|
||||
{ mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; }
|
||||
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; }
|
||||
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; }
|
||||
{ mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; }
|
||||
{ mods = "$mod CTRL"; key = "V"; action = "exec, nomarchy-menu clipboard"; desc = "Clipboard history"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "C"; action = "exec, nomarchy-menu calc"; desc = "Calculator"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "W"; action = "exec, nomarchy-menu web"; desc = "Web search"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "F"; action = "exec, nomarchy-menu files"; desc = "File search"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (networkmanager_dmenu)"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "R"; action = "exec, nomarchy-menu airplane"; desc = "Airplane mode (Wi-Fi + Bluetooth)"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "K"; action = "exec, nomarchy-menu keyboard"; desc = "Keyboard layout"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; group = "Media"; }
|
||||
{ mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker (→ clipboard)"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask AI"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; group = "Media"; }
|
||||
# I as in "settings" muscle memory (Super+I elsewhere); T for Tools.
|
||||
{ mods = "$mod CTRL"; key = "I"; action = "exec, nomarchy-menu system"; desc = "System menu"; group = "Menu"; }
|
||||
{ mods = "$mod CTRL"; key = "T"; action = "exec, nomarchy-menu tools"; desc = "Tools menu"; group = "Menu"; }
|
||||
# The one SUPER+CTRL row that is not a menu module: lock earns the
|
||||
# family slot because bare SUPER+L is a common app bind and Escape is
|
||||
# unusable (see the power-menu note above). Same dispatcher the power
|
||||
# menu's Lock row uses (rofi.nix) — logind, not `hyprlock` directly, so
|
||||
# the session is marked locked and hypridle's lock_cmd stays the one
|
||||
# place that decides how a lock actually looks.
|
||||
{ mods = "$mod CTRL"; key = "L"; action = "exec, loginctl lock-session"; desc = "Lock screen"; group = "Menu"; }
|
||||
{ mods = "$mod SHIFT"; key = "C"; action = "exec, hyprpicker -a"; desc = "Color picker (→ clipboard)"; group = "Menu"; }
|
||||
# #127: dump display state to ~/nomarchy-display-dump-*.txt without SSH.
|
||||
# If the seat is locked, hyprlock may swallow this — use Ctrl+Alt+F3 and
|
||||
# run `nomarchy-display-dump` / `nomarchy-display-wake` on the TTY instead.
|
||||
{ mods = "$mod SHIFT"; key = "D"; action = "exec, nomarchy-display-dump keybind"; desc = "Dump display diagnostics (~/)"; group = "Menu"; }
|
||||
|
||||
# Focus
|
||||
{ mods = "$mod"; key = "H"; action = "movefocus, l"; desc = "Focus left"; }
|
||||
{ mods = "$mod"; key = "L"; action = "movefocus, r"; desc = "Focus right"; }
|
||||
{ mods = "$mod"; key = "K"; action = "movefocus, u"; desc = "Focus up"; }
|
||||
{ mods = "$mod"; key = "J"; action = "movefocus, d"; desc = "Focus down"; }
|
||||
# Focus — SUPER + arrow keys.
|
||||
{ mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; group = "Window"; }
|
||||
{ mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; group = "Window"; }
|
||||
{ mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; group = "Window"; }
|
||||
{ mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; group = "Window"; }
|
||||
|
||||
# Screenshot region to clipboard (the menu's Capture module has more).
|
||||
{ mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy"; desc = "Screenshot region → clipboard"; }
|
||||
# Multi-monitor workspace movement — SUPER + ALT + arrow keys.
|
||||
{ mods = "$mod ALT"; key = "left"; action = "movecurrentworkspacetomonitor, l"; desc = "Move workspace to left monitor"; group = "Workspace"; }
|
||||
{ mods = "$mod ALT"; key = "right"; action = "movecurrentworkspacetomonitor, r"; desc = "Move workspace to right monitor"; group = "Workspace"; }
|
||||
{ mods = "$mod ALT"; key = "up"; action = "movecurrentworkspacetomonitor, u"; desc = "Move workspace to upper monitor"; group = "Workspace"; }
|
||||
{ mods = "$mod ALT"; key = "down"; action = "movecurrentworkspacetomonitor, d"; desc = "Move workspace to lower monitor"; group = "Workspace"; }
|
||||
|
||||
# Screenshots (the menu's Capture module has the rest: OCR, recording).
|
||||
# Bare Print → region to clipboard; the two → file binds save a
|
||||
# timestamped PNG under ~/Pictures/Screenshots and toast the path, the
|
||||
# same plumbing the Capture menu's "→ file" rows use.
|
||||
{ mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy && notify-send Screenshot \"Region copied to clipboard.\""; desc = "Screenshot region → clipboard"; group = "Media"; }
|
||||
{ mods = "SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot region → file"; group = "Media"; }
|
||||
{ mods = "CTRL"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot screen → file"; group = "Media"; }
|
||||
{ mods = "$mod SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" - | satty --filename - --fullscreen --output-filename \"$f\""; desc = "Annotate region"; group = "Media"; }
|
||||
];
|
||||
|
||||
# Rendered only when the session has >1 layout (a comma in
|
||||
# nomarchy.keyboard.layout) — hyprland.nix and rofi.nix both gate on
|
||||
# that same condition, so the bind and its cheatsheet row stay in
|
||||
# step. `current` targets the focused keyboard, so a board with its
|
||||
# own per-device layout (a single one) is a no-op, never a leak.
|
||||
multiLayoutBinds = [
|
||||
{ mods = "$mod SHIFT"; key = "K"; action = "exec, hyprctl switchxkblayout current next"; desc = "Cycle keyboard layout"; group = "Menu"; }
|
||||
];
|
||||
|
||||
extra = [
|
||||
{ keys = "SUPER + 1-9"; desc = "Switch to workspace 1-9"; }
|
||||
{ keys = "SUPER + SHIFT + 1-9"; desc = "Move window to workspace 1-9"; }
|
||||
{ keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; }
|
||||
{ keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; }
|
||||
{ keys = "SUPER + 1-9, 0"; desc = "Switch to workspace 1-10 (0 = 10)"; group = "Workspace"; }
|
||||
{ keys = "SUPER + SHIFT + 1-9, 0"; desc = "Move window to workspace 1-10 (0 = 10)"; group = "Workspace"; }
|
||||
{ keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; group = "Window"; }
|
||||
{ keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; group = "Media"; }
|
||||
{ keys = "Bar: click"; desc = "Caffeine — hold the screen awake (idle inhibitor)"; group = "Media"; }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
# GNOME session to lean on) and themed by Stylix's Qt config, so the
|
||||
# passphrase dialog tracks the palette.
|
||||
#
|
||||
# SSH_AUTH_SOCK is exported by the agent's shell integration (zsh, on via
|
||||
# home.shell.enableZshIntegration in shell.nix). Terminal git/ssh is the
|
||||
# supported path; a GUI client launched outside a shell won't inherit the
|
||||
# socket — revisit with a session-level export if that's ever wanted.
|
||||
# SSH_AUTH_SOCK reaches both terminal and GUI clients: the agent's zsh
|
||||
# integration sets it in interactive shells (home.shell.enableZshIntegration
|
||||
# in shell.nix), and a session-level home.sessionVariables export (below)
|
||||
# covers GUI clients launched outside a shell — e.g. from the rofi launcher —
|
||||
# which never inherit the interactive shell's copy. Both resolve the same
|
||||
# socket via gpgconf, so they can't drift.
|
||||
#
|
||||
# gnome-keyring (system side) stays the Secret Service for application
|
||||
# secrets; modern gnome-keyring no longer runs an SSH agent, so there is no
|
||||
@@ -38,5 +40,16 @@ in
|
||||
defaultCacheTtlSsh = 1800;
|
||||
maxCacheTtlSsh = 7200;
|
||||
};
|
||||
|
||||
# Session-level SSH_AUTH_SOCK so GUI clients launched outside a shell
|
||||
# (rofi launcher, autostarted apps) reach the agent — the shell
|
||||
# integration only covers interactive shells. Resolved with gpgconf at
|
||||
# session-init time (the same lookup the shell integration uses), so it
|
||||
# tracks the agent's real socket rather than a hardcoded path; valid
|
||||
# before first use since the socket is systemd-activated. Reaches GUI
|
||||
# apps the way NIXOS_OZONE_WL does — sourced into the login shell that
|
||||
# starts Hyprland, so every spawned client inherits it.
|
||||
home.sessionVariables.SSH_AUTH_SOCK =
|
||||
"$(${pkgs.gnupg}/bin/gpgconf --list-dirs agent-ssh-socket)";
|
||||
};
|
||||
}
|
||||
|
||||
60
modules/home/kitty.nix
Normal file
60
modules/home/kitty.nix
Normal file
@@ -0,0 +1,60 @@
|
||||
# Kitty — Nomarchy's only terminal, themed from state.json.
|
||||
# Colors, fonts and the full 16-color ANSI palette are baked from the
|
||||
# JSON at eval time (same contract Ghostty used to have). Always
|
||||
# installed: SUPER+Return, SUPER+E, doctor, calendar, and $TERMINAL
|
||||
# all depend on it.
|
||||
{ config, lib, ... }:
|
||||
|
||||
let
|
||||
t = config.nomarchy.theme;
|
||||
c = t.colors;
|
||||
colorSettings = lib.listToAttrs (
|
||||
lib.imap0 (i: color: {
|
||||
name = "color${toString i}";
|
||||
value = color;
|
||||
}) t.ansi
|
||||
);
|
||||
in
|
||||
{
|
||||
# Kitty is ALWAYS installed. `nomarchy.kitty.enable` gates only whether
|
||||
# Nomarchy's theming/config is applied — a user can keep kitty but drop
|
||||
# our config; they cannot remove kitty itself without breaking the
|
||||
# desktop's load-bearing classed windows.
|
||||
programs.kitty = {
|
||||
enable = true;
|
||||
shellIntegration.enableBashIntegration = true;
|
||||
shellIntegration.enableZshIntegration = true;
|
||||
|
||||
font = lib.mkIf config.nomarchy.kitty.enable {
|
||||
name = t.fonts.mono;
|
||||
size = t.fonts.size;
|
||||
};
|
||||
|
||||
settings = lib.mkIf config.nomarchy.kitty.enable ({
|
||||
background = c.base;
|
||||
foreground = c.text;
|
||||
cursor = c.accent;
|
||||
cursor_text_color = c.base;
|
||||
selection_background = c.overlay;
|
||||
selection_foreground = c.text;
|
||||
url_color = c.accent;
|
||||
active_border_color = c.accent;
|
||||
inactive_border_color = c.surface;
|
||||
background_opacity = t.ui.terminalOpacity;
|
||||
window_padding_width = lib.mkDefault 12;
|
||||
confirm_os_window_close = lib.mkDefault 0;
|
||||
enable_audio_bell = lib.mkDefault false;
|
||||
wayland_titlebar_color = lib.mkDefault "background";
|
||||
# Fresh windows for doctor/calendar --class launches (not one shared instance).
|
||||
single_instance = lib.mkDefault false;
|
||||
# Kitty's default is to remember the last OS window's size and replay it
|
||||
# into the next one (~/.cache/kitty/main.json). Harmless while tiled —
|
||||
# the compositor sizes those — but every *floating* kitty then inherits
|
||||
# whatever the last window happened to be, so a sheet opened after a
|
||||
# maximized terminal opens maximized (#139: the Ghostty regression, which
|
||||
# kept no such memory). Floats should be deterministic: each launcher
|
||||
# asks for the size it wants.
|
||||
remember_window_size = lib.mkDefault false;
|
||||
} // colorSettings);
|
||||
};
|
||||
}
|
||||
68
modules/home/mime.nix
Normal file
68
modules/home/mime.nix
Normal file
@@ -0,0 +1,68 @@
|
||||
# Default applications (xdg mimeapps.list) — without this, "open a
|
||||
# PDF/photo" falls to whatever GTK guesses first (GIMP for images).
|
||||
# Every association is mkDefault AND degrades gracefully by design: an
|
||||
# entry whose .desktop file isn't installed is skipped by GIO/xdg-open,
|
||||
# which then falls through to whatever else claims the type — so
|
||||
# deleting an app from the template suite (or never uncommenting the
|
||||
# browser) leaves no broken "open" behaviour, just the old guessing.
|
||||
{ config, lib, ... }:
|
||||
|
||||
lib.mkIf config.nomarchy.mime.enable {
|
||||
xdg.mimeApps = {
|
||||
enable = lib.mkDefault true;
|
||||
defaultApplications = lib.mapAttrs (_: v: lib.mkDefault v) {
|
||||
"application/pdf" = "org.pwmt.zathura.desktop";
|
||||
|
||||
"image/png" = "imv.desktop";
|
||||
"image/jpeg" = "imv.desktop";
|
||||
"image/gif" = "imv.desktop";
|
||||
"image/webp" = "imv.desktop";
|
||||
"image/avif" = "imv.desktop";
|
||||
"image/bmp" = "imv.desktop";
|
||||
"image/tiff" = "imv.desktop";
|
||||
"image/svg+xml" = "imv.desktop";
|
||||
|
||||
# Video → mpv (the template's media player).
|
||||
"video/mp4" = "mpv.desktop";
|
||||
"video/webm" = "mpv.desktop";
|
||||
"video/x-matroska" = "mpv.desktop";
|
||||
"video/quicktime" = "mpv.desktop";
|
||||
|
||||
# Audio → Amberol (the template's GTK4 music player). Amberol
|
||||
# registers these types itself, so this only sets the preference over
|
||||
# mpv, which also claims them. Both x- and canonical names because
|
||||
# files report either. Degrades to mpv/whatever if Amberol is dropped.
|
||||
"audio/mpeg" = "io.bassi.Amberol.desktop";
|
||||
"audio/flac" = "io.bassi.Amberol.desktop";
|
||||
"audio/x-flac" = "io.bassi.Amberol.desktop";
|
||||
"audio/ogg" = "io.bassi.Amberol.desktop";
|
||||
"audio/x-vorbis+ogg" = "io.bassi.Amberol.desktop";
|
||||
"audio/opus" = "io.bassi.Amberol.desktop";
|
||||
"audio/wav" = "io.bassi.Amberol.desktop";
|
||||
"audio/x-wav" = "io.bassi.Amberol.desktop";
|
||||
"audio/mp4" = "io.bassi.Amberol.desktop";
|
||||
"audio/x-m4a" = "io.bassi.Amberol.desktop";
|
||||
"audio/aac" = "io.bassi.Amberol.desktop";
|
||||
|
||||
# Prefer the template's vscode; fall through to gnome-text-editor
|
||||
# (ships on the live ISO after #103, and is the only editor there).
|
||||
# A singleton that names an absent .desktop is silently skipped by
|
||||
# GIO and leaves no handler at all — the #94 / #119 trap. HM tries
|
||||
# the next entry when the preferred one is missing.
|
||||
"text/plain" = [ "code.desktop" "org.gnome.TextEditor.desktop" ];
|
||||
|
||||
# The system-side Thunar (nomarchy.system.fileManager).
|
||||
"inode/directory" = "thunar.desktop";
|
||||
|
||||
# Template ships chromium (chromium-browser.desktop), and since #103
|
||||
# so does the live ISO — an entry naming a package nothing installs is
|
||||
# silently skipped by GIO, which is exactly how the live session ended
|
||||
# up with no default browser at all (#94). Delete the package and the
|
||||
# entry goes quiet again; install another browser and/or override these
|
||||
# keys to retarget.
|
||||
"text/html" = "chromium-browser.desktop";
|
||||
"x-scheme-handler/http" = "chromium-browser.desktop";
|
||||
"x-scheme-handler/https" = "chromium-browser.desktop";
|
||||
};
|
||||
};
|
||||
}
|
||||
72
modules/home/monitor-rules.nix
Normal file
72
modules/home/monitor-rules.nix
Normal file
@@ -0,0 +1,72 @@
|
||||
# Pure monitor-rule composition, split out of hyprland.nix so
|
||||
# checks.display-profiles can exercise the overlay semantics directly —
|
||||
# no Home Manager evaluation, no state-file fixture.
|
||||
{ lib }:
|
||||
|
||||
rec {
|
||||
# Skeleton for an entry built outside the option type (mirrors the
|
||||
# monitorType defaults in options.nix).
|
||||
defaults = {
|
||||
resolution = "preferred"; position = "auto"; scale = 1;
|
||||
transform = null; mirror = null; bitdepth = null; vrr = null; extra = "";
|
||||
};
|
||||
|
||||
# An entry -> a Hyprland `monitor` rule. Unset optional fields are
|
||||
# omitted; `resolution = "disable"` collapses to the short form.
|
||||
rule = m:
|
||||
if m.resolution == "disable" then "${m.name}, disable"
|
||||
else lib.concatStringsSep ", " (
|
||||
[ m.name m.resolution (toString m.position) (toString m.scale) ]
|
||||
++ lib.optionals (m.transform != null) [ "transform" (toString m.transform) ]
|
||||
++ lib.optionals (m.mirror != null) [ "mirror" m.mirror ]
|
||||
++ lib.optionals (m.bitdepth != null) [ "bitdepth" (toString m.bitdepth) ]
|
||||
++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ]
|
||||
++ lib.optional (m.extra != "") m.extra
|
||||
);
|
||||
|
||||
# A profile's workspace pinning ({ "1" = "DP-3"; }) -> a Hyprland
|
||||
# `workspace` rule. Only the active profile's pins are rendered
|
||||
# (hyprland.nix); a workspace rule naming an absent output is inert.
|
||||
workspaceRule = ws: out: "${ws}, monitor:${out}";
|
||||
|
||||
# The full overlay, three layers:
|
||||
#
|
||||
# 1. base (nomarchy.monitors) with the ACTIVE display profile's entries
|
||||
# (settings.displayProfile naming a nomarchy.displayProfiles key)
|
||||
# replacing base entries WHOLE, by name — a profile entry is a
|
||||
# complete statement about that output. Outputs a profile doesn't
|
||||
# name keep their base rules; a cleared/unknown/non-string value
|
||||
# ("" after `nomarchy-display-profile base`, or hand-edited junk —
|
||||
# the validator only warns) means base config only.
|
||||
#
|
||||
# 2. Menu-remembered per-output resolutions (settings.monitors:
|
||||
# output-name -> "WxH@R", written instantly by the Display menu)
|
||||
# overlaid field-level by name: a declared output keeps its
|
||||
# position/scale/etc and only its resolution changes; an output
|
||||
# covered solely by the `,preferred,auto,1` wildcard (e.g. the
|
||||
# laptop's built-in panel) becomes a new rule with default
|
||||
# position/scale. The menu pick wins over a hand-set resolution
|
||||
# (it's the explicit live action) — EXCEPT onto disabled entries: a
|
||||
# stale pick, made while the output was enabled, must not resurrect
|
||||
# a panel the active profile (or the base config) disables.
|
||||
#
|
||||
# 3. Anything still uncovered falls to the wildcard (hyprland.nix
|
||||
# prepends it).
|
||||
resolve = { base, profiles, active, resOverrides }:
|
||||
let
|
||||
activeName = if builtins.isString active then active else "";
|
||||
profileEntries = profiles.${activeName} or [ ];
|
||||
profileNames = map (m: m.name) profileEntries;
|
||||
baseMonitors =
|
||||
lib.filter (m: ! lib.elem m.name profileNames) base
|
||||
++ profileEntries;
|
||||
declaredNames = map (m: m.name) baseMonitors;
|
||||
in
|
||||
map (m: if resOverrides ? ${m.name} && m.resolution != "disable"
|
||||
then m // { resolution = resOverrides.${m.name}; }
|
||||
else m)
|
||||
baseMonitors
|
||||
++ lib.mapAttrsToList
|
||||
(name: res: defaults // { inherit name; resolution = res; })
|
||||
(lib.filterAttrs (name: _: ! lib.elem name declaredNames) resOverrides);
|
||||
}
|
||||
@@ -1,18 +1,97 @@
|
||||
# Night light — a scheduled blue-light filter via hyprsunset (Hyprland's own
|
||||
# gamma/temperature tool). Warm at night, identity (no shift) by day;
|
||||
# hyprsunset's time-based `profile` entries handle the schedule and pick the
|
||||
# right state on session start. Opt-in via nomarchy.nightlight.enable.
|
||||
# gamma/temperature tool). Warm at night, identity (no shift) by day; the
|
||||
# schedule (temperature/sunrise/sunset) is tuned via nomarchy.nightlight.* in
|
||||
# home.nix and baked into the unit's time-based `profile`.
|
||||
#
|
||||
# The hyprsunset HM service module is provided by home-manager; this only
|
||||
# configures it. Override anything with plain services.hyprsunset.* options.
|
||||
{ config, lib, ... }:
|
||||
# Geo mode: when BOTH nomarchy.nightlight.latitude and .longitude are set,
|
||||
# wlsunset replaces hyprsunset — it computes sunrise/sunset from the
|
||||
# location itself (the fixed .sunrise/.sunset times are ignored). Same
|
||||
# toggle script, same Waybar moon, same live-state ExecCondition gate;
|
||||
# only the unit underneath changes.
|
||||
#
|
||||
# Off by default and opt-in. Two git-tracked flags in the state file, both
|
||||
# menu-written (no ~/.local/state):
|
||||
# settings.nightlight.installed — does the hyprsunset unit exist? Sticky; the
|
||||
# option mkDefault-reads it, so the FIRST enable from the menu rebuilds (to
|
||||
# create the unit) and an instant-off is never undone by a later rebuild.
|
||||
# settings.nightlight.on — runtime on/off. Toggled INSTANTLY (write + systemctl,
|
||||
# no rebuild); read by the unit's ExecCondition (should-start) at session
|
||||
# start so the choice survives logout/reboot via the *live* state, not the
|
||||
# eval-frozen store copy. A later rebuild bakes the same value (no divergence).
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy.nightlight;
|
||||
s = config.nomarchy.settings.nightlight;
|
||||
sync = lib.getExe config.nomarchy.package;
|
||||
# Geo mode flips the backing unit; everything user-facing stays the same.
|
||||
geo = cfg.latitude != null && cfg.longitude != null;
|
||||
unit = if geo then "wlsunset.service" else "hyprsunset.service";
|
||||
# Runtime-on default for when the `on` key hasn't been written yet (e.g. right
|
||||
# after the first enable). Baked at eval; only used when the live key is absent.
|
||||
onDefault = lib.boolToString s.on;
|
||||
|
||||
nomarchy-nightlight = pkgs.writeShellScriptBin "nomarchy-nightlight" ''
|
||||
unit=${unit}
|
||||
# Instant runtime on/off: write the in-flake state WITHOUT a rebuild.
|
||||
write_on() { ${sync} --quiet set settings.nightlight.on "$1" --no-switch; }
|
||||
# First enable: mark the feature installed and REBUILD to create the unit
|
||||
# (the one rebuild we accept; every toggle after is instant).
|
||||
install_feature() { ${sync} --quiet set settings.nightlight.installed true; }
|
||||
# Read the LIVE working-tree on/off (~/.nomarchy via $NOMARCHY_PATH), not the
|
||||
# store copy baked into this generation; fall back to the eval-time default
|
||||
# when absent. Normalise Python's True/False bool rendering.
|
||||
is_on() {
|
||||
v=$(${sync} get settings.nightlight.on 2>/dev/null) || v=${onDefault}
|
||||
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
# LoadState=loaded means a real unit (not the empty-file mask HM/packages
|
||||
# leave behind for unused hyprsunset). `systemctl cat` alone succeeds on
|
||||
# a masked unit and wrongly reported "installed" → silent no-op start.
|
||||
installed() {
|
||||
[ "$(systemctl --user show -p LoadState --value "$unit" 2>/dev/null)" = loaded ]
|
||||
}
|
||||
active() { systemctl --user is-active --quiet "$unit" 2>/dev/null; }
|
||||
start() {
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
systemctl --user unmask "$unit" 2>/dev/null || true
|
||||
systemctl --user start "$unit" 2>/dev/null || true
|
||||
}
|
||||
stop() { systemctl --user stop "$unit" 2>/dev/null || true; }
|
||||
case "''${1:-toggle}" in
|
||||
should-start) is_on ;; # ExecCondition gate (login/reboot)
|
||||
status)
|
||||
# Waybar (polls every 3s): moon while running; print nothing otherwise so
|
||||
# the module self-hides — enable / re-enable from Look & Feel.
|
||||
# `active` alone is the truth for the moon; menu labels use the same.
|
||||
active \
|
||||
&& printf '{"text":"","tooltip":"Night light on — ${if geo then "follows your location" else "warm on schedule"} (click to disable)","class":"on"}\n'
|
||||
exit 0 ;;
|
||||
# Plain on/off string for menus (hyprsunset OR wlsunset; not hard-coded).
|
||||
is-active) if active; then echo on; else echo off; fi ;;
|
||||
on)
|
||||
if installed; then write_on true; start; else install_feature; start; fi ;;
|
||||
off) write_on false; stop ;;
|
||||
toggle)
|
||||
if active; then
|
||||
write_on false; stop # on -> off (instant)
|
||||
elif installed; then
|
||||
write_on true; start # installed, off -> on (instant)
|
||||
else
|
||||
install_feature; start # first enable (rebuilds)
|
||||
fi ;;
|
||||
*) echo "usage: nomarchy-nightlight [toggle|status|on|off|should-start|is-active]" >&2; exit 64 ;;
|
||||
esac
|
||||
'';
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.hyprsunset = {
|
||||
config = {
|
||||
# Unit presence tracks the sticky `installed` flag the menu writes (first
|
||||
# enable rebuilds). mkDefault so a hand-set nomarchy.nightlight.enable in
|
||||
# home.nix also works as a declarative opt-in.
|
||||
nomarchy.nightlight.enable = lib.mkDefault s.installed;
|
||||
|
||||
services.hyprsunset = lib.mkIf (cfg.enable && !geo) {
|
||||
enable = true;
|
||||
settings.profile = [
|
||||
# Daytime: identity = no colour change.
|
||||
@@ -21,5 +100,22 @@ in
|
||||
{ time = cfg.sunset; temperature = cfg.temperature; }
|
||||
];
|
||||
};
|
||||
|
||||
# Geo mode: wlsunset owns the schedule — it recomputes sunrise/sunset
|
||||
# from the coordinates daily (no fixed profile to bake).
|
||||
services.wlsunset = lib.mkIf (cfg.enable && geo) {
|
||||
enable = true;
|
||||
latitude = cfg.latitude;
|
||||
longitude = cfg.longitude;
|
||||
temperature.night = cfg.temperature;
|
||||
};
|
||||
|
||||
# Gate the unit on the LIVE on/off state at start time (login/reboot), not
|
||||
# at eval time — so a menu toggle (written without a rebuild) is honoured on
|
||||
# the next session. A failed condition skips the unit (inactive, not failed).
|
||||
systemd.user.services.${lib.removeSuffix ".service" unit}.Service.ExecCondition =
|
||||
lib.mkIf cfg.enable "${nomarchy-nightlight}/bin/nomarchy-nightlight should-start";
|
||||
|
||||
home.packages = [ nomarchy-nightlight ];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# User-level `nomarchy.*` options — the full surface downstream users
|
||||
# configure in their home.nix. Kept small on purpose.
|
||||
{ lib, pkgs, ... }:
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
# One output's layout — turned into a Hyprland `monitor` rule in
|
||||
@@ -55,17 +55,44 @@ let
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# One display profile: a monitor layout plus optional workspace→output
|
||||
# pinning. A bare list of monitor entries still works (the original
|
||||
# shape) — hyprland.nix normalizes it to { monitors = […]; }.
|
||||
# (either, not coercedTo: coercedTo refuses list-of-submodule sources.)
|
||||
displayProfileType = lib.types.either
|
||||
(lib.types.listOf monitorType)
|
||||
(lib.types.submodule {
|
||||
options = {
|
||||
monitors = lib.mkOption {
|
||||
type = lib.types.listOf monitorType;
|
||||
default = [ ];
|
||||
description = "nomarchy.monitors-style entries; each replaces the base entry for the same output whole.";
|
||||
};
|
||||
workspaces = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
example = { "1" = "DP-3"; "9" = "eDP-1"; };
|
||||
description = ''
|
||||
Workspace → output pinning while this profile is active
|
||||
(Hyprland `workspace = <ws>, monitor:<output>` rules).
|
||||
Applied instantly on profile switch (existing workspaces are
|
||||
moved over) and baked at the next rebuild.
|
||||
'';
|
||||
};
|
||||
};
|
||||
});
|
||||
in
|
||||
{
|
||||
options.nomarchy = {
|
||||
# ── Required ───────────────────────────────────────────────────
|
||||
stateFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
example = lib.literalExpression "./theme-state.json";
|
||||
example = lib.literalExpression "./state.json";
|
||||
description = ''
|
||||
Path to theme-state.json, the single source of truth for all UI
|
||||
Path to state.json, the single source of truth for all UI
|
||||
configuration. Must live inside your flake (so evaluation stays
|
||||
pure) and be git-tracked. nomarchy-theme-sync writes to the
|
||||
pure) and be git-tracked. nomarchy-state-sync writes to the
|
||||
on-disk copy; rebuilds bake it into the generation.
|
||||
'';
|
||||
};
|
||||
@@ -73,13 +100,21 @@ in
|
||||
# ── Preferences ────────────────────────────────────────────────
|
||||
terminal = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "ghostty";
|
||||
description = "Terminal emulator command, used by keybinds and $TERMINAL.";
|
||||
default = config.nomarchy.settings.terminal or "kitty";
|
||||
example = "kitty";
|
||||
description = ''
|
||||
Terminal emulator command for keybinds and `$TERMINAL`. Nomarchy
|
||||
ships and themes **Kitty only** (sole supported terminal — works
|
||||
on older GPUs that Ghostty's OpenGL 4.3 floor rejected). Override
|
||||
only if you install another emulator yourself.
|
||||
'';
|
||||
};
|
||||
|
||||
kitty.enable = lib.mkEnableOption "Nomarchy's Kitty configuration (palette/font/opacity from theme-state)" // { default = true; };
|
||||
|
||||
keyboard.layout = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "us";
|
||||
default = config.nomarchy.settings.keyboard.layout or "us";
|
||||
example = "de";
|
||||
description = ''
|
||||
XKB layout for the Hyprland session. The console (and the LUKS
|
||||
@@ -101,14 +136,18 @@ in
|
||||
default = [ ];
|
||||
example = [ "de" "fr" ];
|
||||
description = ''
|
||||
Extra candidate layouts offered by the interactive new-keyboard
|
||||
picker. When non-empty, a small watcher runs in the session: when a
|
||||
keyboard connects that isn't covered by nomarchy.keyboard.devices and
|
||||
hasn't been chosen before, it pops a rofi picker (these layouts plus
|
||||
the primary nomarchy.keyboard.layout), applies the choice, and
|
||||
remembers it per-device (~/.local/state) — re-applying automatically
|
||||
on later reconnects. The runtime-remember complement to the
|
||||
declarative keyboard.devices; a stateful runtime piece by design.
|
||||
Extra candidate layouts shown first by the interactive new-keyboard
|
||||
picker (all installed XKB layouts remain searchable). A small watcher
|
||||
always runs in the Hyprland session: when a keyboard connects that
|
||||
isn't covered by nomarchy.keyboard.devices and hasn't been chosen
|
||||
before, it pops a rofi picker, applies the choice to that keyboard only
|
||||
(a per-device kb_layout, so the built-in board is left alone), and
|
||||
remembers it in the git-tracked in-flake state
|
||||
(settings.keyboard.devices, not ~/.local/state) — re-applied
|
||||
automatically on later reconnects and across reboots. Each remembered
|
||||
choice graduates into nomarchy.keyboard.devices on the next rebuild
|
||||
(a generated device block). The runtime-remember complement to the
|
||||
declarative keyboard.devices.
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -135,15 +174,17 @@ in
|
||||
nomarchy.keyboard.layout for that keyboard only — e.g. an external
|
||||
keyboard that's physically a different layout than the laptop's
|
||||
built-in one. Hyprland applies it automatically whenever that
|
||||
keyboard is connected.
|
||||
keyboard is connected. The interactive watcher
|
||||
(nomarchy.keyboard.layouts) also writes its remembered picks here on
|
||||
the next rebuild, so a runtime choice graduates into reproducible config.
|
||||
'';
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.nomarchy-theme-sync;
|
||||
defaultText = lib.literalExpression "pkgs.nomarchy-theme-sync";
|
||||
description = "The nomarchy-theme-sync package (provided by overlays.default).";
|
||||
default = pkgs.nomarchy-state-sync;
|
||||
defaultText = lib.literalExpression "pkgs.nomarchy-state-sync";
|
||||
description = "The nomarchy-state-sync package (provided by overlays.default).";
|
||||
};
|
||||
|
||||
themesDir = lib.mkOption {
|
||||
@@ -159,8 +200,9 @@ in
|
||||
|
||||
nightlight = {
|
||||
enable = lib.mkEnableOption ''
|
||||
a scheduled blue-light filter (hyprsunset): warm at night, no shift
|
||||
by day. Opt-in; tune the temperature + sunrise/sunset below'';
|
||||
a scheduled blue-light filter (hyprsunset; wlsunset in geo mode):
|
||||
warm at night, no shift by day. Opt-in; tune the temperature +
|
||||
sunrise/sunset (or latitude/longitude) below'';
|
||||
|
||||
temperature = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
@@ -173,14 +215,57 @@ in
|
||||
type = lib.types.str;
|
||||
default = "07:00";
|
||||
example = "06:30";
|
||||
description = "Time (HH:MM) the filter turns OFF — daytime, no colour shift.";
|
||||
description = "Time (HH:MM) the filter turns OFF — daytime, no colour shift. Ignored in geo mode.";
|
||||
};
|
||||
|
||||
sunset = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "20:00";
|
||||
example = "21:00";
|
||||
description = "Time (HH:MM) the filter turns ON — warm.";
|
||||
description = "Time (HH:MM) the filter turns ON — warm. Ignored in geo mode.";
|
||||
};
|
||||
|
||||
latitude = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "52.52";
|
||||
description = ''
|
||||
Geo mode: set BOTH latitude and longitude and sunrise/sunset are
|
||||
computed from your location every day (wlsunset replaces
|
||||
hyprsunset; the fixed .sunrise/.sunset times are ignored).'';
|
||||
};
|
||||
|
||||
longitude = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "13.40";
|
||||
description = "Geo mode longitude — set together with latitude.";
|
||||
};
|
||||
};
|
||||
|
||||
updates = {
|
||||
enable = lib.mkEnableOption ''
|
||||
passive update awareness: a background check (systemd user timer) that
|
||||
compares the flake's locked inputs (nixpkgs, the Nomarchy input, …)
|
||||
against upstream and — when Flatpak is enabled — counts Flatpak
|
||||
updates, surfacing a Waybar indicator + a notification when something
|
||||
is available. It never changes anything; you still run nomarchy-pull /
|
||||
nomarchy-rebuild / nomarchy-home / flatpak update yourself'' // { default = config.nomarchy.settings.updates.enable or false; };
|
||||
|
||||
interval = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "daily";
|
||||
example = "6h";
|
||||
description = "How often to check, as a systemd OnCalendar expression.";
|
||||
};
|
||||
|
||||
flatpak = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Also count available Flatpak updates when the `flatpak` CLI is
|
||||
present (i.e. nomarchy.services.flatpak is on). No effect otherwise.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
@@ -205,21 +290,124 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
launchOrFocus = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.submodule {
|
||||
options = {
|
||||
key = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "B";
|
||||
description = "The key (with `mods`) that focuses-or-launches the app.";
|
||||
};
|
||||
mods = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "$mod";
|
||||
description = "Modifier string, Hyprland syntax (\"$mod\", \"$mod SHIFT\").";
|
||||
};
|
||||
class = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "firefox";
|
||||
description = "Window class to focus (case-insensitive; see `hyprctl clients`).";
|
||||
};
|
||||
command = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Command to launch when no window matches (defaults to the class).";
|
||||
};
|
||||
desc = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Cheatsheet label (defaults to \"Focus or launch <command>\").";
|
||||
};
|
||||
};
|
||||
});
|
||||
default = [ ];
|
||||
example = lib.literalExpression ''
|
||||
[
|
||||
{ key = "B"; class = "firefox"; }
|
||||
{ key = "O"; class = "obsidian"; }
|
||||
]
|
||||
'';
|
||||
description = ''
|
||||
Launch-or-focus binds: the key focuses the app's existing window
|
||||
(case-insensitive class match) or launches it if none is open. Each
|
||||
entry generates a Hyprland bind AND a SUPER+? cheatsheet row. The
|
||||
launcher self-gates: a bind whose command isn't installed notifies
|
||||
instead of failing silently.
|
||||
'';
|
||||
};
|
||||
|
||||
displayProfiles = lib.mkOption {
|
||||
type = lib.types.attrsOf displayProfileType;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
docked = {
|
||||
monitors = [
|
||||
{ name = "eDP-1"; resolution = "disable"; }
|
||||
{ name = "DP-3"; position = "0x0"; }
|
||||
{ name = "DP-4"; position = "auto-right"; }
|
||||
];
|
||||
workspaces = { "1" = "DP-3"; "9" = "DP-4"; };
|
||||
};
|
||||
undocked = [ { name = "eDP-1"; position = "0x0"; } ];
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Named display layouts for the same outputs (docked, undocked, …):
|
||||
a list of nomarchy.monitors-style entries, or an attrset with
|
||||
`monitors` plus optional `workspaces` (workspace → output pinning
|
||||
while the profile is active). Switch from the menu (System ›
|
||||
Display › Profiles) or `nomarchy-display-profile apply <name>`:
|
||||
the profile's rules apply instantly via hyprctl and the choice
|
||||
persists in the in-flake state (settings.displayProfile), so the
|
||||
next rebuild bakes the active profile's entries over
|
||||
nomarchy.monitors by name. Outputs a profile doesn't name keep
|
||||
their base rules.
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Component toggles ──────────────────────────────────────────
|
||||
hyprland.enable = lib.mkEnableOption "Nomarchy's Hyprland configuration" // { default = true; };
|
||||
waybar.enable = lib.mkEnableOption "Nomarchy's Waybar configuration" // { default = true; };
|
||||
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
|
||||
swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; };
|
||||
batteryNotify.enable = lib.mkEnableOption "low-battery notifications at the bar's thresholds (25% low, 10% critical — that one stays up until dismissed); self-gating, a silent no-op on machines without a battery" // { default = true; };
|
||||
dockAudio.enable = lib.mkEnableOption "settled PipeWire/WirePlumber reprobe and automatic default-output switch to an available dock/monitor sink (HDMI/DisplayPort/USB) on fresh display hotplug, with a toast and journal result; a later manual choice sticks until the next plug" // { default = true; };
|
||||
firstBootWelcome.enable = lib.mkEnableOption "one dismissible \"you're set\" toast on the first session (menu/themes/keys + network pointer); marker is settings.firstBootShown in the flake checkout" // { default = true; };
|
||||
idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; };
|
||||
idle.fingerprint = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = config.nomarchy.settings.fingerprint.pam or false;
|
||||
defaultText = lib.literalExpression
|
||||
"(settings.fingerprint.pam from state.json) or false";
|
||||
description = ''
|
||||
Unlock the lock screen with a fingerprint as well as the password, and
|
||||
say so on the input field.
|
||||
|
||||
Reads the SAME `settings.fingerprint.pam` state key that
|
||||
`nomarchy.hardware.fingerprint.pam` defaults from, so the one
|
||||
System › Fingerprint toggle moves the lock screen and login/sudo
|
||||
together — "fingerprint on" is one decision, not two that can drift.
|
||||
(It has to arrive by state, not by reading the NixOS side: hyprlock is
|
||||
configured here, in standalone Home Manager, which has no `osConfig`.)
|
||||
|
||||
The two remain separate *mechanisms*, which is why this option still
|
||||
exists to be set by hand: hyprlock does NOT unlock by fingerprint
|
||||
through PAM. Its PAM stack only runs on submit, so a parallel module
|
||||
never gets to poll — hyprlock has its own fprintd-over-D-Bus backend
|
||||
instead, and this is the switch for it.
|
||||
'';
|
||||
};
|
||||
yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; };
|
||||
osd.enable = lib.mkEnableOption "swayosd on-screen display for volume/brightness/mute" // { default = true; };
|
||||
shell.enable = lib.mkEnableOption "the zsh shell experience (starship prompt, bat/eza/zoxide)" // { default = true; };
|
||||
keys.enable = lib.mkEnableOption "the SSH + GPG agent (gpg-agent fronting SSH, pinentry-qt)" // { default = true; };
|
||||
fastfetch.enable = lib.mkEnableOption "fastfetch system info fronted by the themed Nomarchy logo" // { default = true; };
|
||||
ghostty.enable = lib.mkEnableOption "Nomarchy's Ghostty configuration" // { default = true; };
|
||||
btop.enable = lib.mkEnableOption "btop with the per-theme nomarchy theme" // { default = true; };
|
||||
stylix.enable = lib.mkEnableOption "Stylix theming for the long tail of apps (GTK, Qt, cursors)" // { default = true; };
|
||||
displays.enable = lib.mkEnableOption "the nwg-displays interactive monitor arranger (a helper to find nomarchy.monitors values; the declarative config stays the source of truth)" // { default = true; };
|
||||
viewers.enable = lib.mkEnableOption "the document/image viewers (zathura, Stylix-themed, + imv)" // { default = true; };
|
||||
mime.enable = lib.mkEnableOption "default file associations (xdg mimeapps.list: PDF/image/video/text/browser/directory); entries for absent apps are skipped, so it degrades with the package suite" // { default = true; };
|
||||
|
||||
# ── Computed (read-only) ───────────────────────────────────────
|
||||
theme = lib.mkOption {
|
||||
@@ -228,6 +416,17 @@ in
|
||||
description = "The parsed theme state (stateFile merged over defaults).";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
readOnly = true;
|
||||
description = ''
|
||||
Parsed feature settings — the `settings` section of the state file,
|
||||
what the menu/Waybar toggles write (e.g. settings.nightlight.enable).
|
||||
Feature options mkDefault-read from here, so a menu toggle lands in the
|
||||
in-flake state (git-tracked, reproducible) rather than ~/.local/state.
|
||||
'';
|
||||
};
|
||||
|
||||
lib = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
readOnly = true;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# lock). The media keys drive it via swayosd-client (it performs the
|
||||
# action AND shows the OSD), so changing volume or brightness gives the
|
||||
# visual feedback that bare wpctl/brightnessctl didn't. Themed from
|
||||
# theme-state.json. Brightness needs the backlight udev rule shipped
|
||||
# state.json. Brightness needs the backlight udev rule shipped
|
||||
# system-side (modules/nixos/default.nix → services.udev.packages).
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
|
||||
99
modules/home/recording.nix
Normal file
99
modules/home/recording.nix
Normal file
@@ -0,0 +1,99 @@
|
||||
# Screen recording (menu › Tools › Capture). One helper owns the whole
|
||||
# lifecycle: `start` launches wl-screenrec (VAAPI hardware encode) and
|
||||
# falls back to wf-recorder (software x264) if it dies on the spot —
|
||||
# e.g. no usable VAAPI device; `status` feeds the self-gating Waybar
|
||||
# indicator (waybar.nix, signal 8), whose click is the ONE stop surface
|
||||
# (`stop` SIGINTs the recorder so it finalizes the file cleanly). State
|
||||
# is a runtime pidfile — nothing persists across sessions.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
lib.mkIf config.nomarchy.rofi.enable {
|
||||
home.packages = [
|
||||
pkgs.wl-screenrec
|
||||
pkgs.wf-recorder
|
||||
|
||||
(pkgs.writeShellScriptBin "nomarchy-record" ''
|
||||
run="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
pidfile="$run/nomarchy-record.pid"
|
||||
filefile="$run/nomarchy-record.file"
|
||||
|
||||
alive() { [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; }
|
||||
poke_bar() { pkill -RTMIN+8 waybar 2>/dev/null || true; }
|
||||
|
||||
case "''${1:-}" in
|
||||
start)
|
||||
if alive; then
|
||||
notify-send "Screen recording" "Already recording — stop it via the ⏺ in the bar."
|
||||
exit 1
|
||||
fi
|
||||
target="''${2:-screen}"; audio="''${3:-}"
|
||||
geo=""
|
||||
if [ "$target" = region ]; then
|
||||
geo=$(slurp) || exit 0 # Esc in slurp = cancel, silently
|
||||
fi
|
||||
dir="$HOME/Videos/Recordings"
|
||||
mkdir -p "$dir"
|
||||
file="$dir/$(date +%Y%m%d-%H%M%S).mp4"
|
||||
|
||||
launch() { # launch <recorder> -> 0 if it survived startup
|
||||
case "$1" in
|
||||
wl-screenrec)
|
||||
setsid wl-screenrec ''${geo:+-g "$geo"} \
|
||||
''${audio:+--audio} -f "$file" >/dev/null 2>&1 & ;;
|
||||
wf-recorder)
|
||||
setsid wf-recorder ''${geo:+-g "$geo"} \
|
||||
''${audio:+-a} -f "$file" >/dev/null 2>&1 & ;;
|
||||
esac
|
||||
echo $! > "$pidfile"
|
||||
sleep 0.7
|
||||
alive
|
||||
}
|
||||
|
||||
started=""
|
||||
if command -v wl-screenrec >/dev/null 2>&1 && launch wl-screenrec; then
|
||||
started=wl-screenrec
|
||||
elif command -v wf-recorder >/dev/null 2>&1 && launch wf-recorder; then
|
||||
started=wf-recorder # software fallback (no VAAPI device)
|
||||
fi
|
||||
if [ -z "$started" ]; then
|
||||
rm -f "$pidfile"
|
||||
notify-send "Screen recording" "Recorder failed to start (no wl-screenrec/wf-recorder able to run)."
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$file" > "$filefile"
|
||||
poke_bar
|
||||
notify-send "Screen recording" "Recording ($started)''${audio:+ with audio} — click the ⏺ in the bar to stop." ;;
|
||||
|
||||
stop)
|
||||
if ! alive; then
|
||||
rm -f "$pidfile" "$filefile"; poke_bar
|
||||
notify-send "Screen recording" "No recording is running."
|
||||
exit 0
|
||||
fi
|
||||
pid=$(cat "$pidfile")
|
||||
kill -INT "$pid" 2>/dev/null # graceful: both recorders finalize on INT
|
||||
for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
|
||||
kill -0 "$pid" 2>/dev/null && kill "$pid" 2>/dev/null
|
||||
file=$(cat "$filefile" 2>/dev/null || echo "?")
|
||||
rm -f "$pidfile" "$filefile"
|
||||
poke_bar
|
||||
notify-send "Screen recording saved" "$file" ;;
|
||||
|
||||
active)
|
||||
alive ;;
|
||||
|
||||
status)
|
||||
# Waybar JSON while recording; nothing => module hidden.
|
||||
if alive; then
|
||||
file=$(cat "$filefile" 2>/dev/null || echo "")
|
||||
printf '{"text":"⏺ REC","class":"recording","tooltip":"Recording to %s — click to stop"}\n' "''${file##*/}"
|
||||
fi
|
||||
exit 0 ;;
|
||||
|
||||
*)
|
||||
echo "usage: nomarchy-record start [region|screen] [audio] | stop | active | status" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
'')
|
||||
];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
31
modules/home/satty.nix
Normal file
31
modules/home/satty.nix
Normal file
@@ -0,0 +1,31 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy;
|
||||
t = cfg.theme;
|
||||
c = t.colors;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (pkgs.lib.meta.availableOn pkgs.stdenv.hostPlatform pkgs.satty) {
|
||||
home.packages = [ pkgs.satty ];
|
||||
|
||||
xdg.configFile."satty/config.toml".text = ''
|
||||
[general]
|
||||
# Set the default tool to pointer or arrow?
|
||||
initial-tool = "arrow"
|
||||
copy-command = "wl-copy"
|
||||
# Hitting copy also saves? Let the user hit save if they want to save.
|
||||
# save-after-copy = false
|
||||
|
||||
[color-palette]
|
||||
palette = [
|
||||
"${c.accent}ff",
|
||||
"${c.bad}ff",
|
||||
"${c.warn}ff",
|
||||
"${c.good}ff",
|
||||
"${c.text}ff",
|
||||
"${c.base}ff"
|
||||
]
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
# Shell experience — zsh (the default login shell, set system-side) with
|
||||
# autosuggestions + syntax highlighting, a themed starship prompt, and
|
||||
# modern CLI tools wired in: bat (cat), eza (ls), zoxide (cd). bat uses
|
||||
# the "ansi" theme so it follows the terminal palette (Ghostty is themed
|
||||
# the "ansi" theme so it follows the terminal palette (Kitty is themed
|
||||
# from the same JSON); starship is themed from the palette directly.
|
||||
#
|
||||
# home.shell.enableZshIntegration = true is the single lever that makes
|
||||
# every integrating program (starship, eza, zoxide, ghostty, yazi) emit
|
||||
# every integrating program (starship, eza, zoxide, kitty, yazi) emit
|
||||
# its zsh hooks — no per-module flags needed.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
@@ -69,11 +69,11 @@ in
|
||||
gpl = "git pull";
|
||||
gf = "git fetch --all --prune";
|
||||
|
||||
# Nix — the flake/store verbs (system/home rebuilds already have the
|
||||
# sys-update / home-update commands, so they're not re-aliased here).
|
||||
# Nix — store/flake verbs. Lifecycle is nomarchy-pull /
|
||||
# nomarchy-rebuild / nomarchy-home (full names on PATH, not aliases).
|
||||
ns = "nix shell"; # ns nixpkgs#ripgrep
|
||||
nr = "nix run"; # nr nixpkgs#cowsay
|
||||
nfu = "nix flake update";
|
||||
nfu = "nix flake update"; # prefer nomarchy-pull day-to-day
|
||||
nfc = "nix flake check";
|
||||
nsearch = "nix search nixpkgs";
|
||||
ngc = "nix-collect-garbage -d"; # user generations; sudo for system roots
|
||||
@@ -133,7 +133,7 @@ in
|
||||
};
|
||||
|
||||
# bat: "ansi" follows the terminal's 16-color palette → tracks the
|
||||
# active Nomarchy theme (Ghostty bakes it) with no per-theme config.
|
||||
# active Nomarchy theme (Kitty bakes it) with no per-theme config.
|
||||
programs.bat = {
|
||||
enable = true;
|
||||
config = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Stylix — themes the long tail of applications (GTK, Qt, cursors,
|
||||
# fonts) from the same theme-state.json that drives the live engine.
|
||||
# fonts) from the same state.json that drives the live engine.
|
||||
#
|
||||
# Division of labour: the hot-reload trio (Hyprland, Waybar, Ghostty)
|
||||
# Division of labour: the hot-reload trio (Hyprland, Waybar, Kitty)
|
||||
# is owned by the Nomarchy engine and updates instantly; everything
|
||||
# Stylix touches updates on the next home-manager switch. That is why
|
||||
# autoEnable is off and the trio's Stylix targets stay disabled.
|
||||
@@ -16,6 +16,31 @@ let
|
||||
c = t.colors;
|
||||
hex = lib.removePrefix "#";
|
||||
|
||||
# Accent-button chips (#130). The label is the palette's base — dark on a
|
||||
# dark theme, cream on a light one — so the chip has to be pulled AWAY from
|
||||
# the label until the label is legible: toward white when the label is dark,
|
||||
# toward black when it is cream. Same factor both ways; only the anchor
|
||||
# flips, so "solid accent button, base label" stays the design and just
|
||||
# shifts tone.
|
||||
#
|
||||
# Why a nudge at all: on a saturated mid-tone accent NOTHING clears 4.5 —
|
||||
# neither the cream base (summer-day: 2.72) nor the dark text (1.65) — which
|
||||
# is why upstream adw-gtk3 ships white-on-accent at ~2.7 and why "darken the
|
||||
# label" was a dead end. The chip must move.
|
||||
#
|
||||
# 0.70 measured across all 24 palettes (tools/check-theme-contrast.py asserts
|
||||
# it): worst dark 4.86 (nord/bad), worst light 5.59 (summer-day). Today's 0.90
|
||||
# fails seven themes — miasma/bad 3.43, rose-pine 2.70 — so this is not only a
|
||||
# light-theme fix, which is what the item assumed.
|
||||
#
|
||||
# GTK's mix(a, b, f) = a + (b - a) * f (gtkcsscolorvalue.c), i.e. f is the
|
||||
# weight of the SECOND colour: 0.70 = 70% accent, 30% anchor. Read it that way
|
||||
# or the ladder below looks inverted.
|
||||
chipAnchor = if t.mode == "light" then "black" else "white";
|
||||
# Each state pulls further from the label, so contrast only ever improves —
|
||||
# rest is the worst case and the one the check pins.
|
||||
chip = role: f: "mix(${chipAnchor}, ${role}, ${f})";
|
||||
|
||||
# Map the Nomarchy palette onto base16 roles.
|
||||
base16 = {
|
||||
base00 = hex c.base; # default background
|
||||
@@ -35,6 +60,7 @@ let
|
||||
base0E = hex c.accentAlt;# magenta
|
||||
base0F = hex c.bad; # brown/deprecated
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.stylix.enable {
|
||||
@@ -45,8 +71,139 @@ in
|
||||
base16Scheme = base16;
|
||||
|
||||
targets = {
|
||||
gtk.enable = true;
|
||||
gtk = {
|
||||
enable = true;
|
||||
# nm-applet / classic GTK menus: Stylix base16 selection can
|
||||
# collapse into surface/muted on dark themes (Boreal: selected
|
||||
# row and submenu arrows were effectively invisible). High-
|
||||
# contrast selected rows + explicit arrow colour. Appended to
|
||||
# Stylix's generated gtk.css (not gtk.gtk3.extraCss — that is a
|
||||
# no-op under Stylix).
|
||||
#
|
||||
# #98: dialog button labels. Stylix always installs theme name
|
||||
# "adw-gtk3" (light sheet) and only recolors @define-color vars.
|
||||
# The light sheet hardcodes dark/black button text in places; on a
|
||||
# dark palette that becomes black-on-dark in GTK dialogs. We force
|
||||
# adw-gtk3-dark for dark mode (below) and still pin button label
|
||||
# colours here so dialogs stay legible if a light sheet slips in.
|
||||
extraCss = ''
|
||||
menu menuitem, .menu menuitem, .context-menu menuitem {
|
||||
color: #${base16.base05};
|
||||
}
|
||||
menu menuitem:hover, menu menuitem:selected,
|
||||
.menu menuitem:hover, .menu menuitem:selected,
|
||||
.context-menu menuitem:hover, .context-menu menuitem:selected,
|
||||
menubar > menuitem:hover {
|
||||
background-color: #${base16.base0D};
|
||||
color: #${base16.base00};
|
||||
}
|
||||
menu menuitem:disabled, .menu menuitem:disabled {
|
||||
color: #${base16.base03};
|
||||
}
|
||||
/* Submenu disclosure arrows need no icon remap: pan-end-symbolic
|
||||
resolves via Papirus-Dark's breeze-dark inheritance. The
|
||||
"invisible arrow" bug was Waybar's process-wide stylesheet:
|
||||
its `* { min-height: 0; }` reset also hits the SNI tray menus
|
||||
Waybar hosts and collapses arrow/check nodes. NB: this file
|
||||
CANNOT fix that — Waybar adds its provider at USER priority
|
||||
too and wins the tie in practice (GTK 3.24, observed), so
|
||||
every waybar.css carries its own menu counter-rules. The
|
||||
rules here cover menus in ordinary GTK apps only. */
|
||||
menu menuitem arrow, .menu menuitem arrow, .context-menu menuitem arrow {
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
}
|
||||
check, radio {
|
||||
min-width: 14px;
|
||||
min-height: 14px;
|
||||
color: inherit;
|
||||
}
|
||||
/* GTK4 / popover menus (some portals). */
|
||||
popover.menu contents modelbutton {
|
||||
color: #${base16.base05};
|
||||
}
|
||||
popover.menu contents modelbutton:hover,
|
||||
popover.menu contents modelbutton:selected {
|
||||
background-color: #${base16.base0D};
|
||||
color: #${base16.base00};
|
||||
}
|
||||
|
||||
/* Dialog / message-box action buttons (#98). */
|
||||
button, button label,
|
||||
.dialog-action-area button,
|
||||
.dialog-action-area button label,
|
||||
messagedialog button,
|
||||
messagedialog button label,
|
||||
.message-dialog button,
|
||||
.message-dialog button label {
|
||||
color: #${base16.base05};
|
||||
}
|
||||
button:disabled, button:disabled label {
|
||||
color: alpha(#${base16.base05}, 0.5);
|
||||
}
|
||||
/* Accent buttons: the palette's base as the label, on a chip
|
||||
pulled away from it (#130 — see `chip` above for the why and
|
||||
the measured numbers).
|
||||
|
||||
BOTH chips are pinned, not just destructive as in #98. Two
|
||||
reasons, and the second is the durable one:
|
||||
1. adw-gtk3 builds the destructive background from
|
||||
currentColor — mix(@destructive_color,
|
||||
alpha(currentColor,…)) — so pinning the label alone drags
|
||||
the background with it and the button renders dark-on-dark
|
||||
(1.03:1, invisible). That is the #98 bug.
|
||||
2. A pinned label on an UNPINNED background is unknowable:
|
||||
no static check can see what it renders on, which is
|
||||
exactly why every check was green while #98 shipped. A
|
||||
pinned pair is arithmetic, and
|
||||
tools/check-theme-contrast.py now asserts it for all 24
|
||||
palettes. The rule for anything added here: pin the pair,
|
||||
or pin neither.
|
||||
|
||||
Enabled only — :disabled keeps the sheet's own chrome and
|
||||
already renders legibly. .default is GTK's dialog default and
|
||||
adw-gtk3 styles it as an accent button, which is why it shares
|
||||
the suggested chip rather than inheriting a plain one. */
|
||||
button.suggested-action, button.suggested-action label,
|
||||
button.destructive-action, button.destructive-action label,
|
||||
button.default, button.default label {
|
||||
color: #${base16.base00};
|
||||
}
|
||||
button.suggested-action:not(.flat):not(:disabled),
|
||||
button.default:not(.flat):not(:disabled) {
|
||||
background-color: ${chip "@accent_bg_color" "0.70"};
|
||||
}
|
||||
button.suggested-action:not(.flat):not(:disabled):hover,
|
||||
button.default:not(.flat):not(:disabled):hover {
|
||||
background-color: ${chip "@accent_bg_color" "0.65"};
|
||||
}
|
||||
button.suggested-action:not(.flat):not(:disabled):active,
|
||||
button.suggested-action:not(.flat):not(:disabled):checked,
|
||||
button.default:not(.flat):not(:disabled):active,
|
||||
button.default:not(.flat):not(:disabled):checked {
|
||||
background-color: ${chip "@accent_bg_color" "0.55"};
|
||||
}
|
||||
button.destructive-action:not(.flat):not(:disabled) {
|
||||
background-color: ${chip "@destructive_bg_color" "0.70"};
|
||||
}
|
||||
button.destructive-action:not(.flat):not(:disabled):hover {
|
||||
background-color: ${chip "@destructive_bg_color" "0.65"};
|
||||
}
|
||||
button.destructive-action:not(.flat):not(:disabled):active,
|
||||
button.destructive-action:not(.flat):not(:disabled):checked {
|
||||
background-color: ${chip "@destructive_bg_color" "0.55"};
|
||||
}
|
||||
button.suggested-action.flat, button.suggested-action.flat label {
|
||||
color: #${base16.base0D};
|
||||
}
|
||||
button.destructive-action.flat, button.destructive-action.flat label {
|
||||
color: #${base16.base08};
|
||||
}
|
||||
'';
|
||||
};
|
||||
qt.enable = true;
|
||||
# No-op unless programs.zathura is on (viewers.nix enables it).
|
||||
zathura.enable = true;
|
||||
};
|
||||
|
||||
cursor = {
|
||||
@@ -58,10 +215,12 @@ in
|
||||
# GTK/file-manager/rofi icon theme, resolved from the JSON in
|
||||
# theme.nix (per-theme `icons`, else Papirus-Dark/Light by mode).
|
||||
# Stylix sets gtk.iconTheme from this; both dark/light point at the
|
||||
# already-mode-resolved name.
|
||||
# already-mode-resolved name. The package is papirus by default and
|
||||
# only unions in another pack when a theme's `icons` names one
|
||||
# (theme.nix — opt-in, no default closure bloat).
|
||||
icons = {
|
||||
enable = true;
|
||||
package = lib.mkDefault pkgs.papirus-icon-theme;
|
||||
package = lib.mkDefault t.iconThemePackage;
|
||||
dark = t.iconTheme;
|
||||
light = t.iconTheme;
|
||||
};
|
||||
@@ -87,13 +246,41 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# GTK3 hides menu-item icons by default (gtk-menu-images was flipped
|
||||
# off upstream in 3.10). Classic tray menus — nm-applet's Wi-Fi list,
|
||||
# udiskie — are built as image menu items, so without this their icons
|
||||
# silently vanish. Stylix drives the HM gtk module, so extraConfig
|
||||
# merges into the same settings.ini it already writes.
|
||||
gtk.gtk3.extraConfig = {
|
||||
"gtk-menu-images" = 1;
|
||||
# Pair with adw-gtk3-dark so the theme sheet itself is dark, not just
|
||||
# the @define-color recolor (see gtk.theme.name below).
|
||||
"gtk-application-prefer-dark-theme" = t.mode != "light";
|
||||
};
|
||||
gtk.gtk4.extraConfig."gtk-application-prefer-dark-theme" = t.mode != "light";
|
||||
|
||||
# #98: Stylix hardcodes gtk.theme.name = "adw-gtk3" (light CSS) for every
|
||||
# polarity and only injects libadwaita colour tokens. The light adw-gtk3
|
||||
# sheet assumes light chrome and hardcodes near-black button labels in
|
||||
# places — on Boreal that is black text on dark dialog buttons. Force
|
||||
# the matching dark/light sheet; the package still provides both names.
|
||||
gtk.theme = {
|
||||
package = lib.mkDefault pkgs.adw-gtk3;
|
||||
name = lib.mkForce (if t.mode == "light" then "adw-gtk3" else "adw-gtk3-dark");
|
||||
};
|
||||
|
||||
# GTK4/libadwaita and Qt6 apps decide dark vs light from the XDG
|
||||
# portal's color-scheme (org.freedesktop.appearance), which
|
||||
# xdg-desktop-portal-gtk sources from this gsettings key. Stylix sets
|
||||
# the GTK theme and `polarity` but not this, so a light theme still
|
||||
# rendered libadwaita/Qt apps dark (and vice-versa). Drive it from the
|
||||
# palette mode. (programs.dconf.enable is already on system-side.)
|
||||
dconf.settings."org/gnome/desktop/interface".color-scheme =
|
||||
dconf.settings."org/gnome/desktop/interface" = {
|
||||
color-scheme =
|
||||
if t.mode == "light" then "prefer-light" else "prefer-dark";
|
||||
gtk-theme =
|
||||
if t.mode == "light" then "adw-gtk3" else "adw-gtk3-dark";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# swaync — notification daemon + control centre, themed from
|
||||
# theme-state.json. Until this shipped, nothing rendered notify-send at
|
||||
# state.json. Until this shipped, nothing rendered notify-send at
|
||||
# all: the theme-switch progress toasts, the CLI's font warnings and the
|
||||
# live ISO's welcome message were all invisible.
|
||||
{ config, lib, ... }:
|
||||
@@ -29,11 +29,14 @@ in
|
||||
};
|
||||
|
||||
style = ''
|
||||
/* Palette baked from theme-state.json */
|
||||
/* Palette baked from state.json. Only roles guaranteed to
|
||||
contrast @base in EVERY palette are used: subtext/surface mean
|
||||
"on-surface" in some light themes (summer-day: subtext==base,
|
||||
surface==text — body text was invisible on hardware, item 25).
|
||||
Chips/hovers are alpha(@text) tints — contrast by construction.
|
||||
tools/check-theme-contrast.py guards the hex-on-hex pairings. */
|
||||
@define-color base ${c.base};
|
||||
@define-color surface ${c.surface};
|
||||
@define-color text ${c.text};
|
||||
@define-color subtext ${c.subtext};
|
||||
@define-color accent ${c.accent};
|
||||
@define-color bad ${c.bad};
|
||||
|
||||
@@ -42,6 +45,8 @@ in
|
||||
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
|
||||
border-radius: ${r}px;
|
||||
color: @text;
|
||||
font-family: "${t.fonts.ui}", "${t.fonts.mono}";
|
||||
font-size: ${toString t.fonts.size}pt;
|
||||
}
|
||||
|
||||
.notification-content .summary {
|
||||
@@ -50,7 +55,7 @@ in
|
||||
}
|
||||
|
||||
.notification-content .body {
|
||||
color: @subtext;
|
||||
color: @text;
|
||||
}
|
||||
|
||||
.notification.critical {
|
||||
@@ -62,21 +67,58 @@ in
|
||||
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
|
||||
border-radius: ${r}px;
|
||||
color: @text;
|
||||
font-family: "${t.fonts.ui}", "${t.fonts.mono}";
|
||||
font-size: ${toString t.fonts.size}pt;
|
||||
}
|
||||
|
||||
.control-center .notification-row:focus,
|
||||
.control-center .notification-row:hover {
|
||||
background: alpha(@surface, 0.6);
|
||||
/* Unscoped on purpose: the floating popup wraps .notification in
|
||||
a .notification-row too, and swaync's DEFAULT stylesheet (still
|
||||
loaded underneath this one) paints that row dark on hover — on
|
||||
light themes the theme's dark @text then sat on the default's
|
||||
dark hover, unreadable. The same alpha(@text) tint as
|
||||
everywhere else overrides it in both surfaces. */
|
||||
.notification-row:focus,
|
||||
.notification-row:hover {
|
||||
background: alpha(@text, 0.1);
|
||||
border-radius: ${r}px;
|
||||
}
|
||||
|
||||
/* The REAL popup-hover culprit: the notification body is one big
|
||||
GTK button (.notification-default-action), and the default
|
||||
sheet hovers IT to rgb(56,56,56) — on light themes that put
|
||||
dark @text on a dark chip (unreadable, hardware report
|
||||
2026-07-18). Re-pin body + action buttons to the palette's
|
||||
tint construction; hover stays a tint of @text, so it works
|
||||
in both modes by construction. */
|
||||
.notification-default-action,
|
||||
.notification-action {
|
||||
background: transparent;
|
||||
color: @text;
|
||||
}
|
||||
.notification-default-action:hover,
|
||||
.notification-action:hover {
|
||||
background: alpha(@text, 0.08);
|
||||
}
|
||||
|
||||
/* Same default-stylesheet leak: swaync ships a dark close-button
|
||||
chip; re-pin both ends to the palette's tint construction. */
|
||||
.close-button {
|
||||
background: alpha(@text, 0.1);
|
||||
color: @text;
|
||||
border-radius: ${r}px;
|
||||
}
|
||||
.close-button:hover {
|
||||
background: alpha(@text, 0.2);
|
||||
color: @text;
|
||||
}
|
||||
|
||||
.widget-title {
|
||||
color: @text;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.widget-title > button {
|
||||
background: @surface;
|
||||
background: alpha(@text, 0.1);
|
||||
color: @text;
|
||||
border: none;
|
||||
border-radius: ${r}px;
|
||||
|
||||
52
modules/home/term-sheet.nix
Normal file
52
modules/home/term-sheet.nix
Normal file
@@ -0,0 +1,52 @@
|
||||
# Shared launcher for the floating terminal sheets (#139). Used by waybar.nix
|
||||
# (nomarchy-calendar) and rofi.nix (the doctor sheet) — the classed Kitty
|
||||
# windows hyprland.nix floats and centers.
|
||||
#
|
||||
# Why the size lives here and not in a window rule: Hyprland 0.55.4 silently
|
||||
# ignores a *percentage* `size` rule. Measured on hardware 2026-07-16, both
|
||||
# rule orders, Kitty's own memory disabled so nothing could mask it:
|
||||
# `size 60% 65%` → the window keeps its requested size (no rule applied);
|
||||
# `size 1536 936` → lands exactly, in either order. No `hyprctl configerrors`
|
||||
# either way, which is how it survived the post-0.53 rule rewrite unnoticed.
|
||||
#
|
||||
# Absolute px is not a fix: "a fraction of the screen" is the whole intent, and
|
||||
# one px pair cannot serve a 2560x1440 desk monitor and the 1366x768 Acer that
|
||||
# is live QA (#131). A rule cannot compute it — the monitor is unknown at
|
||||
# eval time — so the sheet asks for its own size instead: read the focused
|
||||
# monitor here, hand Kitty the px, and let it request them. Those windows
|
||||
# therefore carry float/center rules and deliberately no `size` rule.
|
||||
{ pkgs }:
|
||||
|
||||
pkgs.writeShellScriptBin "nomarchy-term-sheet" ''
|
||||
set -u
|
||||
# usage: nomarchy-term-sheet <class> <width%> <height%> <cmd> [args...]
|
||||
[ "$#" -ge 4 ] || { echo "usage: nomarchy-term-sheet <class> <w%> <h%> <cmd> [args...]" >&2; exit 64; }
|
||||
cls="$1"; wpct="$2"; hpct="$3"; shift 3
|
||||
|
||||
# Hyprland reports the monitor in physical px plus the scale it renders at;
|
||||
# Kitty sizes in logical px, so divide before taking the fraction. A sheet
|
||||
# that opens at a clumsy size still beats no sheet, so every failure here
|
||||
# falls back rather than exits: no compositor (a TTY run), no jq answer, or
|
||||
# a nonsense answer all land on a size that fits the smallest panel we ship
|
||||
# on.
|
||||
dims=$(hyprctl monitors -j 2>/dev/null \
|
||||
| ${pkgs.jq}/bin/jq -r --argjson w "$wpct" --argjson h "$hpct" '
|
||||
[ .[] | select(.focused) ][0]
|
||||
| select(. != null)
|
||||
| (.scale // 1) as $s
|
||||
| select($s > 0)
|
||||
| "\((.width / $s * $w / 100) | floor) \((.height / $s * $h / 100) | floor)"' 2>/dev/null) || dims=
|
||||
width=''${dims%% *}
|
||||
height=''${dims##* }
|
||||
case "$width$height" in
|
||||
*[!0-9]*|"") width=800; height=500 ;;
|
||||
esac
|
||||
[ "$width" -ge 320 ] 2>/dev/null || width=800
|
||||
[ "$height" -ge 240 ] 2>/dev/null || height=500
|
||||
|
||||
exec ${pkgs.kitty}/bin/kitty \
|
||||
-o remember_window_size=no \
|
||||
-o initial_window_width="$width" \
|
||||
-o initial_window_height="$height" \
|
||||
--class="$cls" -e "$@"
|
||||
''
|
||||
@@ -1,31 +1,36 @@
|
||||
# Nomarchy theming engine.
|
||||
#
|
||||
# nomarchy.stateFile (theme-state.json, inside the consuming flake) is
|
||||
# nomarchy.stateFile (state.json, inside the consuming flake) is
|
||||
# ingested at evaluation time — pure, because flake files are store
|
||||
# paths — and exposed to every other module as `config.nomarchy.theme`.
|
||||
#
|
||||
# Theme changes are fully Home Manager managed: `nomarchy-theme-sync
|
||||
# Theme changes are fully Home Manager managed: `nomarchy-state-sync
|
||||
# apply <theme>` writes the JSON and runs `home-manager switch`, baking
|
||||
# everything (Hyprland, Waybar, Ghostty, btop, Stylix) into one
|
||||
# everything (Hyprland, Waybar, Kitty, btop, Stylix) into one
|
||||
# read-only generation. No runtime patching, no partial states; theme
|
||||
# history is generation history.
|
||||
#
|
||||
# The one runtime exception is the wallpaper (swww is imperative by
|
||||
# nature): applied at session start, after every switch (hook below),
|
||||
# and cycled instantly with `nomarchy-theme-sync bg next`.
|
||||
# nature): applied at session start, after every switch (hook below), and
|
||||
# after output hotplug (hyprland.nix); cycled instantly with
|
||||
# `nomarchy-state-sync bg next`.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy;
|
||||
|
||||
themeState = builtins.fromJSON (builtins.readFile cfg.stateFile);
|
||||
# Fail-closed load: missing / empty / non-object get a short pointer at
|
||||
# the template + `nomarchy-state-sync validate`, not a raw readFile stack
|
||||
# from deep inside a consumer (nightlight, hyprland, …). Field-level
|
||||
# checks below still run on the merged result.
|
||||
themeState = import ../state-read.nix { inherit lib; } cfg.stateFile;
|
||||
|
||||
# Defaults guarantee evaluation succeeds on a sparse or older state
|
||||
# file (e.g. one written before a schema field was added). The shipped
|
||||
# Tokyo Night preset provides the color/ansi fallbacks; the path is
|
||||
# Boreal preset provides the color/ansi fallbacks; the path is
|
||||
# relative to this module, so it resolves inside Nomarchy's own flake
|
||||
# source even when consumed downstream.
|
||||
preset = builtins.fromJSON (builtins.readFile ../../themes/tokyo-night.json);
|
||||
preset = builtins.fromJSON (builtins.readFile ../../themes/boreal.json);
|
||||
|
||||
defaults = preset // {
|
||||
fonts = {
|
||||
@@ -54,16 +59,106 @@ let
|
||||
# tone for kanagawa/summer-*). Each preset declares its own so a theme
|
||||
# switch always replaces it (deep_merge would otherwise leave it stuck).
|
||||
border = { active = "accent"; inactive = "overlay"; };
|
||||
|
||||
# Non-appearance feature settings the menu/watchers write into this same
|
||||
# in-flake state. nomarchy.nightlight: `installed` (sticky — gates the unit,
|
||||
# so the first enable rebuilds) and `on` (instant runtime on/off).
|
||||
# settings.keyboard.devices: per-device layouts the new-keyboard watcher
|
||||
# remembers (device-name -> XKB layout), git-tracked instead of
|
||||
# ~/.local/state; they graduate into nomarchy.keyboard.devices on the next
|
||||
# rebuild. settings.monitors: per-output resolutions the Display menu
|
||||
# remembers (output-name -> "WxH@R"), overlaid onto nomarchy.monitors by
|
||||
# name in hyprland.nix — the monitor twin of the keyboard graduation.
|
||||
# settings.displayProfile / displayProfileAuto: active named layout +
|
||||
# auto-switch on plug events (hyprland.nix / Display menu).
|
||||
# settings.firstBootShown: first-session welcome toast gate (#81) —
|
||||
# written true after the toast fires (nomarchy-first-boot).
|
||||
# Defaulted so a sparse/older state file still evaluates; nomarchy.settings
|
||||
# exposes them.
|
||||
settings = {
|
||||
nightlight = { installed = false; on = true; };
|
||||
keyboard.devices = { };
|
||||
monitors = { };
|
||||
# Automatic timezone detection (nomarchy.system.autoTimezone): a system
|
||||
# service, but the flag lives here so both sides read one source — the
|
||||
# home side gates the Waybar-refresh watcher (timezone.nix) on it.
|
||||
autoTimezone = false;
|
||||
displayProfile = "";
|
||||
displayProfileAuto = false;
|
||||
firstBootShown = false;
|
||||
};
|
||||
};
|
||||
|
||||
parsed = lib.recursiveUpdate defaults themeState;
|
||||
|
||||
# ── Friendly eval-time validation ───────────────────────────────────
|
||||
# The same schema nomarchy-state-sync enforces before every write. A
|
||||
# HAND-edited state file (the one path that bypasses the tool) must
|
||||
# fail with the field, the problem, and the fix — not a Nix stack
|
||||
# trace deep in some consumer. Checks run on `parsed` (after the
|
||||
# defaults), so missing fields are fine; only wrong values throw.
|
||||
isHex = v: builtins.isString v && builtins.match "#[0-9a-fA-F]{6}" v != null;
|
||||
isNum = v: builtins.isInt v || builtins.isFloat v;
|
||||
colorRoles = [ "base" "mantle" "surface" "overlay" "text" "subtext"
|
||||
"muted" "accent" "accentAlt" "good" "warn" "bad" ];
|
||||
got = v: "got: ${builtins.toJSON v}";
|
||||
problems = lib.concatLists [
|
||||
(map (k: "colors.${k} must be \"#RRGGBB\" (${got (parsed.colors.${k} or null)})")
|
||||
(builtins.filter (k: !isHex (parsed.colors.${k} or null)) colorRoles))
|
||||
(lib.optional (!(parsed.mode == "dark" || parsed.mode == "light"))
|
||||
"mode must be \"dark\" or \"light\" (${got parsed.mode})")
|
||||
(map (k: "ui.${k} must be a non-negative whole number (${got (parsed.ui.${k} or null)})")
|
||||
(builtins.filter
|
||||
(k: let v = parsed.ui.${k} or null; in !(builtins.isInt v && v >= 0))
|
||||
[ "gapsIn" "gapsOut" "borderSize" "rounding" "iconSize" ]))
|
||||
(map (k: "ui.${k} must be a number between 0 and 1 (${got (parsed.ui.${k} or null)})")
|
||||
(builtins.filter
|
||||
(k: let v = parsed.ui.${k} or null; in !(isNum v && v >= 0 && v <= 1))
|
||||
[ "activeOpacity" "inactiveOpacity" "terminalOpacity" ]))
|
||||
(map (k: "ui.${k} must be true or false (${got (parsed.ui.${k} or null)})")
|
||||
(builtins.filter (k: !builtins.isBool (parsed.ui.${k} or null))
|
||||
[ "blur" "shadow" ]))
|
||||
(map (k: "fonts.${k} must be a font-family string (${got (parsed.fonts.${k} or null)})")
|
||||
(builtins.filter (k: !builtins.isString (parsed.fonts.${k} or null))
|
||||
[ "mono" "ui" ]))
|
||||
(lib.optional (!(isNum (parsed.fonts.size or null) && parsed.fonts.size > 0))
|
||||
"fonts.size must be a positive number (${got (parsed.fonts.size or null)})")
|
||||
(lib.optional (!(builtins.isList parsed.ansi
|
||||
&& builtins.length parsed.ansi == 16
|
||||
&& lib.all isHex parsed.ansi))
|
||||
"ansi must be a list of exactly 16 \"#RRGGBB\" strings")
|
||||
(map (k: "border.${k} must be a palette role or \"#RRGGBB\" (${got (parsed.border.${k} or null)})")
|
||||
(builtins.filter
|
||||
(k: let v = parsed.border.${k} or null;
|
||||
in !(builtins.isString v && (builtins.elem v colorRoles || isHex v)))
|
||||
[ "active" "inactive" ]))
|
||||
];
|
||||
checked =
|
||||
if problems == [ ] then parsed
|
||||
else throw ''
|
||||
|
||||
Nomarchy: your state.json is invalid:
|
||||
${lib.concatMapStrings (p: " ✖ ${p}\n") problems}
|
||||
Fix the named field(s) in the state.json of your flake
|
||||
checkout (usually ~/.nomarchy/state.json — the store copy at
|
||||
${toString cfg.stateFile} is a snapshot of it). The tool prints
|
||||
the same report with per-field fixes: `nomarchy-state-sync
|
||||
validate`. Re-applying any preset resets all appearance fields:
|
||||
`nomarchy-state-sync apply boreal`.'';
|
||||
|
||||
# A border value is a palette key (look it up in colors) unless it's
|
||||
# already a literal hex; unknown keys fall through to the raw string.
|
||||
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v;
|
||||
# already a literal hex. Unknown roles are rejected by the field checks
|
||||
# above; resolve only runs on a validated state.
|
||||
resolveColor = v:
|
||||
if lib.hasPrefix "#" v then v
|
||||
else parsed.colors.${v} or (throw ''
|
||||
|
||||
Nomarchy: border role "${v}" is not in the palette (colors.*).
|
||||
Use one of: ${lib.concatStringsSep ", " colorRoles}
|
||||
or a literal "#RRGGBB". Validate with: nomarchy-state-sync validate'');
|
||||
border = {
|
||||
active = resolveColor parsed.border.active;
|
||||
inactive = resolveColor parsed.border.inactive;
|
||||
active = resolveColor checked.border.active;
|
||||
inactive = resolveColor checked.border.inactive;
|
||||
};
|
||||
|
||||
# Resolve the icon theme once and expose it on nomarchy.theme so both
|
||||
@@ -73,10 +168,111 @@ let
|
||||
if parsed.icons != "" then parsed.icons
|
||||
else if parsed.mode == "light" then "Papirus-Light"
|
||||
else "Papirus-Dark";
|
||||
|
||||
# Icon-pack resolution — opt-in, no default bloat. Papirus is the shipped
|
||||
# default and the ONLY icon pack in the closure unless a theme's `icons`
|
||||
# names a set from another pack; then that pack (and only it) is
|
||||
# union-joined alongside papirus. Because the default themes name
|
||||
# `Papirus-*`, the resolved package below is byte-identical to
|
||||
# papirus-icon-theme (no symlinkJoin, no added MB). To offer another set:
|
||||
# add one row to `iconPacks` and set `icons = "<Name>"` in a theme JSON
|
||||
# (see templates/downstream/home.nix). Referencing `pkgs.*` here is
|
||||
# eval-only — a pack enters the closure solely when it is the matched one.
|
||||
papirusPkg = pkgs.papirus-icon-theme;
|
||||
iconPacks = [
|
||||
# pkg + the theme-name prefixes it provides (packs ship many named
|
||||
# variants under one prefix, e.g. Tela / Tela-dark / Tela-blue).
|
||||
{ pkg = papirusPkg; prefixes = [ "Papirus" "breeze" ]; }
|
||||
{ pkg = pkgs.tela-icon-theme; prefixes = [ "Tela" ]; }
|
||||
{ pkg = pkgs.qogir-icon-theme; prefixes = [ "Qogir" ]; }
|
||||
{ pkg = pkgs.reversal-icon-theme; prefixes = [ "Reversal" ]; }
|
||||
{ pkg = pkgs.numix-icon-theme-circle; prefixes = [ "Numix" ]; }
|
||||
];
|
||||
# The pack whose prefix matches the resolved name (null = unknown name →
|
||||
# GTK falls back gracefully; papirus is still present).
|
||||
matchedPack =
|
||||
let m = lib.findFirst
|
||||
(p: lib.any (pre: lib.hasPrefix pre iconTheme) p.prefixes) null iconPacks;
|
||||
in if m == null then null else m.pkg;
|
||||
iconThemePackage =
|
||||
if matchedPack == null || matchedPack == papirusPkg
|
||||
then papirusPkg # single pack → identical store path, zero closure delta
|
||||
else pkgs.symlinkJoin {
|
||||
name = "nomarchy-icon-themes";
|
||||
paths = [ papirusPkg matchedPack ];
|
||||
};
|
||||
|
||||
# ── Tray-icon overrides (BACKLOG #89) ────────────────────────────────
|
||||
# Some SNI apps publish an *app* IconName that the icon set renders in
|
||||
# full colour (EasyEffects 8 → `com.github.wwmm.easyeffects`, Papirus'
|
||||
# blue equaliser), so the icon clashes with Waybar's monochrome tray.
|
||||
# We wrap the resolved set in a thin child theme that `Inherits` it and
|
||||
# ships palette-coloured monochrome overrides for the offenders, then
|
||||
# make that child the session icon theme: every *other* icon still
|
||||
# resolves from the parent unchanged (verified — udiskie, nm-applet and
|
||||
# file-manager icons all fall through). Theme-following: the fill is the
|
||||
# palette `text` colour — the same hue as the bar's own glyphs — so it
|
||||
# is regenerated on every switch and works under any theme/mode. A
|
||||
# scalable override out-resolves the parent's fixed-size icon at every
|
||||
# requested size. Adding another offender = one more SVG in scalable/apps.
|
||||
overrideIconTheme = "Nomarchy-icons";
|
||||
easyeffectsGlyph = pkgs.writeText "com.github.wwmm.easyeffects.svg" ''
|
||||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 16 16">
|
||||
<g style="fill:${checked.colors.text};fill-opacity:1;stroke:none">
|
||||
<rect x="7.5" y="2" width="1" height="12" ry=".5"/>
|
||||
<rect x="12" y="4" width="1" height="8" ry=".5"/>
|
||||
<rect x="3" y="4" width="1" height="8" ry=".5"/>
|
||||
<circle cx="8" cy="5" r="1.5"/>
|
||||
<circle cx="12.5" cy="9" r="1.5"/>
|
||||
<circle cx="3.5" cy="7.5" r="1.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
'';
|
||||
overrideIndex = pkgs.writeText "index.theme" ''
|
||||
[Icon Theme]
|
||||
Name=${overrideIconTheme}
|
||||
Comment=Nomarchy tray-icon overrides
|
||||
Inherits=${iconTheme},hicolor
|
||||
Directories=scalable/apps
|
||||
|
||||
[scalable/apps]
|
||||
Context=Applications
|
||||
Size=48
|
||||
MinSize=8
|
||||
MaxSize=512
|
||||
Type=Scalable
|
||||
'';
|
||||
overrideIconPkg = pkgs.runCommand "nomarchy-tray-icons" { } ''
|
||||
apps="$out/share/icons/${overrideIconTheme}/scalable/apps"
|
||||
mkdir -p "$apps"
|
||||
cp ${overrideIndex} "$out/share/icons/${overrideIconTheme}/index.theme"
|
||||
cp ${easyeffectsGlyph} "$apps/com.github.wwmm.easyeffects.svg"
|
||||
'';
|
||||
# Child + parent joined so the session profile carries both; the child's
|
||||
# index Inherits the parent by name, so lookups start in the child and
|
||||
# fall through. This becomes the exposed iconTheme/-Package below.
|
||||
sessionIconThemePackage = pkgs.symlinkJoin {
|
||||
name = "nomarchy-session-icons";
|
||||
paths = [ overrideIconPkg iconThemePackage ];
|
||||
};
|
||||
in
|
||||
{
|
||||
config = {
|
||||
nomarchy.theme = parsed // { inherit iconTheme border; };
|
||||
# Expose the override child as the session icon theme (it Inherits the
|
||||
# resolved parent, so downstream consumers — stylix GTK, rofi — behave
|
||||
# identically save for the tray overrides). `iconTheme` here is the
|
||||
# child name; the parent name lives on in its index's Inherits.
|
||||
nomarchy.theme = checked // {
|
||||
border = border;
|
||||
iconTheme = overrideIconTheme;
|
||||
iconThemePackage = sessionIconThemePackage;
|
||||
};
|
||||
|
||||
# Feature toggles the menu writes (settings.nightlight.enable, …), exposed
|
||||
# alongside the appearance state. Feature modules mkDefault-read from here
|
||||
# so a menu toggle lands in the flake instead of in ~/.local/state.
|
||||
nomarchy.settings = checked.settings;
|
||||
|
||||
nomarchy.lib = {
|
||||
# "#7aa2f7" -> "rgb(7aa2f7)" (Hyprland color syntax)
|
||||
|
||||
44
modules/home/timezone.nix
Normal file
44
modules/home/timezone.nix
Normal file
@@ -0,0 +1,44 @@
|
||||
# Auto-timezone, home side: keep the Waybar clock in step with the system
|
||||
# timezone. Waybar's clock module captures the zone once at construction, so a
|
||||
# runtime timezone change (automatic-timezoned, nomarchy.system.autoTimezone)
|
||||
# would NOT show until a relogin. A tiny watcher subscribes to timedate1's
|
||||
# change signal and reloads Waybar (SIGUSR2 = the same reload state-sync uses),
|
||||
# so the clock follows your location live. Also catches a manual
|
||||
# `timedatectl set-timezone`.
|
||||
#
|
||||
# Gated on the same in-flake flag the system side reads (settings.autoTimezone,
|
||||
# exposed via nomarchy.settings) — so it only runs when the feature is on. The
|
||||
# menu toggle (nomarchy-autotimezone) rebuilds both sides off that one flag.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy;
|
||||
enabled = cfg.waybar.enable && (cfg.settings.autoTimezone or false);
|
||||
|
||||
tzWatch = pkgs.writeShellScript "nomarchy-tz-watch" ''
|
||||
last=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
|
||||
${pkgs.dbus}/bin/dbus-monitor --system \
|
||||
"type='signal',interface='org.freedesktop.DBus.Properties',path='/org/freedesktop/timedate1',member='PropertiesChanged'" \
|
||||
2>/dev/null |
|
||||
while read -r _; do
|
||||
cur=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
|
||||
[ "$cur" = "$last" ] && continue
|
||||
last=$cur
|
||||
${pkgs.procps}/bin/pkill -SIGUSR2 -x waybar 2>/dev/null || true
|
||||
done
|
||||
'';
|
||||
in
|
||||
{
|
||||
systemd.user.services.nomarchy-tz-watch = lib.mkIf enabled {
|
||||
Unit = {
|
||||
Description = "Reload Waybar on timezone change (auto-timezone)";
|
||||
PartOf = [ "graphical-session.target" ];
|
||||
After = [ "graphical-session.target" ];
|
||||
};
|
||||
Service = {
|
||||
ExecStart = "${tzWatch}";
|
||||
Restart = "on-failure";
|
||||
};
|
||||
Install.WantedBy = [ "graphical-session.target" ];
|
||||
};
|
||||
}
|
||||
162
modules/home/updates.nix
Normal file
162
modules/home/updates.nix
Normal file
@@ -0,0 +1,162 @@
|
||||
# Update awareness (opt-in, nomarchy.updates.enable) — a passive background
|
||||
# check that surfaces a Waybar indicator + a notification when updates are
|
||||
# available, without ever changing anything (you still run nomarchy-pull /
|
||||
# nomarchy-rebuild / nomarchy-home / flatpak update). It counts:
|
||||
# • flake inputs whose locked rev is behind upstream (nixpkgs, the Nomarchy
|
||||
# input, home-manager …) — via `git ls-remote` on each branch-tracking
|
||||
# github/git input in flake.lock; offline → skipped, never a false alarm.
|
||||
# • Flatpak updates, when the `flatpak` CLI is present (services.flatpak on).
|
||||
#
|
||||
# nomarchy-updates is always on PATH and self-gates (status prints nothing
|
||||
# until the timer has found something), so the Waybar module — generated and
|
||||
# whole-swap — can exec it by name even when the feature is off.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy.updates;
|
||||
termSheet = import ./term-sheet.nix { inherit pkgs; };
|
||||
|
||||
nomarchy-updates = pkgs.writeShellScriptBin "nomarchy-updates" ''
|
||||
set -u
|
||||
# System + user profiles, so flatpak / sys-update resolve from a timer-run
|
||||
# service too (build-time tools below use absolute store paths regardless).
|
||||
export PATH="$PATH:/run/current-system/sw/bin:/etc/profiles/per-user/$USER/bin"
|
||||
GIT=${pkgs.git}/bin/git
|
||||
JQ=${pkgs.jq}/bin/jq
|
||||
cache="''${XDG_CACHE_HOME:-$HOME/.cache}/nomarchy"
|
||||
state="$cache/updates.json"
|
||||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||
mkdir -p "$cache"
|
||||
|
||||
count_nix() {
|
||||
local lock="$flake/flake.lock" n=0 name kind owner repo url ref locked giturl up
|
||||
[ -f "$lock" ] || { echo 0; return; }
|
||||
while IFS=$'\t' read -r name kind owner repo url ref locked; do
|
||||
[ -n "$locked" ] || continue
|
||||
case "$kind" in
|
||||
github) giturl="https://github.com/$owner/$repo" ;;
|
||||
git) giturl="$url" ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
up=$("$GIT" ls-remote "$giturl" "$ref" 2>/dev/null | ${pkgs.gawk}/bin/awk 'NR==1{print $1}')
|
||||
[ -n "$up" ] || continue # offline / unknown → skip (no false alarm)
|
||||
[ "$up" != "$locked" ] && n=$((n + 1))
|
||||
done < <(
|
||||
# Only the flake's DIRECT inputs (root.inputs) — not the transitive
|
||||
# closure — so a deep dependency bump doesn't nag as an "update".
|
||||
"$JQ" -r '
|
||||
.nodes as $nodes
|
||||
| ($nodes.root.inputs | [ .[] | if type == "array" then .[0] else . end ]) as $direct
|
||||
| $nodes | to_entries[]
|
||||
| .key as $k | .value as $v
|
||||
| select($direct | index($k))
|
||||
| select(($v.original.type? == "github") or ($v.original.type? == "git"))
|
||||
| select(($v.original.rev? // "") == "") # branch-tracking only
|
||||
| [ $k, $v.original.type,
|
||||
($v.original.owner? // ""), ($v.original.repo? // ""),
|
||||
($v.original.url? // ""), ($v.original.ref? // "HEAD"),
|
||||
($v.locked.rev? // "") ] | @tsv
|
||||
' "$lock"
|
||||
)
|
||||
echo "$n"
|
||||
}
|
||||
|
||||
count_flatpak() {
|
||||
${lib.optionalString cfg.flatpak ''
|
||||
if command -v flatpak >/dev/null 2>&1; then
|
||||
flatpak remote-ls --updates --columns=application 2>/dev/null | ${pkgs.gnugrep}/bin/grep -c . || true
|
||||
return
|
||||
fi
|
||||
''}
|
||||
echo 0
|
||||
}
|
||||
|
||||
refresh_bar() { ${pkgs.procps}/bin/pkill -RTMIN+9 -x waybar 2>/dev/null || true; }
|
||||
|
||||
case "''${1:-status}" in
|
||||
check)
|
||||
nix=$(count_nix); fp=$(count_flatpak); total=$((nix + fp))
|
||||
prev=$("$JQ" -r '.total // 0' "$state" 2>/dev/null || echo 0)
|
||||
printf '{"nix":%d,"flatpak":%d,"total":%d,"ts":%d}\n' \
|
||||
"$nix" "$fp" "$total" "$(${pkgs.coreutils}/bin/date +%s)" > "$state"
|
||||
# Notify only when NEW updates appear, so a daily timer doesn't nag.
|
||||
if [ "$total" -gt 0 ] && [ "$total" -gt "$prev" ]; then
|
||||
msg="$nix flake input(s)"
|
||||
[ "$fp" -gt 0 ] && msg="$msg · $fp Flatpak(s)"
|
||||
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Updates available" \
|
||||
"$msg — click the bar icon, or: nomarchy-pull && nomarchy-rebuild && nomarchy-home"
|
||||
fi
|
||||
refresh_bar ;;
|
||||
status)
|
||||
total=$("$JQ" -r '.total // 0' "$state" 2>/dev/null || echo 0)
|
||||
[ "$total" -gt 0 ] 2>/dev/null || exit 0 # up to date / unchecked → hide
|
||||
nix=$("$JQ" -r '.nix // 0' "$state"); fp=$("$JQ" -r '.flatpak // 0' "$state")
|
||||
tip="Updates available"
|
||||
[ "$nix" -gt 0 ] && tip="$tip\n• $nix flake input(s) — nomarchy-pull && nomarchy-rebuild && nomarchy-home"
|
||||
[ "$fp" -gt 0 ] && tip="$tip\n• $fp Flatpak(s) — flatpak update"
|
||||
printf '{"text":" %d","tooltip":"%s","class":"available"}\n' "$total" "$tip" ;;
|
||||
upgrade)
|
||||
echo "Checking…"; "$0" check
|
||||
nix=$("$JQ" -r '.nix // 0' "$state" 2>/dev/null || echo 0)
|
||||
fp=$("$JQ" -r '.flatpak // 0' "$state" 2>/dev/null || echo 0)
|
||||
echo "Pending: $nix flake input(s), $fp Flatpak(s)."
|
||||
if [ "$nix" -gt 0 ] && command -v nomarchy-pull >/dev/null 2>&1; then
|
||||
read -rp "Run nomarchy-pull && nomarchy-rebuild && nomarchy-home? [y/N] " a
|
||||
if [ "$a" = y ]; then
|
||||
nomarchy-pull
|
||||
nomarchy-rebuild
|
||||
command -v nomarchy-home >/dev/null 2>&1 && nomarchy-home
|
||||
fi
|
||||
fi
|
||||
if [ "$fp" -gt 0 ] && command -v flatpak >/dev/null 2>&1; then
|
||||
read -rp "Run flatpak update? [y/N] " a
|
||||
[ "$a" = y ] && flatpak update
|
||||
fi
|
||||
"$0" check
|
||||
echo "Done — press enter."; read -r _ || true ;;
|
||||
upgrade-window)
|
||||
# The Waybar click target. `upgrade` prompts, so it needs a terminal —
|
||||
# and the bar has none: it is spawned by Hyprland, whose environment has
|
||||
# no $TERMINAL (that is a home.sessionVariables entry, so only login
|
||||
# shells see it). A whole-swap's hand-written on-click that reached for
|
||||
# $TERMINAL expanded to nothing and the click silently did nothing
|
||||
# (#141). Opening our own window is the fix that cannot rot: every
|
||||
# caller — generated module and whole-swap alike — names one env-free
|
||||
# command, and a theme file stops having an opinion about terminals.
|
||||
#
|
||||
# A classed sheet like the calendar and doctor: a short y/N flow has no
|
||||
# business taking the whole screen, and the class is what hyprland.nix
|
||||
# floats it by. Smaller than either (45%x50%) — this is a prompt, not a
|
||||
# document.
|
||||
exec ${termSheet}/bin/nomarchy-term-sheet com.nomarchy.updates 45 50 \
|
||||
"$0" upgrade ;;
|
||||
*) echo "usage: nomarchy-updates [check|status|upgrade|upgrade-window]" >&2; exit 64 ;;
|
||||
esac
|
||||
'';
|
||||
in
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
# Always on PATH so the Waybar module (incl. the static whole-swap themes)
|
||||
# can exec it; it self-gates at runtime.
|
||||
{ home.packages = [ nomarchy-updates ]; }
|
||||
|
||||
(lib.mkIf cfg.enable {
|
||||
systemd.user.services.nomarchy-updates = {
|
||||
Unit.Description = "Check for Nomarchy / nixpkgs / Flatpak updates";
|
||||
Service = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${nomarchy-updates}/bin/nomarchy-updates check";
|
||||
};
|
||||
};
|
||||
systemd.user.timers.nomarchy-updates = {
|
||||
Unit.Description = "Periodic update-awareness check";
|
||||
Timer = {
|
||||
OnStartupSec = "2min";
|
||||
OnCalendar = cfg.interval;
|
||||
Persistent = true;
|
||||
};
|
||||
Install.WantedBy = [ "timers.target" ];
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
15
modules/home/viewers.nix
Normal file
15
modules/home/viewers.nix
Normal file
@@ -0,0 +1,15 @@
|
||||
# Document & image viewers — the "open a PDF / open a photo" half of a
|
||||
# complete workstation. zathura goes through its HM module (not a bare
|
||||
# package) because Stylix themes it via programs.zathura.options — the
|
||||
# reason it's a component toggle here rather than a template package
|
||||
# line. imv rides along: wayland-native, and an image viewer is a
|
||||
# borderless dark surface — nothing to theme. The heavier editors
|
||||
# (GIMP, Inkscape) stay template packages; mime.nix points the default
|
||||
# associations at these viewers.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
lib.mkIf config.nomarchy.viewers.enable {
|
||||
programs.zathura.enable = lib.mkDefault true;
|
||||
|
||||
home.packages = [ pkgs.imv ];
|
||||
}
|
||||
51
modules/home/waybar-language.nix
Normal file
51
modules/home/waybar-language.nix
Normal file
@@ -0,0 +1,51 @@
|
||||
# When the keyboard-layout indicator earns a place in the bar, and how a
|
||||
# whole-swap bar is held to the same answer (BACKLOG #109).
|
||||
#
|
||||
# Pure so checks.waybar-language can unit-test the contract without building
|
||||
# a bar. Consumed by modules/home/waybar.nix.
|
||||
{ lib }:
|
||||
|
||||
rec {
|
||||
# The session's layouts are the comma-separated kb_layout.
|
||||
# nomarchy.keyboard.layouts is deliberately NOT among them — hyprland.nix
|
||||
# keeps it as the pool the new-keyboard picker offers first and never
|
||||
# merges it into kb_layout — so listing candidates cannot by itself make
|
||||
# the current layout ambiguous.
|
||||
sessionLayouts = layout: lib.filter (l: l != "") (lib.splitString "," layout);
|
||||
|
||||
# The indicator answers "which layout am I typing in?", so it is worth bar
|
||||
# space exactly when that has more than one answer.
|
||||
#
|
||||
# devices = declared nomarchy.keyboard.devices (submodules with .layout)
|
||||
# remembered = settings.keyboard.devices from the in-flake state: what the
|
||||
# new-keyboard watcher writes the moment a layout is chosen.
|
||||
# These only graduate into `devices` at the next rebuild, so a
|
||||
# bar reading `devices` alone stays wrong for exactly as long
|
||||
# as the memory is fresh — which is when it matters most.
|
||||
#
|
||||
# Counting devices rather than layouts is the trap: remembering "us" for a
|
||||
# device on a "us" session adds no second answer, and the rows a keyboard's
|
||||
# extra HID collections leave behind are all of that kind.
|
||||
show = { layout, devices ? { }, remembered ? { } }:
|
||||
let
|
||||
session = sessionLayouts layout;
|
||||
perDevice = lib.mapAttrsToList (_: d: d.layout) devices
|
||||
++ lib.attrValues remembered;
|
||||
in
|
||||
lib.length session > 1 || lib.any (l: ! lib.elem l session) perDevice;
|
||||
|
||||
# A whole-swap authors its bar in full and every one of them lists the
|
||||
# language module unconditionally, so without this a single-layout setup
|
||||
# showed on a swapped bar what the generated bar hides. Filter the swap
|
||||
# through the one answer rather than asking four hand-written files to
|
||||
# re-derive it (CONVENTIONS: the parity rule).
|
||||
gate = showLanguage: bar:
|
||||
if showLanguage then bar
|
||||
else
|
||||
lib.mapAttrs
|
||||
(n: v:
|
||||
if lib.hasPrefix "modules-" n && builtins.isList v
|
||||
then lib.filter (m: m != "hyprland/language") v
|
||||
else v)
|
||||
bar;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Waybar — two-tier theming:
|
||||
#
|
||||
# 1. Default: structure, fonts, geometry AND palette baked from
|
||||
# theme-state.json (colors as GTK named colors, @define-color).
|
||||
# state.json (colors as GTK named colors, @define-color).
|
||||
#
|
||||
# 2. Whole-swap: themes with their own visual identity ship
|
||||
# <themesDir>/<slug>/waybar.css (and optionally waybar.jsonc) which
|
||||
@@ -11,13 +11,18 @@
|
||||
|
||||
let
|
||||
t = config.nomarchy.theme;
|
||||
termSheet = import ./term-sheet.nix { inherit pkgs; };
|
||||
|
||||
# Show the active-keyboard-layout indicator only when more than one layout
|
||||
# is in play — multiple session layouts (comma-separated) or per-device
|
||||
# overrides (nomarchy.keyboard.devices) — so single-layout bars stay clean.
|
||||
showLanguage = lib.hasInfix "," config.nomarchy.keyboard.layout
|
||||
|| config.nomarchy.keyboard.layouts != [ ]
|
||||
|| config.nomarchy.keyboard.devices != { };
|
||||
# Whether the keyboard-layout indicator is worth bar space, and the filter
|
||||
# that holds a whole-swap to the same answer. Contract + rationale (and
|
||||
# checks.waybar-language) live in ./waybar-language.nix.
|
||||
langLib = import ./waybar-language.nix { inherit lib; };
|
||||
showLanguage = langLib.show {
|
||||
layout = config.nomarchy.keyboard.layout;
|
||||
devices = config.nomarchy.keyboard.devices;
|
||||
remembered = config.nomarchy.settings.keyboard.devices or { };
|
||||
};
|
||||
gateLanguage = langLib.gate showLanguage;
|
||||
|
||||
# Power-profile indicator (power-profiles-daemon). Both scripts self-
|
||||
# gate: they exit silently unless this is a laptop (a battery is
|
||||
@@ -28,8 +33,46 @@ let
|
||||
# Named writeShellScriptBins (put on PATH via home.packages below) rather
|
||||
# than bare writeShellScript store paths, so the whole-swap themes' static
|
||||
# waybar.jsonc can exec them by name too — same as the swaync bell.
|
||||
# Waybar supervisor — exec-once has no restart, so a crashed bar used to
|
||||
# leave the session bar-less until relogin (seen on hardware: a theme
|
||||
# switch crashed waybar mid-reload). Respawns on ANY exit — a plain
|
||||
# `pkill -x waybar` is now a clean restart, which nomarchy-state-sync
|
||||
# uses instead of the crash-prone in-place SIGUSR2 reload when it sees
|
||||
# this supervisor running. Crash-loop guard: 5 exits within 10s of
|
||||
# their start → give up with a critical notification instead of
|
||||
# spinning. Stop the bar for real: pkill -f nomarchy-waybar (TERM is
|
||||
# trapped to take the child down too).
|
||||
waybarSupervisor = pkgs.writeShellScriptBin "nomarchy-waybar" ''
|
||||
child=
|
||||
trap '[ -n "$child" ] && kill "$child" 2>/dev/null; exit 0' TERM INT
|
||||
fails=0
|
||||
while :; do
|
||||
start=$(date +%s)
|
||||
waybar & child=$!
|
||||
wait "$child"; code=$?
|
||||
if [ $(( $(date +%s) - start )) -lt 10 ]; then
|
||||
fails=$((fails + 1))
|
||||
if [ "$fails" -ge 5 ]; then
|
||||
notify-send -u critical "Waybar" \
|
||||
"Crashing on start (exit $code) — check ~/.config/waybar. Giving up." 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
fails=0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
'';
|
||||
|
||||
powerProfileStatus = pkgs.writeShellScriptBin "nomarchy-powerprofile-status" ''
|
||||
case "$(ls /sys/class/power_supply/ 2>/dev/null)" in *BAT*) ;; *) exit 0 ;; esac
|
||||
# Name-agnostic system battery (BAT0, CMB0, …) — BACKLOG #60.
|
||||
has_bat=
|
||||
for d in /sys/class/power_supply/*/; do
|
||||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||||
has_bat=1; break
|
||||
done
|
||||
[ -n "$has_bat" ] || exit 0
|
||||
command -v powerprofilesctl >/dev/null 2>&1 || exit 0
|
||||
prof=$(powerprofilesctl get 2>/dev/null) || exit 0
|
||||
case "$prof" in
|
||||
@@ -51,6 +94,55 @@ let
|
||||
[ -n "$next" ] && powerprofilesctl set "$next"
|
||||
'';
|
||||
|
||||
# Opens calcurse (lightweight TUI calendar) in a floating, centered kitty
|
||||
# window — Waybar clock on-click. Distinct --class → hyprland.nix float
|
||||
# rules. Kitty is always installed (kitty.nix). The sheet sizes itself
|
||||
# (#139: a percentage `size` rule is silently ignored) — see term-sheet.nix.
|
||||
calendarLauncher = pkgs.writeShellScriptBin "nomarchy-calendar" ''
|
||||
if ! command -v calcurse >/dev/null 2>&1; then
|
||||
notify-send "Calendar" "calcurse isn't installed (removed from home.packages?)." 2>/dev/null
|
||||
exit 0
|
||||
fi
|
||||
exec ${termSheet}/bin/nomarchy-term-sheet com.nomarchy.calendar 60 65 calcurse
|
||||
'';
|
||||
|
||||
# VPN indicator — shows a shield when a NetworkManager VPN/WireGuard
|
||||
# connection is active OR Tailscale is up; prints nothing otherwise so the
|
||||
# module self-hides (like nightlight/updates). The tooltip names each
|
||||
# active VPN (NM connection names, "Tailscale") — without it a forgotten
|
||||
# tailscaled reads as "the icon is stuck". Click opens the VPN submenu.
|
||||
vpnStatus = pkgs.writeShellScriptBin "nomarchy-vpn-status" ''
|
||||
names=$(nmcli -t -f NAME,TYPE connection show --active 2>/dev/null \
|
||||
| awk -F: '$2=="vpn"||$2=="wireguard"{print $1}')
|
||||
ts=""
|
||||
if command -v tailscale >/dev/null 2>&1; then
|
||||
[ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=Tailscale
|
||||
fi
|
||||
[ -n "$names$ts" ] || exit 0
|
||||
list=$(printf '%s\n' "$names" "$ts" | grep -v '^$' | paste -sd ', ' -)
|
||||
tip=$(printf 'VPN: %s\n(click to manage)' "$list" | jq -Rs 'rtrimstr("\n")')
|
||||
printf '{"text":"","tooltip":%s,"class":"on"}\n' "$tip"
|
||||
'';
|
||||
|
||||
# Health warning — self-gates: prints nothing while nomarchy-doctor
|
||||
# exits 0, so the module only appears when the sheet has a ✖ (the
|
||||
# same self-hide discipline as vpn/nightlight/updates). The tooltip
|
||||
# carries the first failing lines; click opens the full sheet.
|
||||
# Absolute path to the doctor binary: waybar's custom-module env can
|
||||
# miss system PATH, and `command -v … || exit 0` then self-hides forever.
|
||||
doctorStatus = pkgs.writeShellScriptBin "nomarchy-doctor-status" ''
|
||||
out=$(${pkgs.nomarchy-doctor}/bin/nomarchy-doctor 2>/dev/null) && exit 0
|
||||
# Strip ANSI so the tooltip is plain text (and waybar never chokes on
|
||||
# control chars); keep only the ✖ lines.
|
||||
tip=$(printf '%s\n' "$out" \
|
||||
| ${pkgs.gnused}/bin/sed 's/\x1b\[[0-9;]*m//g' \
|
||||
| grep '✖' | head -5 \
|
||||
| ${pkgs.jq}/bin/jq -Rs 'rtrimstr("\n") + "\n(click for the full sheet)"')
|
||||
# (md-alert-circle): alert shape that survives incomplete Nerd Font
|
||||
# cuts better than ; class:bad still paints it @bad.
|
||||
printf '{"text":"","tooltip":%s,"class":"bad"}\n' "$tip"
|
||||
'';
|
||||
|
||||
# Per-theme override probe.
|
||||
assetDir = config.nomarchy.themesDir + "/${t.slug}";
|
||||
styleOverride = assetDir + "/waybar.css";
|
||||
@@ -63,9 +155,17 @@ let
|
||||
(lib.mapAttrsToList (name: value: "@define-color ${name} ${value};") t.colors);
|
||||
|
||||
generatedSettings = {
|
||||
layer = "top";
|
||||
# `bottom`, not `top`: on Hyprland the `top` layer renders above every
|
||||
# window — so a real fullscreen surface (a browser video gone
|
||||
# fullscreen) sits *under* the bar. On `bottom` the fullscreen window
|
||||
# covers the bar, while the exclusive zone still reserves its space in
|
||||
# normal tiling. Keep in sync with the whole-swap jsoncs (parity rule).
|
||||
layer = "bottom";
|
||||
position = "top";
|
||||
height = 34;
|
||||
# 36, not 34: the active-workspace pill is inset 3px top+bottom (CSS
|
||||
# below) so it can never overhang the bar's border — the extra height
|
||||
# keeps the pill's text from cramping inside the inset (#152).
|
||||
height = 36;
|
||||
margin-top = t.ui.gapsOut;
|
||||
margin-left = t.ui.gapsOut;
|
||||
margin-right = t.ui.gapsOut;
|
||||
@@ -75,11 +175,28 @@ let
|
||||
# home-manager switch restyles the running bar without a restart.
|
||||
reload_style_on_change = true;
|
||||
|
||||
modules-left = [ "hyprland/workspaces" "hyprland/window" ];
|
||||
# Logo + powermenu: whole-swaps already ship these (parity was reverse
|
||||
# — BACKLOG #63). Click targets are existing nomarchy-menu entry points.
|
||||
modules-left = [ "custom/nomarchy" "hyprland/workspaces" "hyprland/window" ];
|
||||
modules-center = [ "clock" ];
|
||||
modules-right = [ "tray" "pulseaudio" "network" "cpu" "memory" "custom/powerprofile" ]
|
||||
modules-right = [ "custom/recording" "idle_inhibitor" "tray" "custom/vpn" "custom/airplane" "pulseaudio" "custom/powerprofile" "custom/nightlight" ]
|
||||
++ lib.optional showLanguage "hyprland/language"
|
||||
++ [ "battery" "custom/notification" ];
|
||||
++ [ "battery" "custom/doctor" "custom/updates" "custom/notification" "custom/powermenu" ];
|
||||
|
||||
"custom/nomarchy" = {
|
||||
interval = "once";
|
||||
# U+F000 — Nomarchy monogram (literal UTF-8; Nix has no \u escapes).
|
||||
# CSS pins font-family: Nomarchy so Nerd Fonts' glass glyph does not win.
|
||||
format = "";
|
||||
on-click = "nomarchy-menu";
|
||||
tooltip-format = "Nomarchy menu";
|
||||
};
|
||||
|
||||
"custom/powermenu" = {
|
||||
format = ""; # U+F011 power symbol
|
||||
on-click = "nomarchy-menu power";
|
||||
tooltip = false;
|
||||
};
|
||||
|
||||
"hyprland/workspaces" = {
|
||||
format = "{icon}";
|
||||
@@ -92,56 +209,165 @@ let
|
||||
};
|
||||
|
||||
clock = {
|
||||
format = "{:%H:%M}";
|
||||
format-alt = "{:%A %d %B %Y}";
|
||||
tooltip-format = "<tt><small>{calendar}</small></tt>";
|
||||
# Single module: time + short date (whole-swaps used to split these
|
||||
# into clock + clock#date — one click surface for the calendar).
|
||||
format = "{:%H:%M · %a %d %b}";
|
||||
# Left-click → the calendar (nomarchy-calendar → calcurse in a floating
|
||||
# kitty). Tooltip is plain date/zone only — embedding {calendar}
|
||||
# produced an empty popup on hardware (2026-07-10); month view is
|
||||
# one click away.
|
||||
on-click = "nomarchy-calendar";
|
||||
tooltip = true;
|
||||
# One chrono block only: waybar/fmt empties the tooltip when two
|
||||
# bare `{:%…}` specs are concatenated (bar format works because it
|
||||
# is a single block). Newline + zone live under auto-timezone
|
||||
# (tz-watch SIGUSR2 reload).
|
||||
tooltip-format = "{:%A, %d %B %Y\n%Z (UTC%z)}";
|
||||
};
|
||||
|
||||
# Active keyboard layout (per focused device) — only placed in
|
||||
# modules-right when showLanguage (see above).
|
||||
"hyprland/language" = {
|
||||
format = " {short}";
|
||||
# Two spaces after the glyph: the keyboard icon's right bearing
|
||||
# otherwise kisses the layout code (esp. mono Nerd faces).
|
||||
format = "<span size='${toString (t.fonts.size + 2)}pt'> </span>{short}";
|
||||
tooltip = false;
|
||||
};
|
||||
|
||||
pulseaudio = {
|
||||
format = "{icon} {volume}%";
|
||||
# Two trailing spaces on every icon level: high-volume glyphs are the
|
||||
# widest and still kissed the % with a single pad (hardware report).
|
||||
format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span>{volume}%";
|
||||
format-muted = "";
|
||||
format-icons.default = [ "" "" "" ];
|
||||
format-icons.default = [ " " " " " " ];
|
||||
on-click = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
|
||||
# Right-click → the full mixer (per-app volumes) in a floating window
|
||||
# (item 35). pwvucontrol ships in the template suite; the Hyprland
|
||||
# windowrule floats it. Keep in sync with the whole-swap jsoncs.
|
||||
on-click-right = "pwvucontrol";
|
||||
};
|
||||
|
||||
network = {
|
||||
format-wifi = " {essid}";
|
||||
format-ethernet = "";
|
||||
format-disconnected = "";
|
||||
tooltip-format = "{ipaddr} via {gwaddr}";
|
||||
# nm-applet sits in the tray for the GUI path; this is the
|
||||
# keyboard-friendly one.
|
||||
on-click = "${config.nomarchy.terminal} -e nmtui";
|
||||
# Idle inhibitor (caffeine): click → to hold the screen awake —
|
||||
# blocks hypridle's lock / display-off / suspend during video or a
|
||||
# presentation. Waybar holds a Wayland idle-inhibit while activated;
|
||||
# the state resets with the bar (deliberate — caffeine shouldn't
|
||||
# survive a relogin). Summer-night's whole-swap bar had this first
|
||||
# (reverse parity gap, 2026-07-04).
|
||||
idle_inhibitor = {
|
||||
format = "{icon}";
|
||||
format-icons = { activated = ""; deactivated = ""; };
|
||||
tooltip-format-activated = "Screen held awake — click to release";
|
||||
tooltip-format-deactivated = "Keep the screen awake (caffeine)";
|
||||
};
|
||||
|
||||
cpu.format = " {usage}%";
|
||||
memory.format = " {percentage}%";
|
||||
# No network module: nm-applet lives in the tray (the GUI path), so the
|
||||
# bar's wifi/ethernet indicator would just duplicate it.
|
||||
|
||||
battery = {
|
||||
states = { warning = 25; critical = 10; };
|
||||
format = "{icon} {capacity}%";
|
||||
format-charging = " {capacity}%";
|
||||
format-icons = [ "" "" "" "" "" ];
|
||||
# Same double-pad as pulseaudio (single space still tight on some glyphs).
|
||||
format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span>{capacity}%";
|
||||
format-charging = "<span size='${toString (t.fonts.size + 2)}pt'> </span>{capacity}%";
|
||||
format-icons = [ " " " " " " " " " " ];
|
||||
# The kernel's "Not charging" maps to waybar's Plugged state — on a
|
||||
# machine with a charge cap that's the hold band doing its job
|
||||
# (power.nix sets start = cap − 10), which reads as "broken charger"
|
||||
# without an explanation. The cap value isn't baked here: the menu
|
||||
# can change it live (sysfs + state, no rebuild), so a number would
|
||||
# go stale.
|
||||
tooltip-format-plugged = "Plugged in, not charging — held at {capacity}% by the battery charge cap\nCharging resumes ~10% below the cap · click to adjust";
|
||||
# Click either the battery or the power-profile icon → the combined
|
||||
# power menu (profile + charge cap). The granular System rows stay.
|
||||
on-click = "nomarchy-menu powermgmt";
|
||||
};
|
||||
|
||||
"custom/powerprofile" = {
|
||||
exec = "nomarchy-powerprofile-status";
|
||||
return-type = "json";
|
||||
interval = 5;
|
||||
on-click = "nomarchy-powerprofile-cycle";
|
||||
# Opens the same combined power menu as the battery icon (item 36b).
|
||||
# nomarchy-powerprofile-cycle stays a standalone bin for downstream
|
||||
# rebinding — it's just no longer the default click.
|
||||
on-click = "nomarchy-menu powermgmt";
|
||||
};
|
||||
|
||||
# VPN shield — self-hides unless a NM VPN/WireGuard tunnel or Tailscale is
|
||||
# up. Click opens the VPN submenu (nomarchy-vpn). 5s poll like powerprofile.
|
||||
"custom/vpn" = {
|
||||
exec = "nomarchy-vpn-status";
|
||||
return-type = "json";
|
||||
interval = 5;
|
||||
on-click = "nomarchy-vpn";
|
||||
};
|
||||
|
||||
# Health check is real work (systemctl/disk/git sweeps), so a long
|
||||
# interval — this is a tripwire, not a monitor.
|
||||
"custom/doctor" = {
|
||||
exec = "nomarchy-doctor-status";
|
||||
return-type = "json";
|
||||
format = "{}";
|
||||
interval = 300;
|
||||
# signal 10: poke after a fix (or from theme-shot) so the tripwire
|
||||
# doesn't wait a full interval after a first-poll miss.
|
||||
signal = 10;
|
||||
on-click = "nomarchy-menu doctor";
|
||||
};
|
||||
|
||||
# Screen recording indicator. Self-gates: visible only while
|
||||
# nomarchy-record runs (status prints nothing otherwise), and the
|
||||
# click IS the stop surface — the menu only starts recordings.
|
||||
# signal 8: the recorder pokes the bar on start/stop so the ⏺
|
||||
# appears/vanishes instantly (the interval is just a safety net).
|
||||
"custom/recording" = {
|
||||
exec = "nomarchy-record status";
|
||||
return-type = "json";
|
||||
interval = 10;
|
||||
signal = 8;
|
||||
on-click = "nomarchy-record stop";
|
||||
};
|
||||
|
||||
# Night-light (hyprsunset) indicator. Self-gates: the moon shows only while
|
||||
# the schedule runs; otherwise the status helper prints nothing => hidden
|
||||
# (enable / re-enable from the System menu). Click toggles instantly — writes
|
||||
# the in-flake on/off (settings.nightlight.on, no rebuild) and flips the unit
|
||||
# with systemctl, so the choice lands in the flake and survives reboot.
|
||||
"custom/nightlight" = {
|
||||
exec = "nomarchy-nightlight status";
|
||||
return-type = "json";
|
||||
interval = 3;
|
||||
on-click = "nomarchy-nightlight toggle";
|
||||
};
|
||||
|
||||
# Airplane mode (#104). Self-gates: plane glyph only while engaged
|
||||
# (status prints nothing otherwise). Click toggles Wi-Fi+BT and
|
||||
# restores prior radio state on disengage. signal 11 = instant refresh.
|
||||
"custom/airplane" = {
|
||||
exec = "nomarchy-airplane status";
|
||||
return-type = "json";
|
||||
interval = 5;
|
||||
signal = 11;
|
||||
on-click = "nomarchy-airplane toggle";
|
||||
};
|
||||
|
||||
# Update awareness. Self-gates: hidden unless nomarchy.updates is enabled
|
||||
# AND the periodic check found something (the helper prints nothing then).
|
||||
# signal 9 lets the checker refresh it instantly; click opens the upgrade
|
||||
# flow in a terminal.
|
||||
"custom/updates" = {
|
||||
exec = "nomarchy-updates status";
|
||||
return-type = "json";
|
||||
interval = 1800;
|
||||
signal = 9;
|
||||
on-click = "nomarchy-updates upgrade-window";
|
||||
};
|
||||
|
||||
# swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON
|
||||
# (text/tooltip/class) on every change, so it tracks count and DND with
|
||||
# no polling. Left-click toggles the panel; right-click toggles DND.
|
||||
# The `dnd-*` classes (bell-off glyph) are styled muted below.
|
||||
# Every *suppressed* state — DND or app-inhibited — maps to the bell-off
|
||||
# glyph and the muted color below, so "notifications are off right now"
|
||||
# reads by SHAPE, not color alone (color-blind sweep, item 28): inhibited
|
||||
# used to reuse the normal / bells, distinguishable only by color.
|
||||
"custom/notification" = {
|
||||
exec = "swaync-client -swb";
|
||||
return-type = "json";
|
||||
@@ -152,8 +378,8 @@ let
|
||||
notification = "";
|
||||
dnd-none = "";
|
||||
dnd-notification = "";
|
||||
inhibited-none = "";
|
||||
inhibited-notification = "";
|
||||
inhibited-none = "";
|
||||
inhibited-notification = "";
|
||||
dnd-inhibited-none = "";
|
||||
dnd-inhibited-notification = "";
|
||||
};
|
||||
@@ -167,15 +393,32 @@ let
|
||||
};
|
||||
|
||||
generatedStyle = ''
|
||||
/* Palette baked from theme-state.json */
|
||||
/* Palette baked from state.json */
|
||||
${colorDefs}
|
||||
|
||||
/* NB: this `*` reset reaches the SNI tray menus Waybar hosts too
|
||||
(its stylesheet applies process-wide, at a priority user gtk.css
|
||||
cannot out-rank). The menu block right below undoes the damage —
|
||||
keep the two in sync. Do NOT scope `*` to window#waybar instead:
|
||||
the id's specificity would beat the class rules below and wreck
|
||||
the bar. Whole-swap theme waybar.css files carry the same pair. */
|
||||
* {
|
||||
font-family: "${t.fonts.ui}", "${t.fonts.mono}";
|
||||
font-size: ${toString t.fonts.size}pt;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Tray menus: undo the `*` reset — same stylesheet, higher
|
||||
specificity. Arrows/checks/separators are CSS-sized nodes;
|
||||
min-height 0 makes them invisible. */
|
||||
menu menuitem arrow { min-width: 16px; min-height: 16px; }
|
||||
menu check, menu radio { min-width: 14px; min-height: 14px; }
|
||||
menu separator {
|
||||
min-height: 1px;
|
||||
margin: 5px 0;
|
||||
background: alpha(@text, 0.15);
|
||||
}
|
||||
|
||||
window#waybar {
|
||||
background: alpha(@base, 0.85);
|
||||
color: @text;
|
||||
@@ -183,9 +426,20 @@ let
|
||||
border-radius: ${toString t.ui.rounding}px;
|
||||
}
|
||||
|
||||
/* Dim states use the palette's @muted role on DARK themes: since
|
||||
item 28b it is floor-guaranteed legible on @base (muted/base >=
|
||||
2.0, gated by tools/check-theme-contrast.py). On LIGHT themes that
|
||||
2.0-floor grey washes out at number size — inactive workspace
|
||||
numbers were unreadable on every light palette — so light mode
|
||||
promotes them to @subtext (>= 3.0 floor, typically 4-7:1).
|
||||
Secondary-but-not-dim stays alpha(@text, 0.85). */
|
||||
/* 3px vertical margin insets the active pill INSIDE the bar — without
|
||||
it the filled button spans the bar's full inner height and visually
|
||||
overhangs the rounded border (#152). */
|
||||
#workspaces button {
|
||||
padding: 0 8px;
|
||||
color: @muted;
|
||||
margin: 3px 0;
|
||||
color: ${if t.mode == "light" then "@subtext" else "@muted"};
|
||||
border-radius: ${toString t.ui.rounding}px;
|
||||
}
|
||||
|
||||
@@ -200,7 +454,7 @@ let
|
||||
}
|
||||
|
||||
#window {
|
||||
color: @subtext;
|
||||
color: alpha(@text, 0.85);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
@@ -209,26 +463,95 @@ let
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#tray, #pulseaudio, #network, #cpu, #memory, #custom-powerprofile, #language, #battery, #custom-notification {
|
||||
color: @subtext;
|
||||
#custom-nomarchy {
|
||||
color: @accent;
|
||||
font-family: Nomarchy;
|
||||
font-size: ${toString (t.fonts.size + 4)}pt;
|
||||
/* Wider outer padding: the bar's rounded corner curves into the
|
||||
end module's box, so the edge glyphs need more room than the
|
||||
uniform 10px or they look cramped (#152; power button mirrors). */
|
||||
padding: 0 10px 0 14px;
|
||||
}
|
||||
#custom-nomarchy:hover { color: @accentAlt; }
|
||||
|
||||
#tray, #pulseaudio, #custom-powerprofile, #custom-nightlight, #custom-airplane, #custom-updates, #custom-vpn, #custom-recording, #idle_inhibitor, #language, #battery, #custom-doctor, #custom-notification, #custom-powermenu {
|
||||
color: alpha(@text, 0.85);
|
||||
padding: 0 10px;
|
||||
}
|
||||
#custom-powermenu { padding: 0 16px 0 10px; }
|
||||
#custom-powermenu:hover { color: @bad; }
|
||||
|
||||
/* notifications waiting → accent; Do-Not-Disturb → muted bell-off */
|
||||
/* Group rhythm (item 28c): a wider breath before each functional
|
||||
group of the right cluster — media/stats · toggles · status —
|
||||
on top of the uniform module spacing. A group head can self-hide
|
||||
(e.g. no battery on desktops); grouping then degrades to the
|
||||
uniform spacing, never breaks. */
|
||||
#pulseaudio, #custom-powerprofile, #battery { margin-left: 14px; }
|
||||
|
||||
/* Caffeine engaged → warm tone (the screen is being held awake). */
|
||||
#idle_inhibitor.activated { color: @warn; }
|
||||
|
||||
/* Recording in progress → the alert red; the ⏺ is also the stop button. */
|
||||
#custom-recording.recording { color: @bad; }
|
||||
|
||||
/* VPN active → accent green, reading as "connected / protected". */
|
||||
#custom-vpn.on { color: @good; }
|
||||
|
||||
/* Night-light active → warm tone, matching the filter it represents. */
|
||||
#custom-nightlight.on { color: @warn; }
|
||||
#custom-airplane.on { color: @warn; }
|
||||
|
||||
/* Updates pending → accent, to draw the eye. */
|
||||
#custom-updates.available { color: @accent; }
|
||||
|
||||
/* Doctor tripwire — only rendered when something is ✖, so it is
|
||||
always the alert color. */
|
||||
#custom-doctor { color: @bad; }
|
||||
|
||||
/* Icon-only status modules carry no text, so they don't get the
|
||||
(size+2)pt Pango icon span the icon+text modules use — bump their
|
||||
font-size to match, or these glyphs read smaller than the volume /
|
||||
battery / language icons beside them. */
|
||||
#custom-recording, #custom-updates, #custom-vpn, #custom-airplane, #custom-nightlight, #custom-doctor, #custom-notification, #custom-powermenu { font-size: ${toString (t.fonts.size + 2)}pt; }
|
||||
|
||||
/* The speedometer + caffeine glyphs render small in their em box —
|
||||
size them up a touch more so they read at a glance. */
|
||||
#custom-powerprofile, #idle_inhibitor { font-size: ${toString (t.fonts.size + 3)}pt; }
|
||||
|
||||
/* notifications waiting → accent; suppressed (DND or app-inhibited) →
|
||||
muted bell-off, so it reads by shape+color, never color alone. */
|
||||
#custom-notification.notification { color: @accent; }
|
||||
#custom-notification.dnd-none,
|
||||
#custom-notification.dnd-notification { color: @muted; }
|
||||
#custom-notification.dnd-notification,
|
||||
#custom-notification.inhibited-none,
|
||||
#custom-notification.inhibited-notification { color: @muted; }
|
||||
|
||||
#pulseaudio.muted { color: @muted; }
|
||||
#battery.warning { color: @warn; }
|
||||
#battery.critical { color: @bad; }
|
||||
#battery.charging { color: @good; }
|
||||
|
||||
/* Clock (and other) hover tooltips — explicit colors so an empty
|
||||
looking box is never just “text same as background”. */
|
||||
tooltip {
|
||||
background: @surface;
|
||||
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
|
||||
border-radius: ${toString t.ui.rounding}px;
|
||||
}
|
||||
tooltip label { color: @text; }
|
||||
'';
|
||||
in
|
||||
{
|
||||
programs.waybar = lib.mkIf config.nomarchy.waybar.enable {
|
||||
enable = true;
|
||||
systemd.enable = true; # started/stopped with graphical-session.target
|
||||
# Launched from Hyprland's exec-once (hyprland.nix), NOT a systemd user
|
||||
# service. Bound to graphical-session.target the unit raced Hyprland's IPC
|
||||
# on a warm relogin — it started before the socket was up, exited, landed
|
||||
# in `failed`, and was never retried, so the bar vanished. exec-once only
|
||||
# fires once Hyprland is up, dodging the race; theme switches reload the
|
||||
# running bar via SIGUSR2 (nomarchy-state-sync). No uwsm here to manage the
|
||||
# session target, so we don't depend on its lifecycle.
|
||||
systemd.enable = false;
|
||||
|
||||
# mkDefault so downstream can replace the whole bar config/style with
|
||||
# a plain home.nix assignment. For per-theme identity, prefer the
|
||||
@@ -237,7 +560,7 @@ in
|
||||
# docs/OVERRIDES.md.
|
||||
settings.mainBar = lib.mkDefault (
|
||||
if hasConfigOverride
|
||||
then builtins.fromJSON (builtins.readFile configOverride)
|
||||
then gateLanguage (builtins.fromJSON (builtins.readFile configOverride))
|
||||
else generatedSettings
|
||||
);
|
||||
|
||||
@@ -248,8 +571,12 @@ in
|
||||
);
|
||||
};
|
||||
|
||||
# The power-profile helpers on PATH, so both the generated bar and the
|
||||
# whole-swap themes' static waybar.jsonc can exec them by name.
|
||||
# Bar helpers on PATH (whole-swap jsoncs exec them by bare name) +
|
||||
# feature deps of bar clicks: calcurse (clock), pwvucontrol (volume
|
||||
# right-click) — not opt-in template packages.
|
||||
home.packages = lib.optionals config.nomarchy.waybar.enable
|
||||
[ powerProfileStatus powerProfileCycle ];
|
||||
[ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus doctorStatus calendarLauncher
|
||||
pkgs.calcurse
|
||||
pkgs.pwvucontrol
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# yazi — the flagship file manager: a fast, keyboard-driven TUI that
|
||||
# fits the distro's identity (Ghostty's Kitty-graphics previews, rofi
|
||||
# menus, everything from one JSON). Themed from theme-state.json and
|
||||
# fits the distro's identity (Kitty graphics previews, rofi
|
||||
# menus, everything from one JSON). Themed from state.json and
|
||||
# shipped with a curated plugin set. The GUI half (Thunar, "open folder"
|
||||
# handler) is nomarchy.system.fileManager on the system side.
|
||||
#
|
||||
@@ -92,7 +92,7 @@ in
|
||||
|
||||
# Theme from the palette. yazi merges this over its built-in theme,
|
||||
# so only the accent-bearing UI is specified; everything else keeps
|
||||
# yazi's defaults (which already follow Ghostty's ANSI colors).
|
||||
# yazi's defaults (which already follow Kitty's ANSI colors).
|
||||
theme = {
|
||||
mgr = {
|
||||
cwd = { fg = c.accent; };
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# nixosModules.nomarchy in flake.nix) and layer your machine specifics
|
||||
# (bootloader, hostname, users, hardware) on top. Host concerns are
|
||||
# deliberately NOT set here. Everything user-facing (Hyprland config,
|
||||
# Waybar, Ghostty, theming) lives in modules/home.
|
||||
# Waybar, Kitty, theming) lives in modules/home.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
@@ -20,35 +20,98 @@ let
|
||||
nomarchyLogoFont = pkgs.runCommandLocal "nomarchy-logo-font" { } ''
|
||||
install -Dm444 ${./branding/Nomarchy.ttf} $out/share/fonts/truetype/Nomarchy.ttf
|
||||
'';
|
||||
|
||||
# Menu Preferences › Bluetooth package writes settings.bluetooth.enable; read
|
||||
# it from the state file, the only place it exists on the NixOS side (the
|
||||
# hardware.nix/timezone.nix bridge). Missing/invalid JSON fails closed via
|
||||
# state-read.nix rather than a raw stack. null = key absent, which
|
||||
# leaves the option's own default (true) alone.
|
||||
sysState =
|
||||
if cfg.stateFile != null
|
||||
then import ../state-read.nix { inherit lib; } cfg.stateFile
|
||||
else { };
|
||||
stateBluetooth =
|
||||
let v = (sysState.settings or { }).bluetooth.enable or null;
|
||||
in if builtins.isBool v then v else null;
|
||||
in
|
||||
{
|
||||
imports = [ ./options.nix ./plymouth.nix ./file-manager.nix ./power.nix ./services.nix ];
|
||||
imports = [
|
||||
./options.nix ./plymouth.nix ./greeter.nix ./file-manager.nix
|
||||
./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.nix
|
||||
./gen-prune.nix # #128 system+HM generation prune (14d, keep ≥3 past)
|
||||
];
|
||||
|
||||
config = {
|
||||
# The safe half of distro branding: distroName flows into
|
||||
# /etc/os-release PRETTY_NAME, systemd-boot entry titles and the
|
||||
# ISO boot-menu label. distroId stays "nixos" on purpose — it feeds
|
||||
# DEFAULT_HOSTNAME and upstream isNixos checks (see roadmap).
|
||||
# Distro branding. distroName flows into /etc/os-release PRETTY_NAME,
|
||||
# systemd-boot entry titles and the ISO boot-menu label; distroId is the
|
||||
# machine-readable ID (DEFAULT_HOSTNAME, lsb DISTRIB_ID, CPE name).
|
||||
# distroId = "nomarchy" makes os-release honest — ID=nomarchy with
|
||||
# ID_LIKE=nixos, the standard derivative-distro lineage marker (cf.
|
||||
# Ubuntu→debian) — and is safe: switch-to-configuration builds its
|
||||
# "is this NixOS?" guard from the *configured* distroId (and /etc/NIXOS
|
||||
# still exists as the fallback), so rebuilds keep working; the nixos-*
|
||||
# CLI tools are package names, untouched. The one side effect is that
|
||||
# isNixos goes false and blanks the upstream nixos.org URLs, so we
|
||||
# restore them pointing at the project instead.
|
||||
system.nixos.distroName = lib.mkDefault "Nomarchy";
|
||||
system.nixos.distroId = lib.mkDefault "nomarchy";
|
||||
# No codename. Upstream hardcodes it into three fields —
|
||||
# VERSION = "${release} (${codeName})", PRETTY_NAME likewise, plus
|
||||
# VERSION_CODENAME (misc/version.nix) — so "Nomarchy 26.05 (Yarara)" read
|
||||
# as if Yarara were *our* release name. It is nixpkgs': ours is the number,
|
||||
# and the number is what tells you what you are running. Setting
|
||||
# `system.nixos.codeName = ""` is the wrong lever — it renders "26.05 ()" —
|
||||
# so override the assembled strings through the merge hook upstream
|
||||
# provides. The empty VERSION_CODENAME is deliberate and in keeping with
|
||||
# the file's own style (ANSI_COLOR="", IMAGE_ID="" …): the key cannot be
|
||||
# removed through `//`, and os-release(5) makes every field optional.
|
||||
# `nixos-version` still prints "(Yarara)" and is left alone on purpose —
|
||||
# that command reports the *nixpkgs* release this system was built from,
|
||||
# which is exactly when the codename is the honest answer.
|
||||
system.nixos.extraOSReleaseArgs = lib.mkDefault {
|
||||
HOME_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||||
DOCUMENTATION_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||||
SUPPORT_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||||
BUG_REPORT_URL = "https://git.bemagri.xyz/bernardo/Nomarchy/issues";
|
||||
VERSION = config.system.nixos.release;
|
||||
VERSION_CODENAME = "";
|
||||
PRETTY_NAME = "${config.system.nixos.distroName} ${config.system.nixos.release}";
|
||||
};
|
||||
system.nixos.extraLSBReleaseArgs = lib.mkDefault {
|
||||
LSB_VERSION = config.system.nixos.release;
|
||||
DISTRIB_CODENAME = "";
|
||||
DISTRIB_DESCRIPTION = "${config.system.nixos.distroName} ${config.system.nixos.release}";
|
||||
};
|
||||
|
||||
# MOTD on TTY/SSH login (the desktop auto-logs into Hyprland, so this
|
||||
# is mostly seen over SSH or on a bare console). Branded, and doubles
|
||||
# as a cheat sheet for the distro's own helpers.
|
||||
users.motd = lib.mkDefault ''
|
||||
# as a cheat sheet for the distro's own helpers. Fingerprint line only
|
||||
# when fprintd is enabled (no permanent nag on machines without a
|
||||
# reader); doctor is always on PATH via systemPackages.
|
||||
users.motd = lib.mkDefault (''
|
||||
|
||||
${distroName} — a NixOS desktop, themed from one JSON.
|
||||
|
||||
sys-update update inputs + rebuild the system
|
||||
home-update apply home/theme changes (no sudo)
|
||||
nomarchy-theme-sync apply <theme> switch the whole palette
|
||||
nomarchy-pull update flake inputs (nomarchy, nixpkgs, …)
|
||||
nomarchy-rebuild rebuild the system (current lock)
|
||||
nomarchy-home rebuild the desktop / Home Manager
|
||||
nomarchy-state-sync apply <theme> switch the whole palette
|
||||
nomarchy-doctor read-only health check
|
||||
SUPER+? keybindings cheatsheet
|
||||
'';
|
||||
SUPER+M → System › Firmware check LVFS firmware updates (fwupd)
|
||||
'' + lib.optionalString config.nomarchy.hardware.fingerprint.enable ''
|
||||
SUPER+M → System › Fingerprint enroll a finger (fprintd)
|
||||
'');
|
||||
|
||||
# Unfree allowed distro-wide: pragmatic-desktop territory (vendor
|
||||
# GPU/wifi drivers, firmware, fonts, …). The custom nixpkgs-config
|
||||
# type can't carry a nested mkDefault; disagree with
|
||||
# `nixpkgs.config = lib.mkForce { allowUnfree = false; }`.
|
||||
# `NIXPKGS_ALLOW_UNFREE` covers CLI `nix-shell` / `nix shell` /
|
||||
# `nix run` (those ignore the system `nixpkgs.config`); pure flake
|
||||
# eval still needs `--impure` when the env var is the only gate.
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
environment.variables.NIXPKGS_ALLOW_UNFREE = "1";
|
||||
|
||||
# ── One keyboard layout everywhere, incl. the LUKS prompt ────────
|
||||
# services.xserver.xkb.layout is the single source of truth for the
|
||||
@@ -69,6 +132,15 @@ in
|
||||
console.earlySetup = lib.mkDefault true;
|
||||
boot.initrd.systemd.enable = lib.mkDefault true;
|
||||
|
||||
# Nomarchy roots are BTRFS, not ZFS, so adopt the 26.11 default early and
|
||||
# silence the eval warning the old `true` default emits. mkDefault, so a
|
||||
# genuine ZFS-root downstream can still force it back on.
|
||||
boot.zfs.forceImportRoot = lib.mkDefault false;
|
||||
|
||||
# Magic SysRq Keys: safety net for Wayland lockups. Alt+SysRq+REISUB allows
|
||||
# safe reboot without data loss when the compositor hangs.
|
||||
boot.kernel.sysctl."kernel.sysrq" = lib.mkDefault 1;
|
||||
|
||||
# ── Wayland session: Hyprland ────────────────────────────────────
|
||||
# Installs the binary, registers the session, wires up
|
||||
# xdg-desktop-portal-hyprland. Configuration is Home Manager's job.
|
||||
@@ -79,22 +151,8 @@ in
|
||||
extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file pickers, etc.
|
||||
};
|
||||
|
||||
services.greetd = lib.mkIf cfg.greeter.enable {
|
||||
enable = lib.mkDefault true;
|
||||
settings = {
|
||||
default_session = {
|
||||
# start-hyprland is Hyprland 0.55's watchdog launcher; running
|
||||
# the bare binary makes every session print a warning.
|
||||
command = lib.mkDefault "${pkgs.tuigreet}/bin/tuigreet --time --remember --greeting 'Welcome to ${distroName}' --cmd start-hyprland";
|
||||
user = "greeter";
|
||||
};
|
||||
# Boot straight into the session once; logout → normal greeter.
|
||||
initial_session = lib.mkIf (cfg.greeter.autoLogin != null) {
|
||||
command = "start-hyprland";
|
||||
user = cfg.greeter.autoLogin;
|
||||
};
|
||||
};
|
||||
};
|
||||
# The greetd/tuigreet login screen lives in ./greeter.nix — themed
|
||||
# from the state JSON (console.colors + --theme) at system rebuild.
|
||||
|
||||
# ── Audio: Pipewire ──────────────────────────────────────────────
|
||||
security.rtkit.enable = lib.mkDefault cfg.audio.enable;
|
||||
@@ -105,6 +163,10 @@ in
|
||||
alsa.support32Bit = true;
|
||||
pulse.enable = true;
|
||||
wireplumber.enable = true;
|
||||
# Prefer dock/HDMI/USB sinks when present; fall back to built-in when
|
||||
# they disappear (BACKLOG #87). Rules: ./dock-audio-rules.nix.
|
||||
wireplumber.extraConfig."90-nomarchy-dock-audio" =
|
||||
import ./dock-audio-rules.nix;
|
||||
};
|
||||
|
||||
# ── Desktop services ─────────────────────────────────────────────
|
||||
@@ -113,6 +175,10 @@ in
|
||||
services.dbus.enable = lib.mkDefault true;
|
||||
services.upower.enable = lib.mkDefault true;
|
||||
|
||||
# Hardware security key (FIDO2/U2F/GPG) support. Without pcscd, tokens like
|
||||
# YubiKeys often silently fail in WebAuthn (browsers), SSH, and GPG.
|
||||
services.pcscd.enable = lib.mkDefault true;
|
||||
|
||||
# Firmware updates via LVFS. Ships the daemon + its metadata-refresh
|
||||
# timer only — it never flashes anything on its own; applying an update
|
||||
# is an explicit `fwupdmgr update`. On by default as desktop security
|
||||
@@ -121,7 +187,54 @@ in
|
||||
# `services.fwupd.enable = false`.
|
||||
services.fwupd.enable = lib.mkDefault true;
|
||||
|
||||
# Drive health monitoring (SMART).
|
||||
services.smartd = {
|
||||
enable = lib.mkDefault true;
|
||||
notifications.x11.enable = lib.mkDefault true;
|
||||
notifications.wall.enable = lib.mkDefault true;
|
||||
};
|
||||
|
||||
# Self-gate on the hardware, like every other conditional bit of the
|
||||
# distro. smartd's config is DEVICESCAN, and where no drive answers SMART
|
||||
# it exits **17** ("Unable to monitor any SMART enabled devices") — which
|
||||
# systemd records as a FAILED unit, nomarchy-doctor faithfully reports as
|
||||
# a failed system unit, and Waybar renders as a red health icon. That is
|
||||
# every QEMU guest (virtio exposes no SMART), most live USB sticks, and
|
||||
# some eMMC — so a plain VM install greeted the user with a health warning
|
||||
# about a daemon that had nothing to do (Bernardo, live ISO 2026-07-14).
|
||||
#
|
||||
# ExecCondition is the right lever, not SuccessExitStatus = 17: a failed
|
||||
# *condition* leaves the unit **inactive** and unfailed, while exit 17
|
||||
# from a machine that DOES have drives still fails loudly — which is the
|
||||
# entire reason the daemon is here. `smartctl --scan` prints nothing
|
||||
# exactly when smartd would find nothing, so it is the same question
|
||||
# asked before the daemon can fail it.
|
||||
systemd.services.smartd.serviceConfig.ExecCondition =
|
||||
lib.mkIf config.services.smartd.enable
|
||||
(lib.mkDefault "${pkgs.writeShellScript "smartd-any-smart-device" ''
|
||||
[ -n "$(${pkgs.smartmontools}/bin/smartctl --scan)" ]
|
||||
''}");
|
||||
# Core security: enable AppArmor to confine desktop apps and services.
|
||||
security.apparmor.enable = true;
|
||||
security.apparmor.killUnconfinedConfinables = false;
|
||||
|
||||
# Core stability: reboot automatically after 10s on a kernel panic
|
||||
# (prevents the system from hanging indefinitely on a black screen).
|
||||
boot.kernelParams = [ "panic=10" "oops=panic" ];
|
||||
|
||||
# Explicitly enable systembus-notify to resolve a mkDefault conflict
|
||||
# between earlyoom (true) and smartd (false).
|
||||
services.systembus-notify.enable = true;
|
||||
|
||||
# Enable I2C and DDC/CI by default for external monitor brightness control.
|
||||
nomarchy.hardware.i2c.ddcci = lib.mkDefault true;
|
||||
|
||||
networking.networkmanager.enable = lib.mkDefault true;
|
||||
# OpenVPN support for the VPN menu's import/connect flow (nomarchy-vpn).
|
||||
# WireGuard needs no plugin (NetworkManager imports wg .conf natively);
|
||||
# this adds the openvpn type so `nmcli connection import type openvpn` works.
|
||||
# mkDefault so a downstream can drop it to slim the closure.
|
||||
networking.networkmanager.plugins = lib.mkDefault [ pkgs.networkmanager-openvpn ];
|
||||
|
||||
# No double-unlock on hibernate. Locking the session before sleep is
|
||||
# right for suspend (resumes from RAM, no other gate), but an encrypted
|
||||
@@ -134,6 +247,13 @@ in
|
||||
# system unit hooked to the sleep targets: it locks on the RAM-resume
|
||||
# sleeps always, and on hibernate only when the disk is unencrypted (no
|
||||
# LUKS gate to rely on). Replaces hypridle's old before_sleep_cmd.
|
||||
#
|
||||
# #115 suspend-then-hibernate: the first phase is RAM-resume (lock),
|
||||
# then after HibernateDelaySec systemd enters pure hibernate. Without
|
||||
# an unlock before that second phase, an encrypted resume would demand
|
||||
# LUKS *and* still-locked hyprlock. Drop the session lock immediately
|
||||
# before encrypted hibernate — LUKS is the gate; unencrypted still
|
||||
# locks via the unit below on hibernate.target.
|
||||
systemd.services.nomarchy-lock-before-sleep =
|
||||
let
|
||||
encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
|
||||
@@ -152,6 +272,20 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.nomarchy-unlock-before-encrypted-hibernate =
|
||||
let encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
|
||||
in lib.mkIf encrypted {
|
||||
description = "Unlock session before encrypted hibernate (LUKS is the resume gate)";
|
||||
before = [ "systemd-hibernate.service" ];
|
||||
wantedBy = [ "hibernate.target" ];
|
||||
# After lock-before-sleep on the s2h RAM phase; before the image.
|
||||
after = [ "nomarchy-lock-before-sleep.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${config.systemd.package}/bin/loginctl unlock-sessions";
|
||||
};
|
||||
};
|
||||
|
||||
# zsh as the default login shell (the desktop's shell experience —
|
||||
# starship/bat/eza/zoxide — is configured home-side in shell.nix).
|
||||
# programs.zsh.enable wires /etc/zshrc, completion and /etc/shells;
|
||||
@@ -163,14 +297,21 @@ in
|
||||
# overrides this regardless.
|
||||
users.defaultUserShell = lib.mkOverride 500 pkgs.zsh;
|
||||
|
||||
# The in-flake state drives the toggle; mkDefault so a hand-set
|
||||
# nomarchy.system.bluetooth.enable in system.nix still pins it (the
|
||||
# greeter.autoLogin shape). mkIf, not a fallback expression, so an absent
|
||||
# key leaves the option default as the single source of `true`.
|
||||
nomarchy.system.bluetooth.enable =
|
||||
lib.mkIf (stateBluetooth != null) (lib.mkDefault stateBluetooth);
|
||||
|
||||
hardware.bluetooth.enable = lib.mkDefault cfg.bluetooth.enable;
|
||||
services.blueman.enable = lib.mkDefault cfg.bluetooth.enable;
|
||||
|
||||
# ── Foreign binaries: nix-ld ─────────────────────────────────────
|
||||
# An ld.so shim so dynamically-linked binaries not built for NixOS
|
||||
# (downloaded tools, language servers, pip/npm-installed ELFs, the
|
||||
# npx-fetched claude-code) run without manual patchelf. On by default —
|
||||
# a pragmatic-desktop expectation.
|
||||
# (downloaded tools, language servers, pip/npm-installed ELFs) run
|
||||
# without manual patchelf. On by default — a pragmatic-desktop
|
||||
# expectation.
|
||||
programs.nix-ld.enable = lib.mkDefault true;
|
||||
|
||||
# ── Firmware ─────────────────────────────────────────────────────
|
||||
@@ -231,10 +372,17 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# Background filesystem health checks. Scans all BTRFS filesystems to detect
|
||||
# and (if RAID/dup) correct bitrot before it propagates.
|
||||
services.btrfs.autoScrub = lib.mkIf ((config.fileSystems."/".fsType or "") == "btrfs") {
|
||||
enable = lib.mkDefault true;
|
||||
interval = lib.mkDefault "monthly";
|
||||
};
|
||||
|
||||
# ── Fonts ────────────────────────────────────────────────────────
|
||||
# The ten most popular Nerd Fonts ship by default, so any of them
|
||||
# can be named in the theme state's fonts.mono and actually resolve
|
||||
# (nomarchy-theme-sync warns when a configured font is missing).
|
||||
# (nomarchy-state-sync warns when a configured font is missing).
|
||||
fonts = {
|
||||
packages = with pkgs; [
|
||||
nerd-fonts.jetbrains-mono
|
||||
@@ -249,6 +397,10 @@ in
|
||||
# (every weight × variant) — too heavy for the ISO and closures.
|
||||
nerd-fonts.mononoki
|
||||
nerd-fonts.inconsolata
|
||||
# GeistMono (57 MB) — the Boreal theme's mono face; modern and
|
||||
# geometric, unlike the ten defaults. Cheap enough for the ISO
|
||||
# (Iosevka above was rejected at 1.1 GB; this is a compact family).
|
||||
nerd-fonts.geist-mono
|
||||
inter
|
||||
noto-fonts
|
||||
noto-fonts-color-emoji
|
||||
@@ -267,31 +419,15 @@ in
|
||||
|
||||
# ── Essential packages ───────────────────────────────────────────
|
||||
environment.systemPackages = with pkgs; [
|
||||
nomarchy-theme-sync # provided by overlays.default
|
||||
nomarchy-state-sync # provided by overlays.default
|
||||
nomarchy-doctor # read-only health check (System › Doctor)
|
||||
nomarchy-detect-hw # post-install hardware re-probe (HARDWARE.md §8)
|
||||
|
||||
# Friendly wrappers for the two rebuild paths (README §3). Run as
|
||||
# your user: `nix flake update` must NOT run as root (libgit2
|
||||
# refuses the user-owned flake repo) — sudo happens inside, only
|
||||
# for the system switch.
|
||||
(pkgs.writeShellScriptBin "sys-update" ''
|
||||
set -e
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "sys-update: run as your normal user (it sudos the rebuild itself)" >&2
|
||||
exit 1
|
||||
fi
|
||||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||
echo "sys-update: updating flake inputs in $flake"
|
||||
nix flake update --flake "$flake"
|
||||
if command -v nixos-rebuild-snap >/dev/null 2>&1; then
|
||||
sudo nixos-rebuild-snap "$@" # BTRFS snapshot first
|
||||
else
|
||||
sudo nixos-rebuild switch --flake "$flake#default" "$@"
|
||||
fi
|
||||
'')
|
||||
(pkgs.writeShellScriptBin "home-update" ''
|
||||
set -e
|
||||
exec home-manager switch --flake "''${NOMARCHY_PATH:-$HOME/.nomarchy}" "$@"
|
||||
'')
|
||||
# Day-to-day lifecycle (README §3): nomarchy-pull / -rebuild / -home
|
||||
# (+ legacy sys-update / sys-rebuild / home-update). Defined once in
|
||||
# pkgs/nomarchy-lifecycle; also installed via HM so a home switch can
|
||||
# refresh a stale system-package pull script.
|
||||
pkgs.nomarchy-lifecycle
|
||||
|
||||
git
|
||||
vim
|
||||
@@ -304,6 +440,7 @@ in
|
||||
wl-clipboard
|
||||
grim
|
||||
slurp
|
||||
hyprpicker
|
||||
] ++ lib.optional (cfg.snapper.enable && (config.fileSystems."/".fsType or "") == "btrfs")
|
||||
# Snapshot, then rebuild — rollback material for system changes
|
||||
# (theme changes don't need it; HM generations already roll back).
|
||||
@@ -312,16 +449,84 @@ in
|
||||
echo "This script must be run as root (use sudo)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Prefer the path nomarchy-rebuild passes through sudo; fall back
|
||||
# for hand invocations.
|
||||
flake="''${NOMARCHY_PATH:-/etc/nixos}"
|
||||
echo "Creating pre-rebuild snapshot..."
|
||||
${pkgs.snapper}/bin/snapper -c root create \
|
||||
-d "Pre-rebuild $(date +'%Y-%m-%d %H:%M:%S')" \
|
||||
--cleanup-algorithm number
|
||||
echo "Rebuilding..."
|
||||
nixos-rebuild switch --flake /etc/nixos#default "$@"
|
||||
echo "Rebuilding $flake#default ..."
|
||||
nixos-rebuild switch --flake "$flake#default" "$@"
|
||||
'')
|
||||
# The desktop snapshot manager (browse / diff / restore / rollback over
|
||||
# snapper, elevating via polkit) — what `nomarchy-menu snapshot` launches.
|
||||
++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant;
|
||||
# snapper, elevating via polkit) — the primary `nomarchy-menu snapshot`
|
||||
# target. The "2.2 segfault" is unprivileged-only (libbtrfsutil
|
||||
# unprivileged subvolume iteration, btrfs-progs 6.17.1, fixed upstream
|
||||
# after); the pkexec launcher runs it as root, where it works —
|
||||
# VM-proven, guarded by checks.snapshot-gui.
|
||||
++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant
|
||||
# Keyboard-driven snapper browser/restore — the menu's fallback when the
|
||||
# GUI is absent, and handy over SSH. Runs as root (snapper is root-only
|
||||
# here; the menu opens it in a terminal via sudo, one password prompt),
|
||||
# fzf to pick, with browse/diff (read-only) and typed-`yes` confirmation
|
||||
# before any write.
|
||||
++ lib.optional cfg.snapper.enable (pkgs.writeShellApplication {
|
||||
name = "nomarchy-snapshots";
|
||||
runtimeInputs = with pkgs; [ snapper fzf gawk gnugrep less coreutils systemd ];
|
||||
text = ''
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "nomarchy-snapshots must run as root (snapper needs it) — use sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t configs < <(snapper list-configs | awk 'NR>2 {print $1}' | grep .)
|
||||
if [ "''${#configs[@]}" -eq 0 ]; then
|
||||
echo "No snapper configs found." >&2; exit 1
|
||||
elif [ "''${#configs[@]}" -eq 1 ]; then
|
||||
config="''${configs[0]}"
|
||||
else
|
||||
config=$(printf '%s\n' "''${configs[@]}" | fzf --prompt="snapper config> ") || exit 0
|
||||
fi
|
||||
|
||||
while :; do
|
||||
snap=$(snapper -c "$config" list \
|
||||
| fzf --header-lines=2 --prompt="[$config] pick a snapshot (Esc quits)> ") || exit 0
|
||||
num=$(awk '{print $1}' <<<"$snap")
|
||||
case "$num" in ""|*[!0-9]*) continue ;; esac
|
||||
|
||||
action=$(printf '%s\n' \
|
||||
"Browse changes since #$num (read-only)" \
|
||||
"Restore changed files to #$num (undochange)" \
|
||||
"Roll the system back to #$num (reboot)" \
|
||||
"↩ Back to the snapshot list" \
|
||||
| fzf --prompt="snapshot #$num> ") || exit 0
|
||||
|
||||
case "$action" in
|
||||
Browse*)
|
||||
snapper -c "$config" status "$num..0" | less -R || true ;;
|
||||
Restore*)
|
||||
read -rp "Revert files in '$config' to snapshot #$num? Type yes to confirm: " ans || continue
|
||||
if [ "$ans" = yes ]; then
|
||||
snapper -c "$config" undochange "$num..0"
|
||||
echo "Files restored. Press enter."; read -r _ || true
|
||||
fi ;;
|
||||
Roll*)
|
||||
if [ "$config" != root ]; then
|
||||
echo "Rollback applies to the 'root' config only; use Restore for '$config'. Press enter."
|
||||
read -r _ || true
|
||||
else
|
||||
read -rp "Roll the SYSTEM back to #$num and REBOOT now? Type yes to confirm: " ans || continue
|
||||
if [ "$ans" = yes ]; then
|
||||
snapper -c root rollback "$num"
|
||||
echo "Rolled back — rebooting…"; systemctl reboot
|
||||
fi
|
||||
fi ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
done
|
||||
'';
|
||||
});
|
||||
|
||||
# Don't let boot entries fill the ESP over the years.
|
||||
boot.loader.systemd-boot.configurationLimit = lib.mkDefault 10;
|
||||
@@ -331,22 +536,30 @@ in
|
||||
# users without root. Harmless baseline even if the OSD is disabled.
|
||||
services.udev.packages = [ pkgs.swayosd ];
|
||||
|
||||
# Mic-mute LED sysfs nodes (ThinkPad platform::micmute, HDA *::micmute)
|
||||
# default to root-only. PipeWire mute does not drive the kernel's
|
||||
# audio-micmute trigger, so nomarchy-mic-mute writes brightness itself.
|
||||
# Group `video` matches the backlight udev pattern (swayosd) and the
|
||||
# template login user's extraGroups.
|
||||
services.udev.extraRules = lib.mkAfter ''
|
||||
ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*micmute*", \
|
||||
RUN+="${pkgs.coreutils}/bin/chgrp video /sys/class/leds/%k/brightness /sys/class/leds/%k/trigger", \
|
||||
RUN+="${pkgs.coreutils}/bin/chmod g+w /sys/class/leds/%k/brightness /sys/class/leds/%k/trigger"
|
||||
'';
|
||||
|
||||
# ── Nix itself ───────────────────────────────────────────────────
|
||||
nix = {
|
||||
settings = {
|
||||
experimental-features = [ "nix-command" "flakes" ];
|
||||
auto-optimise-store = lib.mkDefault true;
|
||||
# The downstream flake (~/.nomarchy) is meant to be a live working
|
||||
# tree: nomarchy-theme-sync rewrites theme-state.json on every
|
||||
# tree: nomarchy-state-sync rewrites state.json on every
|
||||
# switch (and you needn't commit each tweak), so the "Git tree is
|
||||
# dirty" warning fires on every rebuild and is pure noise here.
|
||||
warn-dirty = lib.mkDefault false;
|
||||
};
|
||||
gc = {
|
||||
automatic = lib.mkDefault true;
|
||||
dates = lib.mkDefault "weekly";
|
||||
options = lib.mkDefault "--delete-older-than 14d";
|
||||
};
|
||||
# Generation age+floor policy + store GC: modules/nixos/gen-prune.nix (#128).
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
38
modules/nixos/dock-audio-rules.nix
Normal file
38
modules/nixos/dock-audio-rules.nix
Normal file
@@ -0,0 +1,38 @@
|
||||
# WirePlumber 0.5 ALSA rules: prefer dock / external sinks over built-in
|
||||
# analog when they appear (BACKLOG #87). Higher priority.session wins as
|
||||
# the default node; when HDMI/USB goes away, the next-highest (usually
|
||||
# the laptop speakers/headphones) becomes default again.
|
||||
#
|
||||
# Pure attrset so checks.dock-audio can unit-test the contract without a
|
||||
# full PipeWire stack. Consumed by modules/nixos/default.nix.
|
||||
#
|
||||
# Priorities stay ≤1500 (WirePlumber docs: sinks default ~600–1000; going
|
||||
# much higher can outrank streams / BT in surprising ways).
|
||||
{
|
||||
"monitor.alsa.rules" = [
|
||||
{
|
||||
# HDMI / DisplayPort monitor audio (names vary: .hdmi-stereo,
|
||||
# .hdmi-surround, HiFi__HDMI1__sink, …).
|
||||
matches = [
|
||||
{ "node.name" = "~alsa_output\\..*\\.hdmi-.*"; }
|
||||
{ "node.name" = "~alsa_output\\..*HDMI.*"; }
|
||||
{ "node.name" = "~alsa_output\\..*\\.DisplayPort.*"; }
|
||||
{ "node.name" = "~alsa_output\\..*\\.dp-.*"; }
|
||||
];
|
||||
actions."update-props" = {
|
||||
"priority.driver" = 1100;
|
||||
"priority.session" = 1100;
|
||||
};
|
||||
}
|
||||
{
|
||||
# USB dock / dongle audio (Thunderbolt dock, USB-C audio, …).
|
||||
matches = [
|
||||
{ "node.name" = "~alsa_output\\.usb-.*"; }
|
||||
];
|
||||
actions."update-props" = {
|
||||
"priority.driver" = 1050;
|
||||
"priority.session" = 1050;
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -25,6 +25,7 @@ in
|
||||
services.udisks2.enable = lib.mkDefault true; # mount/unmount removable media
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
file-roller # GUI archive manager (backend for thunar-archive-plugin)
|
||||
ffmpegthumbnailer # video thumbnails (Thunar + yazi)
|
||||
libgsf # ODF thumbnails
|
||||
poppler-utils # PDF thumbnails / pdftoppm (yazi PDF preview too)
|
||||
|
||||
41
modules/nixos/gen-prune.nix
Normal file
41
modules/nixos/gen-prune.nix
Normal file
@@ -0,0 +1,41 @@
|
||||
# #128 — weekly generation prune: system + Home Manager profiles.
|
||||
# Policy: drop gens older than 14 days only when they are beyond the
|
||||
# three most recent *past* generations (current always kept).
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
config = {
|
||||
environment.systemPackages = [ pkgs.nomarchy-gen-prune ];
|
||||
|
||||
# Stock nix.gc --delete-older-than has no keep-N floor and would fight
|
||||
# this policy. Keep weekly store GC for dead paths only; generation
|
||||
# selection is nomarchy-gen-prune's job.
|
||||
nix.gc = {
|
||||
automatic = lib.mkDefault true;
|
||||
dates = lib.mkDefault "weekly";
|
||||
# Empty options → collect unreferenced store paths only (no age-based
|
||||
# profile generation wipe).
|
||||
options = lib.mkDefault "";
|
||||
};
|
||||
|
||||
systemd.services.nomarchy-gen-prune = {
|
||||
description = "Prune old NixOS and Home Manager generations (14d, keep ≥3 past)";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune";
|
||||
};
|
||||
# After the stock store GC timer if both fire weekly.
|
||||
after = [ "nix-gc.service" ];
|
||||
};
|
||||
|
||||
systemd.timers.nomarchy-gen-prune = {
|
||||
description = "Weekly Nix generation prune (Nomarchy #128)";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = "weekly";
|
||||
Persistent = true;
|
||||
RandomizedDelaySec = "1h";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
133
modules/nixos/greeter.nix
Normal file
133
modules/nixos/greeter.nix
Normal file
@@ -0,0 +1,133 @@
|
||||
# Greeter — greetd/tuigreet, themed from the same state.json that
|
||||
# drives the desktop (nomarchy.system.stateFile; the Plymouth model:
|
||||
# baked at SYSTEM rebuild, so it follows the theme as of the last
|
||||
# sys-update, not the last instant apply).
|
||||
#
|
||||
# tuigreet draws on the virtual console with the 16 ANSI slots, so the
|
||||
# theming is two-part:
|
||||
# 1. console.colors — the VT palette becomes the theme's ansi[] hexes
|
||||
# (which also themes raw ttys and the LUKS passphrase prompt: the
|
||||
# same JSON reaches every pre-session surface).
|
||||
# 2. --theme — tuigreet components on NAMED slots (its parser is
|
||||
# ratatui Color::from_str; names map to the standard indexes, e.g.
|
||||
# blue=4, gray=7, white=15, so the palette above hands them the
|
||||
# theme's colors). ANSI "black" stays dark even in light themes —
|
||||
# the greeter reads terminal-dark there, the same convention every
|
||||
# terminal applies to ANSI colors.
|
||||
#
|
||||
# Auto-login is in-flake state like the rest (settings.greeter.autoLogin,
|
||||
# written by System › Auto-login via nomarchy-autologin below), NOT a baked
|
||||
# line in system.nix: a hand-set `nomarchy.system.greeter.autoLogin` outranks
|
||||
# the state default, which would leave the menu toggle flipping JSON that
|
||||
# nothing reads. The installer therefore seeds the STATE on LUKS machines and
|
||||
# the template keeps its example commented (templates/downstream/system.nix).
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy.system;
|
||||
distroName = config.system.nixos.distroName;
|
||||
|
||||
sync = lib.getExe pkgs.nomarchy-state-sync;
|
||||
|
||||
# Menu/CLI toggle, same shape as nomarchy-autotimezone: runs as the normal
|
||||
# user (it owns the flake checkout + writes the state), sudos only the
|
||||
# system switch. greetd's initial_session is baked at system rebuild, so
|
||||
# there is nothing to apply live — the next boot is the observable change.
|
||||
nomarchy-autologin = pkgs.writeShellScriptBin "nomarchy-autologin" ''
|
||||
set -e
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "nomarchy-autologin: run as your normal user (it sudos the rebuild itself)" >&2
|
||||
exit 1
|
||||
fi
|
||||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||
|
||||
cur=$(${sync} get settings.greeter.autoLogin 2>/dev/null) || cur=null
|
||||
case "''${1:-toggle}" in
|
||||
on) new="\"$USER\"" ;;
|
||||
off) new=null ;;
|
||||
toggle) case "$cur" in null|""|None) new="\"$USER\"" ;; *) new=null ;; esac ;;
|
||||
status) echo "$cur"; exit 0 ;;
|
||||
*) echo "usage: nomarchy-autologin [toggle|on|off|status]" >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
${sync} --quiet set settings.greeter.autoLogin "$new" --no-switch
|
||||
|
||||
notify-send "Auto-login" "Rebuilding the system…" 2>/dev/null || true
|
||||
sudo nixos-rebuild switch --flake "$flake#default"
|
||||
|
||||
if [ "$new" = null ]; then
|
||||
notify-send "Auto-login off" "The greeter asks who you are on the next boot." 2>/dev/null || true
|
||||
else
|
||||
notify-send "Auto-login on" "Next boot goes straight to the desktop." 2>/dev/null || true
|
||||
fi
|
||||
'';
|
||||
|
||||
# Fails closed with an actionable message via state-read.nix, like
|
||||
# every other stateFile consumer — a raw fromJSON here would bury a bad
|
||||
# state file under a Nix stack pointing at greeter.nix.
|
||||
state =
|
||||
if cfg.stateFile != null
|
||||
then import ../state-read.nix { inherit lib; } cfg.stateFile
|
||||
else { };
|
||||
|
||||
# The auto-login user from the state, or null. Read here via the state file
|
||||
# — NOT `config.nomarchy.settings`, which exists only on the Home Manager
|
||||
# side: on NixOS that attribute is missing, and `or null` swallows the
|
||||
# error, so the old default silently evaluated to null on every machine.
|
||||
stateAutoLogin =
|
||||
let v = (state.settings or { }).greeter.autoLogin or null;
|
||||
in if builtins.isString v && v != "" then v else null;
|
||||
|
||||
# A sparse/hand-rolled state without a proper ansi block just skips the
|
||||
# theming (stock tuigreet grey) — never an eval error.
|
||||
ansi = state.ansi or [ ];
|
||||
themed = builtins.isList ansi && builtins.length ansi == 16;
|
||||
|
||||
tuigreetTheme = lib.concatStringsSep ";" [
|
||||
"container=black" # ansi[0] — the theme's terminal background
|
||||
"border=blue" # ansi[4] — the accent family in every shipped palette
|
||||
"title=cyan"
|
||||
"greet=cyan"
|
||||
"prompt=green"
|
||||
"input=white" # ansi[15] — bright foreground
|
||||
"action=blue"
|
||||
"button=yellow"
|
||||
"time=cyan"
|
||||
"text=gray" # ansi[7] — muted foreground
|
||||
];
|
||||
in
|
||||
{
|
||||
config = {
|
||||
# Shipped unconditionally so the menu can turn auto-login back ON while
|
||||
# it's off — the same reason nomarchy-autotimezone is unconditional.
|
||||
environment.systemPackages = [ nomarchy-autologin ];
|
||||
|
||||
# Track the in-flake flag; mkDefault so a hand-set
|
||||
# nomarchy.system.greeter.autoLogin in system.nix still wins (the
|
||||
# autoTimezone pattern).
|
||||
nomarchy.system.greeter.autoLogin = lib.mkDefault stateAutoLogin;
|
||||
|
||||
# VT palette from the theme (RRGGBB, no #; lands as vt.default_* kernel
|
||||
# params). mkDefault so a downstream console.colors wins.
|
||||
console.colors = lib.mkIf themed (lib.mkDefault (map (lib.removePrefix "#") ansi));
|
||||
|
||||
services.greetd = lib.mkIf cfg.greeter.enable {
|
||||
enable = lib.mkDefault true;
|
||||
settings = {
|
||||
default_session = {
|
||||
# start-hyprland is Hyprland 0.55's watchdog launcher; running
|
||||
# the bare binary makes every session print a warning.
|
||||
command = lib.mkDefault ("${pkgs.tuigreet}/bin/tuigreet --time --remember --greeting 'Welcome to ${distroName}'"
|
||||
+ lib.optionalString themed " --theme '${tuigreetTheme}'"
|
||||
+ " --cmd start-hyprland");
|
||||
user = "greeter";
|
||||
};
|
||||
# Boot straight into the session once; logout → normal greeter.
|
||||
initial_session = lib.mkIf (cfg.greeter.autoLogin != null) {
|
||||
command = "start-hyprland";
|
||||
user = cfg.greeter.autoLogin;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
370
modules/nixos/hardware.nix
Normal file
370
modules/nixos/hardware.nix
Normal file
@@ -0,0 +1,370 @@
|
||||
# Hardware enablement beyond nixos-hardware.
|
||||
#
|
||||
# The nixos-hardware "common-*" profiles the installer selects cover the
|
||||
# BASICS (microcode, the Intel/AMD VA-API media stack, weekly fstrim), and
|
||||
# power.nix adds thermald + power-profiles-daemon. This module fills the GAP
|
||||
# above those: broadly-beneficial bits that default ON when the installer
|
||||
# detects the vendor (opt-OUT), and heavier/experimental bits behind opt-IN
|
||||
# toggles. The installer's hardware-db.sh probes what's present and writes the
|
||||
# matching nomarchy.hardware.* into the generated system.nix.
|
||||
#
|
||||
# Audited against the commons so we don't double-set: we add GuC/HuC, the
|
||||
# amd-pstate governor, the AMD VA-API env, GPU-compute runtimes, fprintd, and
|
||||
# the NPU driver — none of which the commons turn on.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy.hardware;
|
||||
# Fingerprint PAM can follow state.json (menu toggle → next
|
||||
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55). Missing or
|
||||
# invalid JSON fails closed (state-read.nix) instead of a raw stack.
|
||||
hwState =
|
||||
if config.nomarchy.system.stateFile != null
|
||||
then import ../state-read.nix { inherit lib; } config.nomarchy.system.stateFile
|
||||
else { };
|
||||
pamFromState = (hwState.settings or { }).fingerprint.pam or false;
|
||||
|
||||
sync = lib.getExe pkgs.nomarchy-state-sync;
|
||||
|
||||
# The single fingerprint on/off switch (System › Fingerprint). One state key
|
||||
# for one user-facing decision — it drives login/sudo PAM here AND the
|
||||
# hyprlock unlock in modules/home/idle.nix, which reads the same
|
||||
# settings.fingerprint.pam. Two rebuilds, because the two live in different
|
||||
# configurations: sudo the system switch (PAM), then a home switch
|
||||
# (hyprlock). Same user-owns-the-flake shape as nomarchy-autotimezone.
|
||||
#
|
||||
# This does NOT decide whether login prompts at all — auto-login skips the
|
||||
# greeter entirely, so "fingerprint on" adds the finger to whatever prompts
|
||||
# actually happen (sudo, lock screen, and the greeter only when auto-login
|
||||
# is off). See nomarchy-autologin in ./greeter.nix.
|
||||
nomarchy-fingerprint = pkgs.writeShellScriptBin "nomarchy-fingerprint" ''
|
||||
set -e
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "nomarchy-fingerprint: run as your normal user (it sudos the rebuild itself)" >&2
|
||||
exit 1
|
||||
fi
|
||||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||
|
||||
cur=$(${sync} get settings.fingerprint.pam 2>/dev/null) || cur=false
|
||||
case "''${1:-toggle}" in
|
||||
on) new=true ;;
|
||||
off) new=false ;;
|
||||
toggle) case "$cur" in true|True) new=false ;; *) new=true ;; esac ;;
|
||||
status) echo "$cur"; exit 0 ;;
|
||||
*) echo "usage: nomarchy-fingerprint [toggle|on|off|status]" >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
# Turning it ON with no enrolled finger would advertise a scan that cannot
|
||||
# succeed on every prompt — refuse instead, and say where to go.
|
||||
if [ "$new" = true ] \
|
||||
&& fprintd-list "$USER" 2>/dev/null | grep -qiE 'no fingers enrolled|No devices available'; then
|
||||
notify-send "Fingerprint" "Enroll a finger first (System › Fingerprint › Enroll)." 2>/dev/null || true
|
||||
echo "nomarchy-fingerprint: no finger enrolled — run fprintd-enroll first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${sync} --quiet set settings.fingerprint.pam "$new" --no-switch
|
||||
|
||||
notify-send "Fingerprint" "Rebuilding…" 2>/dev/null || true
|
||||
sudo nixos-rebuild switch --flake "$flake#default"
|
||||
home-manager switch --flake "$flake"
|
||||
|
||||
if [ "$new" = true ]; then
|
||||
notify-send "Fingerprint on" "Password or finger — at sudo, the lock screen, and the greeter." 2>/dev/null || true
|
||||
else
|
||||
notify-send "Fingerprint off" "Password only. Enrolled fingers are kept." 2>/dev/null || true
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.nomarchy.hardware = {
|
||||
intel = {
|
||||
enable = lib.mkEnableOption ''
|
||||
Intel CPU/GPU enablement (the installer turns this on when it detects
|
||||
an Intel CPU or GPU). Complements nixos-hardware's common-gpu-intel'';
|
||||
|
||||
guc = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = cfg.intel.enable;
|
||||
defaultText = lib.literalExpression "config.nomarchy.hardware.intel.enable";
|
||||
description = ''
|
||||
Load the GPU's GuC/HuC firmware via the i915 param
|
||||
(i915.enable_guc=3) — better power management and HuC-accelerated
|
||||
media. On by default with intel.enable. NOTE: this is the *i915*
|
||||
driver's param; the newer `xe` driver (Lunar Lake / Battlemage /
|
||||
Panther Lake and other recent Xe GPUs) enables GuC by default and
|
||||
ignores it, so the installer turns this off on xe-driver hardware.
|
||||
'';
|
||||
};
|
||||
|
||||
computeRuntime = lib.mkEnableOption ''
|
||||
Intel GPU compute: the OpenCL / Level Zero (intel-compute-runtime) and
|
||||
oneVPL (vpl-gpu-rt) runtimes for GPU compute and transcode. Opt-in (a
|
||||
few hundred MB) — the Intel counterpart to AMD ROCm'';
|
||||
};
|
||||
|
||||
amd = {
|
||||
enable = lib.mkEnableOption ''
|
||||
AMD CPU/GPU enablement (installer-set on an AMD CPU or GPU).
|
||||
Complements nixos-hardware's common-cpu-amd / common-gpu-amd'';
|
||||
|
||||
pstate = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = cfg.amd.enable;
|
||||
defaultText = lib.literalExpression "config.nomarchy.hardware.amd.enable";
|
||||
description = ''
|
||||
Use the amd-pstate EPP driver (amd_pstate=active) — the modern Zen
|
||||
power/perf governor that power-profiles-daemon drives per profile.
|
||||
On by default with amd.enable (broadly beneficial on Zen 2+).
|
||||
'';
|
||||
};
|
||||
|
||||
vaapi = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = cfg.amd.enable;
|
||||
defaultText = lib.literalExpression "config.nomarchy.hardware.amd.enable";
|
||||
description = ''
|
||||
Point VA-API at mesa's radeonsi (LIBVA_DRIVER_NAME=radeonsi) for
|
||||
hardware video decode/encode. On by default with amd.enable.
|
||||
'';
|
||||
};
|
||||
|
||||
rocm = {
|
||||
enable = lib.mkEnableOption ''
|
||||
AMD ROCm: the HIP / OpenCL GPU-compute stack (multi-GB closure).
|
||||
Opt-in — unlocks GPU PyTorch / Ollama on Radeon'';
|
||||
|
||||
gfxOverride = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "11.0.0";
|
||||
description = ''
|
||||
HSA_OVERRIDE_GFX_VERSION for GPUs ROCm doesn't officially list
|
||||
(e.g. an RDNA3 780M iGPU, gfx1103, needs "11.0.0"). Empty = no
|
||||
override.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
fingerprint = {
|
||||
enable = lib.mkEnableOption ''
|
||||
a fingerprint reader via fprintd (the installer turns this on when it
|
||||
detects a known reader). Enroll with `fprintd-enroll`'';
|
||||
|
||||
pam = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = pamFromState;
|
||||
defaultText = lib.literalExpression
|
||||
"(settings.fingerprint.pam from state.json) or false";
|
||||
description = ''
|
||||
Use the fingerprint for login and sudo (PAM). Opt-in — password-only
|
||||
stays the default for the cautious; enroll a finger first. Defaults
|
||||
from state.json `settings.fingerprint.pam` (System › Fingerprint
|
||||
menu) when set; otherwise false.
|
||||
'';
|
||||
};
|
||||
|
||||
parallel = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
With fingerprint PAM on, accept the password OR a fingerprint at the
|
||||
same prompt (type or touch, whichever comes first) instead of stock
|
||||
pam_fprintd's sequential wait-for-the-reader-then-password. Uses the
|
||||
pam-fprint-grosshack module (an fprintd fork — source-reviewed; every
|
||||
failure path falls through to the normal password rule, so password
|
||||
login can never be locked out by it). Set false for the stock
|
||||
sequential behavior.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
npu.enable = lib.mkEnableOption ''
|
||||
the on-die NPU (AI accelerator) kernel driver — amdxdna on AMD (Ryzen
|
||||
AI), intel_vpu on Intel (Core Ultra and newer). Opt-in and experimental:
|
||||
this loads the in-kernel driver only; the userspace runtime (AMD XRT /
|
||||
oneAPI Level Zero NPU) is yours to add. Needs a recent kernel (see
|
||||
latestKernel)'';
|
||||
|
||||
latestKernel = lib.mkEnableOption ''
|
||||
the latest mainline kernel (pkgs.linuxPackages_latest) instead of the
|
||||
distro default — for very new hardware whose drivers (a fresh NPU, the
|
||||
`xe` GPU driver, new-platform enablement) only landed recently. Off by
|
||||
default; the default kernel already carries amd-pstate and amdxdna (6.14+)'';
|
||||
|
||||
camera = {
|
||||
hideIrSensor = lib.mkEnableOption ''
|
||||
hiding a dual-sensor webcam's IR (face-unlock) node from PipeWire so
|
||||
apps only ever see the colour camera. Such modules (common on recent
|
||||
ThinkPads) expose the IR sensor as a SECOND, identically-named
|
||||
"Integrated Camera"; selecting it gives a dark, 8-bit-greyscale image —
|
||||
the classic "my webcam is dark" symptom. The installer turns this on
|
||||
when it detects a paired RGB+IR webcam. Only the PipeWire node is
|
||||
disabled — the kernel /dev/video* device stays open, so face-unlock
|
||||
(Howdy) still works. Acts on the V4L2 path only; the libcamera monitor
|
||||
is left untouched, so an external camera you plug in is never affected.
|
||||
See irMatch'';
|
||||
|
||||
irMatch = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "~.*(Integrated I|IR Camera|Infrared).*";
|
||||
description = ''
|
||||
WirePlumber regex (matched against a V4L2 node's api.v4l2.cap.card)
|
||||
selecting the IR sensor to hide. The default catches the common
|
||||
dual-sensor naming ("… Integrated I", "IR Camera", "Infrared"); set it
|
||||
to your camera's IR card name if it differs — find it with
|
||||
`v4l2-ctl --list-devices` or `wpctl inspect`. Only consulted when
|
||||
camera.hideIrSensor is on.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
i2c = {
|
||||
enable = lib.mkEnableOption ''
|
||||
I2C devices support. Enables access to /dev/i2c-* (useful for RGB
|
||||
controllers, sensors, and DDC/CI monitor control)'';
|
||||
|
||||
ddcci = lib.mkEnableOption ''
|
||||
the ddcci-driver kernel module to expose external monitors as standard
|
||||
backlight devices via DDC/CI. This allows brightness keys and swayosd
|
||||
to natively control external displays'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkMerge [
|
||||
# ── Intel ──────────────────────────────────────────────────────────
|
||||
(lib.mkIf cfg.intel.guc {
|
||||
boot.kernelParams = [ "i915.enable_guc=3" ];
|
||||
})
|
||||
(lib.mkIf cfg.intel.computeRuntime {
|
||||
hardware.graphics.extraPackages = with pkgs; [ intel-compute-runtime vpl-gpu-rt ];
|
||||
})
|
||||
|
||||
# ── AMD ────────────────────────────────────────────────────────────
|
||||
(lib.mkIf cfg.amd.pstate {
|
||||
boot.kernelParams = [ "amd_pstate=active" ];
|
||||
})
|
||||
(lib.mkIf cfg.amd.vaapi {
|
||||
# radeonsi itself comes from mesa (via common-gpu-amd); this just steers
|
||||
# libva at it. mkDefault so a hand-set value or another module wins.
|
||||
environment.sessionVariables.LIBVA_DRIVER_NAME = lib.mkDefault "radeonsi";
|
||||
hardware.graphics.extraPackages = [ pkgs.libva ];
|
||||
})
|
||||
(lib.mkIf cfg.amd.rocm.enable {
|
||||
hardware.graphics.extraPackages = [ pkgs.rocmPackages.clr pkgs.rocmPackages.clr.icd ];
|
||||
environment.sessionVariables = lib.optionalAttrs (cfg.amd.rocm.gfxOverride != "") {
|
||||
HSA_OVERRIDE_GFX_VERSION = cfg.amd.rocm.gfxOverride;
|
||||
};
|
||||
})
|
||||
|
||||
# ── Fingerprint ────────────────────────────────────────────────────
|
||||
# NixOS defaults security.pam.services.*.fprintAuth to
|
||||
# services.fprintd.enable — so turning on fprintd alone would enable
|
||||
# finger auth for login/sudo/greetd/passwd/… even when the user (or
|
||||
# the migration template) left fingerprint.pam commented off. Force
|
||||
# every interactive service we care about to follow our opt-in flag.
|
||||
(lib.mkIf cfg.fingerprint.enable {
|
||||
services.fprintd.enable = true;
|
||||
# Ships whenever a reader exists, regardless of the pam flag: the
|
||||
# toggle's whole job is to turn the flag back on while it's off.
|
||||
environment.systemPackages = [ nomarchy-fingerprint ];
|
||||
security.pam.services = lib.genAttrs [
|
||||
"login" "sudo" "su" "greetd" "hyprlock" "sshd"
|
||||
"passwd" "chsh" "chfn" "chpasswd"
|
||||
"polkit-1" "swaylock"
|
||||
"groupadd" "groupdel" "groupmod" "groupmems"
|
||||
] (_: {
|
||||
fprintAuth = cfg.fingerprint.pam;
|
||||
} // lib.optionalAttrs (cfg.fingerprint.pam && cfg.fingerprint.parallel) {
|
||||
# Parallel mode: same rule slot as stock fprintd (so ordering —
|
||||
# sufficient, before pam_unix — is inherited), different module.
|
||||
# grosshack prompts for the password itself while polling the
|
||||
# reader; whichever lands first wins. A typed password makes the
|
||||
# rule FAIL with the token stored, and the stock
|
||||
# `auth sufficient pam_unix.so … try_first_pass` right after it
|
||||
# does the actual validation — password stays sufficient on its
|
||||
# own, so a broken reader/fprintd can never lock login out.
|
||||
rules.auth.fprintd.modulePath = lib.mkForce
|
||||
"${pkgs.pam-fprint-grosshack}/lib/security/pam_fprintd_grosshack.so";
|
||||
});
|
||||
})
|
||||
|
||||
# ── Newest kernel for very-new hardware (opt-in escape hatch) ──────
|
||||
(lib.mkIf cfg.latestKernel {
|
||||
boot.kernelPackages = lib.mkDefault pkgs.linuxPackages_latest;
|
||||
})
|
||||
|
||||
# ── NPU (in-kernel driver only; userspace runtime is BYO) ──────────
|
||||
# The driver has to actually be in the running kernel — warn (don't fail)
|
||||
# when it predates the shipped one, pointing at latestKernel.
|
||||
(lib.mkIf (cfg.npu.enable && cfg.amd.enable) {
|
||||
boot.kernelModules = [ "amdxdna" ];
|
||||
warnings = lib.optional
|
||||
(!lib.versionAtLeast config.boot.kernelPackages.kernel.version "6.14")
|
||||
''
|
||||
nomarchy.hardware.npu: the amdxdna driver needs kernel >= 6.14, but
|
||||
this config ships ${config.boot.kernelPackages.kernel.version}. Set
|
||||
nomarchy.hardware.latestKernel = true.'';
|
||||
})
|
||||
(lib.mkIf (cfg.npu.enable && cfg.intel.enable) {
|
||||
boot.kernelModules = [ "intel_vpu" ];
|
||||
warnings = lib.optional
|
||||
(!lib.versionAtLeast config.boot.kernelPackages.kernel.version "6.11")
|
||||
''
|
||||
nomarchy.hardware.npu: the intel_vpu driver (especially for newer
|
||||
NPUs) wants a recent kernel, but this config ships
|
||||
${config.boot.kernelPackages.kernel.version}. Consider
|
||||
nomarchy.hardware.latestKernel = true.'';
|
||||
})
|
||||
|
||||
# ── Webcam: hide a dual-sensor module's IR node ────────────────────
|
||||
# A built-in RGB+IR webcam exposes its IR (face-unlock) sensor as a second,
|
||||
# identically-named camera; an app that picks it gets a dark greyscale
|
||||
# image. Disable that node on the V4L2 PipeWire path so only the colour
|
||||
# camera is offered. Matched by card name (irMatch). libcamera is left
|
||||
# alone on purpose — an external camera may rely on it, and surgical
|
||||
# internal-only libcamera scoping isn't possible (the distinguishing
|
||||
# device props bind after the monitor rule runs). The kernel /dev/video*
|
||||
# stays open, so Howdy face-unlock still reads the IR sensor directly.
|
||||
(lib.mkIf (cfg.camera.hideIrSensor && config.services.pipewire.wireplumber.enable) {
|
||||
services.pipewire.wireplumber.extraConfig."90-nomarchy-hide-ir-camera" = {
|
||||
"monitor.v4l2.rules" = [
|
||||
{
|
||||
matches = [ { "api.v4l2.cap.card" = cfg.camera.irMatch; } ];
|
||||
actions."update-props"."node.disabled" = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
})
|
||||
|
||||
# ── I2C / DDC/CI ───────────────────────────────────────────────────
|
||||
(lib.mkIf cfg.i2c.enable {
|
||||
hardware.i2c.enable = true;
|
||||
})
|
||||
(lib.mkIf cfg.i2c.ddcci {
|
||||
# The driver needs I2C underneath it
|
||||
hardware.i2c.enable = true;
|
||||
boot.extraModulePackages = [ config.boot.kernelPackages.ddcci-driver ];
|
||||
boot.kernelModules = [ "ddcci_backlight" ];
|
||||
})
|
||||
|
||||
# ── Sanity ─────────────────────────────────────────────────────────
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.amd.rocm.enable -> cfg.amd.enable;
|
||||
message = "nomarchy.hardware.amd.rocm.enable needs nomarchy.hardware.amd.enable.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.intel.computeRuntime -> cfg.intel.enable;
|
||||
message = "nomarchy.hardware.intel.computeRuntime needs nomarchy.hardware.intel.enable.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.npu.enable -> (cfg.amd.enable || cfg.intel.enable);
|
||||
message = "nomarchy.hardware.npu.enable needs a detected Intel or AMD platform.";
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
}
|
||||
61
modules/nixos/oom.nix
Normal file
61
modules/nixos/oom.nix
Normal file
@@ -0,0 +1,61 @@
|
||||
# Memory-pressure protection: keep the desktop alive when RAM runs out.
|
||||
#
|
||||
# A workstation that compiles from source WILL exhaust memory eventually —
|
||||
# a big `nix build`, a runaway eval, a browser tab. The kernel's own OOM
|
||||
# killer acts only after the system has thrashed itself unresponsive
|
||||
# (often minutes of frozen desktop) and then picks by badness score,
|
||||
# which can land on the compositor.
|
||||
#
|
||||
# earlyoom over systemd-oomd — a deliberate choice: oomd kills whole
|
||||
# cgroups, and a Hyprland session runs as ONE scope (nothing spawns
|
||||
# per-app systemd scopes here, unlike GNOME), so under pressure oomd
|
||||
# would take out the entire desktop to save it. earlyoom kills a single
|
||||
# process (highest oom_score ≈ the hog) BEFORE the thrash point — "kill
|
||||
# the build step, keep the session". nixpkgs default-enables oomd in an
|
||||
# inert state (no slices monitored); it's disabled outright below so
|
||||
# there is exactly one owner of the OOM story.
|
||||
#
|
||||
# zram sits one layer earlier: a compressed RAM swap that absorbs
|
||||
# day-to-day memory pressure (a browser's idle tabs, a big eval's cold
|
||||
# pages) before it ever reaches the earlyoom threshold — more headroom
|
||||
# on the same RAM, no disk. High swap priority so the kernel fills zram
|
||||
# first; any real disk swapfile stays reserved for the hibernate image
|
||||
# (BACKLOG #76's other half — hibernation can't resume from volatile
|
||||
# zram, so day-to-day paging must not consume the disk swap). zstd @ 50%
|
||||
# RAM are the nixpkgs defaults, kept explicit for reproducibility.
|
||||
{ lib, ... }:
|
||||
|
||||
{
|
||||
zramSwap = {
|
||||
enable = lib.mkDefault true;
|
||||
algorithm = lib.mkDefault "zstd";
|
||||
memoryPercent = lib.mkDefault 50;
|
||||
# Beats any disk swapfile's auto-assigned (negative) priority, so
|
||||
# day-to-day paging lands in zram and the disk swap is left for
|
||||
# hibernation. See the header note and BACKLOG #76.
|
||||
priority = lib.mkDefault 100;
|
||||
};
|
||||
|
||||
services.earlyoom = {
|
||||
enable = lib.mkDefault true;
|
||||
|
||||
# Desktop toast when something is killed (relayed via
|
||||
# systembus-notify), so a vanished build/tab is explained rather
|
||||
# than mysterious.
|
||||
enableNotifications = lib.mkDefault true;
|
||||
|
||||
# Never pick the session plumbing: losing the compositor or the lock
|
||||
# screen IS the outage this module exists to prevent (and killing a
|
||||
# Wayland session-lock client trips its go-to-a-tty failsafe). No
|
||||
# --prefer tuning: highest-memory selection already targets the hog.
|
||||
# Matched unanchored — NixOS wrappers rename comm to ".foo-wrapped".
|
||||
extraArgs = lib.mkDefault [
|
||||
"--avoid"
|
||||
"(Hyprland|hyprlock|greetd|waybar|pipewire|wireplumber|Xwayland|nix-daemon|systemd)"
|
||||
];
|
||||
};
|
||||
|
||||
# One owner (see header): oomd ships default-on but inert; make the
|
||||
# earlyoom choice explicit and total.
|
||||
systemd.oomd.enable = lib.mkDefault false;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
# Deliberately small: only things a downstream user plausibly disagrees
|
||||
# with get a toggle. Everything else in the system module is set with
|
||||
# lib.mkDefault, so plain NixOS options override it natively.
|
||||
{ lib, ... }:
|
||||
{ config, lib, ... }:
|
||||
|
||||
{
|
||||
options.nomarchy.system = {
|
||||
@@ -12,28 +12,35 @@
|
||||
greeter.autoLogin = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
defaultText = lib.literalExpression
|
||||
"(settings.greeter.autoLogin from state.json) or null";
|
||||
example = "ada";
|
||||
description = ''
|
||||
Log this user straight into Hyprland on boot (greetd
|
||||
initial_session); logging out lands on the normal greeter.
|
||||
The installer sets it on LUKS-encrypted machines — the disk
|
||||
passphrase already gates access, a second prompt is ceremony.
|
||||
|
||||
Normally you leave this alone and use System › Auto-login, which
|
||||
writes `settings.greeter.autoLogin` in state.json —
|
||||
./greeter.nix mkDefaults this option from it. The installer seeds
|
||||
that state on LUKS-encrypted machines: the disk passphrase already
|
||||
gates access, so a second prompt is ceremony. Setting this option by
|
||||
hand pins the choice and the menu toggle can no longer move it.
|
||||
'';
|
||||
};
|
||||
|
||||
plymouth.enable = lib.mkEnableOption ''
|
||||
the Nomarchy Plymouth boot splash (logo + progress + LUKS prompt),
|
||||
background-tinted from theme-state.json via nomarchy.system.stateFile.
|
||||
background-tinted from state.json via nomarchy.system.stateFile.
|
||||
Recolors on system rebuilds — theme switches don't touch the initrd'' // { default = true; };
|
||||
|
||||
stateFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
example = lib.literalExpression "./theme-state.json";
|
||||
example = lib.literalExpression "./state.json";
|
||||
description = ''
|
||||
theme-state.json for the system-side consumers (currently the
|
||||
state.json for the system-side consumers (currently the
|
||||
Plymouth splash background). lib.mkFlake wires it automatically
|
||||
from your flake; null falls back to the Tokyo Night base color.
|
||||
from your flake; null falls back to the Boreal base color.
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -44,7 +51,26 @@
|
||||
option'' // { default = true; };
|
||||
|
||||
audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; };
|
||||
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = true; };
|
||||
# default stays a plain `true` here; ./default.nix mkDefaults it from
|
||||
# settings.bluetooth.enable (menu Preferences › Bluetooth package). Reading
|
||||
# the state in the option default is the trap ROADMAP § "NixOS-side state
|
||||
# bridges (#116)" documents: `config.nomarchy.settings` does not exist on
|
||||
# the NixOS side, and `or true` silently swallowed that for years.
|
||||
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // {
|
||||
default = true;
|
||||
defaultText = lib.literalExpression
|
||||
"(settings.bluetooth.enable from state.json) or true";
|
||||
};
|
||||
|
||||
autoTimezone.enable = lib.mkEnableOption ''
|
||||
automatic timezone detection (geoclue + automatic-timezoned): the
|
||||
system timezone — and so the Waybar clock — follows your location, so
|
||||
travelling to another zone updates the time on its own. Off by default
|
||||
(it's a location service and needs the network); toggle it from the
|
||||
System menu, which lands the choice in the in-flake state file. Enabling
|
||||
it unsets the static time.timeZone for you (a runtime timezone needs
|
||||
/etc/localtime writable), so the menu toggle drives a system rebuild''
|
||||
// { default = false; };
|
||||
|
||||
snapper.enable = lib.mkEnableOption ''
|
||||
hourly/daily BTRFS timeline snapshots of / via snapper, plus the
|
||||
@@ -92,15 +118,49 @@
|
||||
|
||||
batteryChargeLimit = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.ints.between 50 100);
|
||||
# No state bridge at eval time, by design: ./power.nix's oneshot reads
|
||||
# settings.power.batteryChargeLimit out of the live state.json
|
||||
# with jq at *runtime* and prefers it over this baked value, so the
|
||||
# menu applies before (and without) a rebuild. This used to read
|
||||
# `config.nomarchy.settings…`, which does not exist on the NixOS side
|
||||
# and so was always null — dead, but harmless precisely because the
|
||||
# runtime path never depended on it (ROADMAP § state bridges, #116).
|
||||
default = null;
|
||||
# Dell Adaptive charge mode ignores the end threshold unless we
|
||||
# also select Custom (power.nix oneshot); see Latitude 5310 QA.
|
||||
example = 80;
|
||||
description = ''
|
||||
Stop charging at this percentage to extend battery lifespan,
|
||||
where the hardware exposes a charge threshold
|
||||
(/sys/class/power_supply/BAT*/charge_control_end_threshold).
|
||||
null leaves charging at the firmware default. Backend-independent
|
||||
(a small systemd unit writes the sysfs knob at boot), so it
|
||||
works under PPD too; needs nomarchy.system.power.laptop.
|
||||
where the hardware exposes charge_control_end_threshold on a
|
||||
system battery (type=Battery under /sys/class/power_supply;
|
||||
name-agnostic — BAT0, CMB0, …).
|
||||
null leaves charging at the firmware default (menu writes 100).
|
||||
Backend-independent: the menu applies live via sysfs (udev
|
||||
GROUP=users on the threshold node) and persists settings in
|
||||
theme-state; a oneshot re-applies on boot and AC replug. On
|
||||
Dell (and similar) the oneshot also selects charge type Custom
|
||||
— Adaptive ignores the threshold while still reporting it.
|
||||
Needs nomarchy.system.power.laptop.
|
||||
'';
|
||||
};
|
||||
|
||||
# #115: suspend → hibernate after 1h on battery. Default true so a
|
||||
# bag-carried laptop stops draining without a menu trip; Preferences
|
||||
# flips settings.power.suspendThenHibernate (state bridge). No-op
|
||||
# when boot.resumeDevice is unset (no hibernate path).
|
||||
suspendThenHibernate = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
defaultText = lib.literalExpression
|
||||
"(settings.power.suspendThenHibernate from state.json) or true";
|
||||
description = ''
|
||||
When true and hibernation is wired (boot.resumeDevice), idle and
|
||||
undocked lid-close use systemd suspend-then-hibernate on battery:
|
||||
sleep, then hibernate after HibernateDelaySec (1 hour). On AC,
|
||||
plain suspend only (HibernateOnACPower=false; lid uses
|
||||
HandleLidSwitchExternalPower=suspend). Toggle from System ›
|
||||
Preferences › Suspend then hibernate — needs a system rebuild for
|
||||
logind; nomarchy-suspend reads the live state for menu/hypridle.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Plymouth boot splash — Nomarchy-branded (ported from the legacy
|
||||
# iteration), background tinted from the same theme-state.json that
|
||||
# iteration), background tinted from the same state.json that
|
||||
# drives the desktop (nomarchy.system.stateFile, wired automatically by
|
||||
# lib.mkFlake). One caveat by design: theme switches are Home
|
||||
# Manager-only and never touch the initrd, so the splash follows the
|
||||
# theme as of the last SYSTEM rebuild (`sys-update`), not the last
|
||||
# `nomarchy-theme-sync apply`.
|
||||
# `nomarchy-state-sync apply`.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
@@ -12,19 +12,32 @@ let
|
||||
|
||||
state =
|
||||
if cfg.stateFile != null
|
||||
then builtins.fromJSON (builtins.readFile cfg.stateFile)
|
||||
then import ../state-read.nix { inherit lib; } cfg.stateFile
|
||||
else { };
|
||||
colorOf = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
|
||||
base = colorOf "base" "#1a1b26";
|
||||
# Fallbacks match the distro default theme (Boreal) when stateFile is null.
|
||||
base = colorOf "base" "#21272F";
|
||||
# Splash elements are recolored from the palette so they read on any
|
||||
# base, light or dark: foreground glyphs → text, field/track boxes →
|
||||
# surface (raised from base in both polarities), the progress fill →
|
||||
# accent. The shipped art is a fixed navy that vanished on dark themes.
|
||||
text = colorOf "text" "#a9b1d6";
|
||||
surface = colorOf "surface" "#32344a";
|
||||
accent = colorOf "accent" "#7aa2f7";
|
||||
text = colorOf "text" "#D3DAE0";
|
||||
surface = colorOf "surface" "#303A46";
|
||||
accent = colorOf "accent" "#B79BE8";
|
||||
# The keyboard hint is a footnote, not a headline: subtext, like the rest of
|
||||
# the palette's secondary text (#145).
|
||||
subtext = colorOf "subtext" "#97A3B2";
|
||||
magick = lib.getExe' pkgs.imagemagick "magick";
|
||||
|
||||
# What the passphrase prompt actually types with. `services.xserver.xkb.layout`
|
||||
# is this distro's single source for the layout, bridged to the console (and
|
||||
# so to the initrd prompt) by console.useXkbConfig — see modules/nixos/default.nix.
|
||||
# A VT loads exactly ONE keymap, so a comma list ("us,gb") means the FIRST:
|
||||
# printing the raw string would lie in precisely the multi-layout case the
|
||||
# label exists for.
|
||||
kbdLayout = lib.head
|
||||
(lib.splitString "," (config.services.xserver.xkb.layout or "us"));
|
||||
|
||||
# Plymouth's Window.SetBackgroundTopColor takes three floats in
|
||||
# 0.0–1.0; the .plymouth metadata's ConsoleLogBackgroundColor takes a
|
||||
# 0xRRGGBB hex. Nix has no float math: multiply, integer-divide, pad.
|
||||
@@ -37,7 +50,9 @@ let
|
||||
else if lib.stringLength s == 2 then "0${s}"
|
||||
else s;
|
||||
in "0.${padded}";
|
||||
channel = off: byteToFloat (lib.fromHexString (lib.substring off 2 base));
|
||||
channelOf = hex: off: byteToFloat (lib.fromHexString (lib.substring off 2 hex));
|
||||
channel = channelOf base; # Window.SetBackground* (the splash base)
|
||||
fgChannel = channelOf subtext; # Image.Text needs the same 0.0-1.0 floats
|
||||
|
||||
nomarchy-plymouth = pkgs.stdenv.mkDerivation {
|
||||
pname = "nomarchy-plymouth";
|
||||
@@ -52,6 +67,17 @@ let
|
||||
mkdir -p "$themedir"
|
||||
cp * "$themedir/"
|
||||
|
||||
# The keyboard glyph for the passphrase hint (#145) is plymouth's own —
|
||||
# literally the icon Fedora shows, since its keymap widget loads this same
|
||||
# asset. Copied at build time rather than vendored: the bytes stay in
|
||||
# nixpkgs' (GPL) plymouth and never enter this repo, and it cannot drift
|
||||
# from the plymouth we actually run.
|
||||
# chmod: store files are read-only (444) and `recolor` rewrites in place.
|
||||
# unpackPhase makes the *source* writable, which is why the art below
|
||||
# needs no such thing — a file copied straight from the store does.
|
||||
cp ${pkgs.plymouth}/share/plymouth/themes/spinner/keyboard.png "$themedir/keyboard.png"
|
||||
chmod +w "$themedir/keyboard.png"
|
||||
|
||||
# Recolor the splash art from the palette (flat fill, alpha kept) so
|
||||
# it reads on any base instead of the shipped fixed navy.
|
||||
recolor() { ${magick} "$themedir/$1" -fill "#$2" -colorize 100 "$themedir/$1"; }
|
||||
@@ -61,6 +87,7 @@ let
|
||||
recolor entry.png ${surface} # password field box
|
||||
recolor progress_box.png ${surface} # progress track
|
||||
recolor progress_bar.png ${accent} # progress fill
|
||||
recolor keyboard.png ${subtext} # passphrase keyboard-layout hint
|
||||
|
||||
# Point the .plymouth metadata into the store
|
||||
sed -i "s|/usr/share/plymouth/themes/nomarchy|$themedir|g" \
|
||||
@@ -72,6 +99,10 @@ let
|
||||
-e 's|@BG_R@|${channel 0}|g' \
|
||||
-e 's|@BG_G@|${channel 2}|g' \
|
||||
-e 's|@BG_B@|${channel 4}|g' \
|
||||
-e 's|@FG_R@|${fgChannel 0}|g' \
|
||||
-e 's|@FG_G@|${fgChannel 2}|g' \
|
||||
-e 's|@FG_B@|${fgChannel 4}|g' \
|
||||
-e 's|@LAYOUT@|${kbdLayout}|g' \
|
||||
"$themedir/nomarchy.script"
|
||||
sed -i 's|@BG_HEX@|${base}|g' \
|
||||
"$themedir/nomarchy.plymouth"
|
||||
|
||||
@@ -3,18 +3,125 @@
|
||||
Window.SetBackgroundTopColor(@BG_R@, @BG_G@, @BG_B@);
|
||||
Window.SetBackgroundBottomColor(@BG_R@, @BG_G@, @BG_B@);
|
||||
|
||||
logo.image = Image("logo.png");
|
||||
# Everything is placed by layout(), which runs on every canvas change — never
|
||||
# once at parse time (#137).
|
||||
#
|
||||
# How the canvas works, because it is not obvious and the old code read as
|
||||
# correct: the script plugin lays all heads out in ONE virtual canvas of
|
||||
# max_width x max_height (the largest head), centres each display inside it
|
||||
# (display->x = (max_width - width) / 2) and draws each sprite at
|
||||
# (sprite.x - display.x). So Window.GetWidth() is the CANVAS width — the
|
||||
# widest head, NOT head 0 — and a canvas-centred sprite lands centred on every
|
||||
# head. The arithmetic below is unchanged from the original and it was always
|
||||
# right. What it was not is permanent: when a head arrives or leaves the
|
||||
# canvas resizes, the plugin re-centres each display, and sprites keep the
|
||||
# coordinates they were given — so positions frozen at parse time end up off by
|
||||
# (new_max - old_max) / 2 on EVERY head. Booting or shutting down with an
|
||||
# external attached is exactly that. One monitor never resizes the canvas,
|
||||
# which is why this only ever showed docked.
|
||||
#
|
||||
# TRAP — read before editing layout(). In plymouth script a bare assignment
|
||||
# inside a function writes the GLOBAL if that name already exists globally
|
||||
# (`global.foo` and a bare `foo` are the same variable; it only becomes a local
|
||||
# when no global of that name exists). So a local named after the global it
|
||||
# guards on silently updates that global *before* the comparison, the guard is
|
||||
# then always false, and the body never runs — the whole splash renders as a
|
||||
# bare background, with no error logged anywhere. Hence `cw`/`ch` below, and
|
||||
# never `canvas_width = Window.GetWidth()`.
|
||||
logo.original_image = Image("logo.png");
|
||||
lock.image = Image("lock.png");
|
||||
entry.image = Image("entry.png");
|
||||
bullet.image = Image("bullet.png");
|
||||
# The keyboard hint for the passphrase prompt (#145): a VT loads ONE keymap and
|
||||
# knows nothing of per-device layouts, so what you type here is the console
|
||||
# layout — which is worth saying out loud before three wrong tries on a disk
|
||||
# nobody can read yet. Both are baked at build time by plymouth.nix.
|
||||
kbd.icon_image = Image("keyboard.png");
|
||||
kbd.text_image = Image.Text("@LAYOUT@", @FG_R@, @FG_G@, @FG_B@);
|
||||
|
||||
# Calculate scale factor to make logo ~15% of screen height
|
||||
logo_scale_factor = (Window.GetHeight() * 0.15) / logo.image.GetHeight();
|
||||
logo_width = logo.image.GetWidth() * logo_scale_factor;
|
||||
logo_height = logo.image.GetHeight() * logo_scale_factor;
|
||||
logo.image = logo.image.Scale(logo_width, logo_height);
|
||||
|
||||
logo.sprite = Sprite(logo.image);
|
||||
logo.sprite.SetX (Window.GetWidth() / 2 - logo.image.GetWidth() / 2);
|
||||
logo.sprite.SetY (Window.GetHeight() / 2 - logo.image.GetHeight() / 2);
|
||||
logo.sprite = Sprite();
|
||||
logo.sprite.SetOpacity (1);
|
||||
entry.sprite = Sprite(entry.image);
|
||||
entry.sprite.SetOpacity (0);
|
||||
lock.sprite = Sprite();
|
||||
lock.sprite.SetOpacity (0);
|
||||
kbd.icon_sprite = Sprite();
|
||||
kbd.icon_sprite.SetOpacity (0);
|
||||
kbd.text_sprite = Sprite();
|
||||
kbd.text_sprite.SetOpacity (0);
|
||||
|
||||
global.canvas_width = 0;
|
||||
global.canvas_height = 0;
|
||||
global.bullet_size = 7;
|
||||
global.bullet_gap = 5;
|
||||
|
||||
fun layout ()
|
||||
{
|
||||
cw = Window.GetWidth();
|
||||
ch = Window.GetHeight();
|
||||
|
||||
if (cw != global.canvas_width || ch != global.canvas_height)
|
||||
{
|
||||
global.canvas_width = cw;
|
||||
global.canvas_height = ch;
|
||||
|
||||
# Logo: ~15% of canvas height, centred.
|
||||
logo_scale = (ch * 0.15) / logo.original_image.GetHeight();
|
||||
logo.image = logo.original_image.Scale(
|
||||
logo.original_image.GetWidth() * logo_scale,
|
||||
logo.original_image.GetHeight() * logo_scale);
|
||||
logo.sprite.SetImage(logo.image);
|
||||
logo.sprite.SetX(cw / 2 - logo.image.GetWidth() / 2);
|
||||
logo.sprite.SetY(ch / 2 - logo.image.GetHeight() / 2);
|
||||
|
||||
# Password entry, under the logo.
|
||||
entry.x = cw / 2 - entry.image.GetWidth() / 2;
|
||||
entry.y = logo.sprite.GetY() + logo.image.GetHeight() + 40;
|
||||
entry.sprite.SetPosition(entry.x, entry.y, 10001);
|
||||
|
||||
# Lock, slightly shorter than the entry, to its left.
|
||||
# (source lock.png is 84x96)
|
||||
lock_h = entry.image.GetHeight() * 0.8;
|
||||
lock_w = 84 * (lock_h / 96);
|
||||
lock.sprite.SetImage(lock.image.Scale(lock_w, lock_h));
|
||||
lock.sprite.SetPosition(entry.x - lock_w - 15,
|
||||
entry.y + entry.image.GetHeight() / 2 - lock_h / 2,
|
||||
10001);
|
||||
|
||||
# Keyboard hint, centred as one icon+text group under the entry.
|
||||
kbd_gap = 8;
|
||||
kbd_x = cw / 2 - (kbd.icon_image.GetWidth() + kbd_gap
|
||||
+ kbd.text_image.GetWidth()) / 2;
|
||||
kbd_y = entry.y + entry.image.GetHeight() + 18;
|
||||
kbd.icon_sprite.SetImage(kbd.icon_image);
|
||||
kbd.icon_sprite.SetPosition(kbd_x, kbd_y, 10001);
|
||||
kbd.text_sprite.SetImage(kbd.text_image);
|
||||
kbd.text_sprite.SetPosition(
|
||||
kbd_x + kbd.icon_image.GetWidth() + kbd_gap,
|
||||
kbd_y + kbd.icon_image.GetHeight() / 2 - kbd.text_image.GetHeight() / 2,
|
||||
10001);
|
||||
|
||||
# Bullets already on screen belong to the old canvas.
|
||||
for (index = 0; bullet.sprites[index]; index++)
|
||||
{
|
||||
bullet.sprites[index].SetPosition(
|
||||
entry.x + 20 + index * (global.bullet_size + global.bullet_gap),
|
||||
entry.y + entry.image.GetHeight() / 2 - global.bullet_size / 2,
|
||||
10002);
|
||||
}
|
||||
|
||||
# Progress box + bar share the entry's line.
|
||||
progress_box.sprite.SetPosition(
|
||||
cw / 2 - progress_box.image.GetWidth() / 2,
|
||||
entry.y + entry.image.GetHeight() / 2 - progress_box.image.GetHeight() / 2,
|
||||
0);
|
||||
progress_bar.sprite.SetPosition(
|
||||
cw / 2 - progress_bar.original_image.GetWidth() / 2,
|
||||
entry.y + entry.image.GetHeight() / 2
|
||||
- progress_bar.original_image.GetHeight() / 2,
|
||||
1);
|
||||
}
|
||||
}
|
||||
|
||||
# Use these to adjust the progress bar timing
|
||||
global.fake_progress_limit = 0.7; # Target percentage for fake progress (0.0 to 1.0)
|
||||
@@ -31,6 +138,10 @@ global.max_progress = 0.0; # Track the maximum progress reached to prevent back
|
||||
|
||||
fun refresh_callback ()
|
||||
{
|
||||
# Cheap: two Window.Get*() reads; layout() returns at once unless the canvas
|
||||
# actually resized (a head arrived or left).
|
||||
layout();
|
||||
|
||||
global.animation_frame++;
|
||||
|
||||
# Animate fake progress to limit over time with easing
|
||||
@@ -91,12 +202,18 @@ fun show_password_dialog()
|
||||
{
|
||||
lock.sprite.SetOpacity(1);
|
||||
entry.sprite.SetOpacity(1);
|
||||
# The keyboard hint belongs to the prompt: it is only ever the answer to
|
||||
# "what am I typing with?", so it appears and leaves with the box (#145).
|
||||
kbd.icon_sprite.SetOpacity(1);
|
||||
kbd.text_sprite.SetOpacity(1);
|
||||
}
|
||||
|
||||
fun hide_password_dialog()
|
||||
{
|
||||
lock.sprite.SetOpacity(0);
|
||||
entry.sprite.SetOpacity(0);
|
||||
kbd.icon_sprite.SetOpacity(0);
|
||||
kbd.text_sprite.SetOpacity(0);
|
||||
for (index = 0; bullet.sprites[index]; index++)
|
||||
bullet.sprites[index].SetOpacity(0);
|
||||
}
|
||||
@@ -121,30 +238,7 @@ fun stop_fake_progress()
|
||||
|
||||
#----------------------------------------- Dialogue --------------------------------
|
||||
|
||||
lock.image = Image("lock.png");
|
||||
entry.image = Image("entry.png");
|
||||
bullet.image = Image("bullet.png");
|
||||
|
||||
entry.sprite = Sprite(entry.image);
|
||||
entry.x = Window.GetWidth()/2 - entry.image.GetWidth() / 2;
|
||||
entry.y = logo.sprite.GetY() + logo.image.GetHeight() + 40;
|
||||
entry.sprite.SetPosition(entry.x, entry.y, 10001);
|
||||
entry.sprite.SetOpacity(0);
|
||||
|
||||
# Scale lock to be slightly shorter than entry field height
|
||||
# Original lock is 84x96, entry height determines scale
|
||||
lock_height = entry.image.GetHeight() * 0.8;
|
||||
lock_scale = lock_height / 96;
|
||||
lock_width = 84 * lock_scale;
|
||||
|
||||
scaled_lock = lock.image.Scale(lock_width, lock_height);
|
||||
lock.sprite = Sprite(scaled_lock);
|
||||
lock.x = entry.x - lock_width - 15;
|
||||
lock.y = entry.y + entry.image.GetHeight()/2 - lock_height/2;
|
||||
lock.sprite.SetPosition(lock.x, lock.y, 10001);
|
||||
lock.sprite.SetOpacity(0);
|
||||
|
||||
# Bullet array
|
||||
# Images and sprites are created at the top; every position lives in layout().
|
||||
bullet.sprites = [];
|
||||
|
||||
fun display_normal_callback ()
|
||||
@@ -206,21 +300,18 @@ Plymouth.SetDisplayPasswordFunction(display_password_callback);
|
||||
|
||||
progress_box.image = Image("progress_box.png");
|
||||
progress_box.sprite = Sprite(progress_box.image);
|
||||
|
||||
progress_box.x = Window.GetWidth() / 2 - progress_box.image.GetWidth() / 2;
|
||||
progress_box.y = entry.y + entry.image.GetHeight() / 2 - progress_box.image.GetHeight() / 2;
|
||||
progress_box.sprite.SetPosition(progress_box.x, progress_box.y, 0);
|
||||
progress_box.sprite.SetOpacity(0);
|
||||
|
||||
progress_bar.original_image = Image("progress_bar.png");
|
||||
progress_bar.sprite = Sprite();
|
||||
progress_bar.image = progress_bar.original_image.Scale(1, progress_bar.original_image.GetHeight());
|
||||
|
||||
progress_bar.x = Window.GetWidth() / 2 - progress_bar.original_image.GetWidth() / 2;
|
||||
progress_bar.y = progress_box.y + (progress_box.image.GetHeight() - progress_bar.original_image.GetHeight()) / 2;
|
||||
progress_bar.sprite.SetPosition(progress_bar.x, progress_bar.y, 1);
|
||||
progress_bar.sprite.SetOpacity(0);
|
||||
|
||||
# First placement: everything layout() reads now exists. The refresh callback
|
||||
# re-runs it, so a head arriving or leaving mid-splash moves the splash with it
|
||||
# instead of stranding it against a canvas that is gone.
|
||||
layout();
|
||||
|
||||
fun progress_callback (duration, progress)
|
||||
{
|
||||
global.real_progress = progress;
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
# Active power management. One concern, one file: this is the system
|
||||
# side — the power daemon (power-profiles-daemon by default, or TLP),
|
||||
# thermald, and the battery charge limit. The profile *switcher* and the
|
||||
# Waybar *indicator* live home-side (rofi.nix / waybar.nix); they self-
|
||||
# gate on powerprofilesctl being present, so there's no system→home wiring
|
||||
# to keep in sync (the same way the Waybar battery widget auto-hides on
|
||||
# desktops). See the roadmap in README.md.
|
||||
{ config, lib, ... }:
|
||||
# thermald, the battery charge limit, and suspend-then-hibernate (#115).
|
||||
# The profile *switcher* and the Waybar *indicator* live home-side
|
||||
# (rofi.nix / waybar.nix); they self-gate on powerprofilesctl being
|
||||
# present, so there's no system→home wiring to keep in sync (the same
|
||||
# way the Waybar battery widget auto-hides on desktops). See the
|
||||
# roadmap in README.md.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy.system.power;
|
||||
sysCfg = config.nomarchy.system;
|
||||
ppd = cfg.backend == "ppd";
|
||||
tlp = cfg.backend == "tlp";
|
||||
chargeLimit = cfg.laptop && cfg.batteryChargeLimit != null;
|
||||
|
||||
# settings.power.suspendThenHibernate → this option (ROADMAP § state
|
||||
# bridges #116). null = key absent → leave option default (true).
|
||||
sysState =
|
||||
if sysCfg.stateFile != null
|
||||
then import ../state-read.nix { inherit lib; } sysCfg.stateFile
|
||||
else { };
|
||||
stateS2h =
|
||||
let v = ((sysState.settings or { }).power or { }).suspendThenHibernate or null;
|
||||
in if builtins.isBool v then v else null;
|
||||
|
||||
# Hibernate needs a resume device (installer swapfile / partition).
|
||||
# Without it, offering s2h only produces a suspend that never wakes.
|
||||
canHibernate = (config.boot.resumeDevice or "") != "";
|
||||
s2hActive = cfg.suspendThenHibernate && canHibernate;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
@@ -32,29 +48,187 @@ in
|
||||
services.power-profiles-daemon.enable = lib.mkDefault ppd;
|
||||
services.tlp.enable = lib.mkDefault tlp;
|
||||
|
||||
# State bridge (#115 / #116): menu writes settings.power.suspendThenHibernate.
|
||||
nomarchy.system.power.suspendThenHibernate =
|
||||
lib.mkIf (stateS2h != null) (lib.mkDefault stateS2h);
|
||||
|
||||
# Smart suspend helper (hypridle + Power menu). Live state read.
|
||||
environment.systemPackages = [ pkgs.nomarchy-suspend ];
|
||||
|
||||
# Clamshell / dock (BACKLOG #86): when the machine is "docked" in
|
||||
# logind's sense (≥1 external display connected), closing the lid
|
||||
# must NOT suspend — the external panel is the session. systemd's
|
||||
# built-in default is already ignore; we set it explicitly so a
|
||||
# downstream override or lock-bump drift is visible, and so
|
||||
# checks.clamshell-logind can assert the contract.
|
||||
# #115: undocked lid on battery → suspend-then-hibernate when the
|
||||
# toggle is on and resume is wired; on AC → plain suspend so a
|
||||
# docked-at-desk lid-close doesn't plan a hibernate. Display-profile
|
||||
# "docked" layouts (eDP off) are orthogonal (nomarchy.displayProfiles).
|
||||
services.logind.settings.Login = {
|
||||
HandleLidSwitchDocked = lib.mkDefault "ignore";
|
||||
} // lib.optionalAttrs s2hActive {
|
||||
HandleLidSwitch = lib.mkDefault "suspend-then-hibernate";
|
||||
HandleLidSwitchExternalPower = lib.mkDefault "suspend";
|
||||
};
|
||||
|
||||
# 1h in suspend then hibernate; never start the countdown on AC
|
||||
# (systemd ≥257 HibernateOnACPower). Only matters when something
|
||||
# actually enters suspend-then-hibernate.
|
||||
systemd.sleep.settings.Sleep = lib.mkIf s2hActive {
|
||||
HibernateDelaySec = "1h";
|
||||
HibernateOnACPower = false;
|
||||
};
|
||||
|
||||
# thermald is Intel-only, so off unless asked (the installer enables
|
||||
# it on a GenuineIntel CPU). Sits happily next to either backend.
|
||||
services.thermald.enable = lib.mkDefault cfg.thermal.enable;
|
||||
|
||||
# Battery charge limit via sysfs. PPD can't cap charge at all, and
|
||||
# TLP's own knob only applies under TLP — so a tiny oneshot writes
|
||||
# the threshold directly, independent of the backend. Boot-time only:
|
||||
# some firmwares reset the threshold on unplug; revisit with a udev
|
||||
# hook if that bites. `-` paths that don't exist are skipped, so this
|
||||
# is a clean no-op on hardware without the control.
|
||||
systemd.services.nomarchy-battery-charge-limit = lib.mkIf chargeLimit {
|
||||
description = "Cap battery charging at ${toString cfg.batteryChargeLimit}%";
|
||||
# the threshold directly, independent of the backend. Re-applied on
|
||||
# AC state changes by the udev rule below (some firmwares reset the
|
||||
# threshold when the charger is unplugged).
|
||||
#
|
||||
# Instant menu path (user decision 2026-07-10): the threshold node is
|
||||
# group-writable for `users` so nomarchy-menu can echo live without
|
||||
# rebuild; this oneshot still owns boot + AC-replug re-apply. Prefer
|
||||
# live state.json under /home/*/.nomarchy so a menu change
|
||||
# survives reboot before the next sys-rebuild bakes the Nix option.
|
||||
systemd.services.nomarchy-battery-charge-limit = lib.mkIf cfg.laptop {
|
||||
description = "Apply battery charge end threshold from state or config";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [ pkgs.jq pkgs.coreutils ];
|
||||
# A sustained dock/AC event storm can land SPACED starts — each run
|
||||
# finishes (~1s) before the next event, so nothing coalesces and 5
|
||||
# successful starts in 10s trip systemd's default start limit: the
|
||||
# unit is marked failed (start-limit-hit) although every run
|
||||
# succeeded (T14s, 2026-07-13; the #101 coalescing only covers
|
||||
# events that arrive DURING a run). The write is idempotent and
|
||||
# sub-second — exempt it from rate limiting.
|
||||
unitConfig.StartLimitIntervalSec = 0;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
for thresh in /sys/class/power_supply/BAT*/charge_control_end_threshold; do
|
||||
[ -w "$thresh" ] && echo ${toString cfg.batteryChargeLimit} > "$thresh"
|
||||
set -euo pipefail
|
||||
# Baked generation default (empty = full charge / no cap).
|
||||
limit=${if cfg.batteryChargeLimit != null then toString cfg.batteryChargeLimit else ""}
|
||||
# Prefer the newest live state write (menu path, no rebuild yet).
|
||||
for st in /home/*/.nomarchy/state.json; do
|
||||
[ -r "$st" ] || continue
|
||||
v=$(jq -r '.settings.power.batteryChargeLimit // empty' "$st" 2>/dev/null || true)
|
||||
case "$v" in
|
||||
""|null|Null) ;;
|
||||
*[!0-9]*) ;;
|
||||
*) limit=$v ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$limit" ] || limit=100
|
||||
# Name-agnostic system batteries (BACKLOG #60).
|
||||
for d in /sys/class/power_supply/*/; do
|
||||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||||
thresh="$d/charge_control_end_threshold"
|
||||
[ -e "$thresh" ] || continue
|
||||
# Keep nodes group-writable for the menu's live path (udev
|
||||
# sets this on add; re-assert after firmware recreates attrs).
|
||||
for node in charge_control_end_threshold charge_control_start_threshold charge_types charge_type; do
|
||||
[ -e "$d$node" ] || continue
|
||||
chgrp users "$d$node" 2>/dev/null || true
|
||||
chmod 0664 "$d$node" 2>/dev/null || true
|
||||
done
|
||||
# Dell (and some others): charge_control_* thresholds are only
|
||||
# honoured in Custom mode. Adaptive/Standard ignore end_threshold
|
||||
# while still reporting the written value — battery keeps charging
|
||||
# past the cap (Latitude 5310: end=80, type=[Adaptive], capacity 96%+).
|
||||
# Off (100): restore Adaptive if listed, else leave type alone.
|
||||
ctypes="$d/charge_types"
|
||||
ctype="$d/charge_type"
|
||||
if [ -e "$ctypes" ] || [ -e "$ctype" ]; then
|
||||
listed=$(cat "$ctypes" 2>/dev/null || cat "$ctype" 2>/dev/null || true)
|
||||
write_type() {
|
||||
local t="$1"
|
||||
[ -n "$t" ] || return 0
|
||||
if [ -w "$ctypes" ]; then echo "$t" > "$ctypes" 2>/dev/null || true
|
||||
elif [ -w "$ctype" ]; then echo "$t" > "$ctype" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
if [ "$limit" -lt 100 ] 2>/dev/null; then
|
||||
# listed looks like: "Trickle Fast Standard [Adaptive] Custom"
|
||||
case "$listed" in *Custom*) write_type Custom ;; esac
|
||||
else
|
||||
case "$listed" in
|
||||
*Adaptive*) write_type Adaptive ;;
|
||||
*Standard*) write_type Standard ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
# Start threshold: must be < end. Keep a ~10% hysteresis band when
|
||||
# the node exists (firmware may reject start >= end).
|
||||
start_node="$d/charge_control_start_threshold"
|
||||
if [ -w "$start_node" ] && [ "$limit" -lt 100 ] 2>/dev/null; then
|
||||
start=$(( limit > 15 ? limit - 10 : 0 ))
|
||||
echo "$start" > "$start_node" 2>/dev/null || true
|
||||
fi
|
||||
# Some firmwares (Dell) reset the threshold on unplug *after*
|
||||
# the udev event — write once, wait, write again (type first).
|
||||
echo "$limit" > "$thresh" 2>/dev/null || true
|
||||
sleep 1
|
||||
if [ -e "$ctypes" ] || [ -e "$ctype" ]; then
|
||||
listed=$(cat "$ctypes" 2>/dev/null || cat "$ctype" 2>/dev/null || true)
|
||||
if [ "$limit" -lt 100 ] 2>/dev/null; then
|
||||
case "$listed" in
|
||||
*Custom*)
|
||||
if [ -w "$ctypes" ]; then echo Custom > "$ctypes" 2>/dev/null || true
|
||||
elif [ -w "$ctype" ]; then echo Custom > "$ctype" 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
echo "$limit" > "$thresh" 2>/dev/null || true
|
||||
done
|
||||
exit 0
|
||||
'';
|
||||
};
|
||||
|
||||
# Writable threshold for the logged-in user (menu live apply) + AC
|
||||
# re-apply of the oneshot. Laptop only; no-op when the attr is absent.
|
||||
# Immediate + delayed start: Dell/Latitude firmware often stomps the
|
||||
# threshold to 100 right after unplug; a single immediate oneshot
|
||||
# loses the race (hardware: unplug→100, plug→80). systemd-run hands
|
||||
# the delayed start off so udev's short RUN budget is not held. Keep the
|
||||
# oneshot inactive after success and use `start`, not `restart`: a USB-C
|
||||
# dock can emit a burst of Mains change events, and restart would SIGTERM
|
||||
# the in-flight one-second settling pass until systemd hits its start
|
||||
# limit. Concurrent starts instead coalesce safely.
|
||||
services.udev.extraRules = lib.mkIf cfg.laptop (
|
||||
let
|
||||
systemctl = "${config.systemd.package}/bin/systemctl";
|
||||
systemdRun = "${config.systemd.package}/bin/systemd-run";
|
||||
in ''
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_control_end_threshold", GROUP="users", MODE="0664"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_types", GROUP="users", MODE="0664"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_type", GROUP="users", MODE="0664"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_control_start_threshold", GROUP="users", MODE="0664"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemctl} --no-block start nomarchy-battery-charge-limit.service"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemdRun} --no-block --collect --on-active=2s --timer-property=AccuracySec=200ms ${systemctl} --no-block start nomarchy-battery-charge-limit.service"
|
||||
''
|
||||
);
|
||||
|
||||
# Unprivileged restart of the oneshot so the menu can re-apply as
|
||||
# root when the threshold node is not (yet) user-writable.
|
||||
security.polkit.extraConfig = lib.mkIf cfg.laptop ''
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
subject.isInGroup("users")) {
|
||||
var unit = action.lookup("unit");
|
||||
if (unit == "nomarchy-battery-charge-limit.service") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
}
|
||||
});
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,12 +7,26 @@
|
||||
|
||||
let
|
||||
cfg = config.nomarchy.services;
|
||||
|
||||
# Menu Preferences › Printing writes settings.printing.enable; read it
|
||||
# from the state file, the only place it exists on the NixOS side (the
|
||||
# hardware.nix/timezone.nix bridge). Missing/invalid JSON fails closed via
|
||||
# state-read.nix rather than a raw stack. null = key absent, which
|
||||
# leaves the option's own default alone.
|
||||
svcState =
|
||||
if config.nomarchy.system.stateFile != null
|
||||
then import ../state-read.nix { inherit lib; } config.nomarchy.system.stateFile
|
||||
else { };
|
||||
statePrinting =
|
||||
let v = (svcState.settings or { }).printing.enable or null;
|
||||
in if builtins.isBool v then v else null;
|
||||
in
|
||||
{
|
||||
options.nomarchy.services = {
|
||||
tailscale.enable = lib.mkEnableOption ''
|
||||
Tailscale, the mesh VPN — ships the daemon; authenticate once with
|
||||
`sudo tailscale up`'';
|
||||
Tailscale, the mesh VPN — ships the daemon and makes the login user its
|
||||
operator, so `tailscale up/down/set` and the System → VPN menu work
|
||||
without sudo. Authenticate once (the menu's Connect, or `tailscale up`)'';
|
||||
|
||||
syncthing.enable = lib.mkEnableOption ''
|
||||
Syncthing continuous file sync, running as the login user — add
|
||||
@@ -73,9 +87,18 @@ in
|
||||
with the lmstudio/alpaca GUIs). CPU by default — set
|
||||
`services.ollama.acceleration` natively for GPU offload'';
|
||||
|
||||
# default stays a plain `false` here; the state bridge is a mkDefault
|
||||
# below, from settings.printing.enable (menu Preferences › Printing
|
||||
# toggle) — see ROADMAP § state bridges (#116) for why the old read of
|
||||
# `config.nomarchy.settings` never worked.
|
||||
printing.enable = lib.mkEnableOption ''
|
||||
CUPS printing with Avahi/mDNS, so network printers are auto-discovered
|
||||
(add vendor drivers via `services.printing.drivers`)'';
|
||||
(add vendor drivers via `services.printing.drivers`); the menu's
|
||||
System ▸ Printers entry opens the system-config-printer GUI'' // {
|
||||
default = false;
|
||||
defaultText = lib.literalExpression
|
||||
"(settings.printing.enable from state.json) or false";
|
||||
};
|
||||
|
||||
openrgb.enable = lib.mkEnableOption ''
|
||||
the OpenRGB daemon and GUI for controlling RGB lighting on peripherals
|
||||
@@ -122,8 +145,22 @@ in
|
||||
};
|
||||
|
||||
config = lib.mkMerge [
|
||||
# The in-flake state drives the toggle; mkDefault so a hand-set
|
||||
# nomarchy.services.printing.enable in system.nix still pins it (the
|
||||
# greeter.autoLogin shape). mkIf, not a fallback expression, so an absent
|
||||
# key leaves the option default as the single source of `false`.
|
||||
(lib.mkIf (statePrinting != null) {
|
||||
nomarchy.services.printing.enable = lib.mkDefault statePrinting;
|
||||
})
|
||||
|
||||
(lib.mkIf cfg.tailscale.enable {
|
||||
services.tailscale.enable = true;
|
||||
# Let the login user drive tailscale (up/down/set — and so the VPN menu's
|
||||
# Tailscale controls) without sudo. It's already in wheel, so the operator
|
||||
# grant is no real new privilege, just skips the password prompt. The
|
||||
# module's tailscaled-set unit applies this after the daemon starts.
|
||||
# mkDefault so a downstream can drop or replace it (e.g. extraSetFlags = []).
|
||||
services.tailscale.extraSetFlags = lib.mkDefault [ "--operator=${args.username}" ];
|
||||
})
|
||||
|
||||
(lib.mkIf cfg.syncthing.enable {
|
||||
@@ -246,6 +283,9 @@ in
|
||||
nssmdns4 = true;
|
||||
openFirewall = true;
|
||||
};
|
||||
# The CUPS admin GUI — the menu's System ▸ Printers entry execs it
|
||||
# (self-gated on this binary), so it ships with the printing service.
|
||||
environment.systemPackages = [ pkgs.system-config-printer ];
|
||||
})
|
||||
|
||||
(lib.mkIf cfg.openrgb.enable {
|
||||
|
||||
88
modules/nixos/timezone.nix
Normal file
88
modules/nixos/timezone.nix
Normal file
@@ -0,0 +1,88 @@
|
||||
# Automatic timezone detection (opt-in) — the system timezone, and so the
|
||||
# Waybar clock, follows your location, so travelling to another zone updates
|
||||
# the time on its own. Geoclue feeds `automatic-timezoned`, which drives
|
||||
# /etc/localtime at runtime.
|
||||
#
|
||||
# In-flake state, menu-driven (the keyboard/night-light philosophy): the on/off
|
||||
# flag lives in the same state.json under `settings.autoTimezone`
|
||||
# (git-tracked, reproducible), written by the System-menu toggle
|
||||
# (nomarchy-autotimezone). Because this is a SYSTEM service — not a user unit it
|
||||
# can start/stop instantly like night-light — the toggle drives a system rebuild
|
||||
# (plus a home switch for the Waybar-refresh watcher in timezone.nix home-side).
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy.system;
|
||||
|
||||
# Read the same state file the rest of the system side uses (Plymouth too),
|
||||
# wired by lib.mkFlake. The flag defaults off when stateFile is null;
|
||||
# a set-but-missing/invalid path fails closed via state-read.nix.
|
||||
state =
|
||||
if cfg.stateFile != null
|
||||
then import ../state-read.nix { inherit lib; } cfg.stateFile
|
||||
else { };
|
||||
stateEnabled = (state.settings or { }).autoTimezone or false;
|
||||
|
||||
sync = lib.getExe pkgs.nomarchy-state-sync;
|
||||
|
||||
# Menu/CLI toggle. Runs as the normal user (it owns the flake checkout +
|
||||
# writes the state); sudos only the system switch, like sys-update. Writes
|
||||
# the in-flake flag, then rebuilds: the system rebuild bakes the service +
|
||||
# the time.timeZone override, the home switch installs/removes the Waybar
|
||||
# refresh watcher — both read the same flag we just wrote.
|
||||
nomarchy-autotimezone = pkgs.writeShellScriptBin "nomarchy-autotimezone" ''
|
||||
set -e
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "nomarchy-autotimezone: run as your normal user (it sudos the rebuild itself)" >&2
|
||||
exit 1
|
||||
fi
|
||||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||
|
||||
cur=$(${sync} get settings.autoTimezone 2>/dev/null) || cur=false
|
||||
case "''${1:-toggle}" in
|
||||
on) new=true ;;
|
||||
off) new=false ;;
|
||||
toggle) case "$cur" in true|True) new=false ;; *) new=true ;; esac ;;
|
||||
status) echo "$cur"; exit 0 ;;
|
||||
*) echo "usage: nomarchy-autotimezone [toggle|on|off|status]" >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
${sync} --quiet set settings.autoTimezone "$new" --no-switch
|
||||
|
||||
if [ "$new" = true ]; then
|
||||
notify-send "Auto timezone" "Enabling — rebuilding the system…" 2>/dev/null || true
|
||||
else
|
||||
notify-send "Auto timezone" "Disabling — rebuilding the system…" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
sudo nixos-rebuild switch --flake "$flake#default"
|
||||
home-manager switch --flake "$flake"
|
||||
|
||||
if [ "$new" = true ]; then
|
||||
notify-send "Auto timezone on" "The clock now follows your location." 2>/dev/null || true
|
||||
else
|
||||
notify-send "Auto timezone off" "Back to the fixed timezone in system.nix." 2>/dev/null || true
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
{
|
||||
# Shipped unconditionally so the menu can enable the feature even while
|
||||
# it's off. Track the in-flake flag; mkDefault so a hand-set
|
||||
# nomarchy.system.autoTimezone.enable in system.nix still wins.
|
||||
environment.systemPackages = [ nomarchy-autotimezone ];
|
||||
nomarchy.system.autoTimezone.enable = lib.mkDefault stateEnabled;
|
||||
}
|
||||
|
||||
(lib.mkIf cfg.autoTimezone.enable {
|
||||
services.geoclue2.enable = true;
|
||||
services.automatic-timezoned.enable = true;
|
||||
# A runtime timezone needs /etc/localtime writable. automatic-timezoned
|
||||
# sets time.timeZone = null itself, but the installer writes a static
|
||||
# value at normal priority, which would collide (a hard eval error) —
|
||||
# mkForce null overrides both and resolves cleanly.
|
||||
time.timeZone = lib.mkForce null;
|
||||
})
|
||||
];
|
||||
}
|
||||
94
modules/state-read.nix
Normal file
94
modules/state-read.nix
Normal file
@@ -0,0 +1,94 @@
|
||||
# Pure state.json loader — fail closed with a short, actionable
|
||||
# message instead of a raw `readFile` / `fromJSON` stack buried in a
|
||||
# consumer. Used by modules/home/theme.nix (required path), the NixOS
|
||||
# stateFile consumers, and lib.mkFlake (early gate).
|
||||
#
|
||||
# Field-level schema checks stay in theme.nix (post-defaults). This file
|
||||
# only gates *existence* and *JSON-shape* so the first failure the user
|
||||
# sees points at the state file, not at nightlight.nix.
|
||||
#
|
||||
# Callers may still pass a legacy path (state.json); messages name
|
||||
# whatever path they gave so a half-migrated checkout is still debuggable.
|
||||
{ lib }:
|
||||
|
||||
path:
|
||||
|
||||
let
|
||||
pathStr = toString path;
|
||||
baseName = baseNameOf pathStr;
|
||||
|
||||
tip = ''
|
||||
Fix:
|
||||
• Missing file → copy the template next to your flake.nix:
|
||||
templates/downstream/state.json
|
||||
or regenerate from a preset:
|
||||
nomarchy-state-sync apply boreal
|
||||
• Bad syntax / wrong shape → edit ${baseName} (trailing commas
|
||||
are the usual culprit) or reset with `apply` as above.
|
||||
• Field-level schema (colors, ui, border, …):
|
||||
nomarchy-state-sync validate
|
||||
Eval-time field checks live in modules/home/theme.nix.
|
||||
• Still on theme-state.json? Easiest is any menu write /
|
||||
`nomarchy-state-sync set` — it migrates and stages the rename
|
||||
for you. Renaming by hand needs `git add state.json` too.
|
||||
• File IS on disk but eval still says missing? On a git flake only
|
||||
*tracked* files exist — an untracked state.json is invisible:
|
||||
git add state.json'';
|
||||
|
||||
missingMsg = ''
|
||||
|
||||
Nomarchy: state file is missing:
|
||||
${pathStr}
|
||||
|
||||
This file is required (appearance + menu settings). Add state.json to
|
||||
your flake checkout — git-tracked — so evaluation stays pure.
|
||||
${tip}'';
|
||||
|
||||
emptyMsg = ''
|
||||
|
||||
Nomarchy: state file is empty:
|
||||
${pathStr}
|
||||
${tip}'';
|
||||
|
||||
notObjectMsg = ''
|
||||
|
||||
Nomarchy: state file must be a JSON object `{ ... }`:
|
||||
${pathStr}
|
||||
${tip}'';
|
||||
|
||||
# lib.trim / lib.strings.trim — strip leading/trailing whitespace so an
|
||||
# all-whitespace file counts as empty and a BOM-less `{` is recognized.
|
||||
strip = s:
|
||||
let
|
||||
# Prefer lib.trim when present (nixpkgs ≥ 23.11); fall back so a
|
||||
# very old pin still gates missing/non-object.
|
||||
trim =
|
||||
if lib ? trim then lib.trim
|
||||
else if lib.strings ? trim then lib.strings.trim
|
||||
else (x: x);
|
||||
in trim s;
|
||||
|
||||
in
|
||||
if !(builtins.pathExists path) then
|
||||
throw missingMsg
|
||||
else
|
||||
let
|
||||
raw = builtins.readFile path;
|
||||
stripped = strip raw;
|
||||
in
|
||||
if stripped == "" then
|
||||
throw emptyMsg
|
||||
# Reject non-objects before fromJSON where we can (null / array / string
|
||||
# literals). Subtle syntax errors still surface from fromJSON itself —
|
||||
# those messages already include line/column; the path tip above is the
|
||||
# part a raw stack used to bury.
|
||||
else if builtins.match "[[:space:]]*\\{.*" stripped == null then
|
||||
throw notObjectMsg
|
||||
else
|
||||
let
|
||||
value = builtins.fromJSON raw;
|
||||
in
|
||||
if !(builtins.isAttrs value) then
|
||||
throw notObjectMsg
|
||||
else
|
||||
value
|
||||
124
pkgs/nomarchy-airplane/default.nix
Normal file
124
pkgs/nomarchy-airplane/default.nix
Normal file
@@ -0,0 +1,124 @@
|
||||
{ lib
|
||||
, writeShellScriptBin
|
||||
, networkmanager
|
||||
, util-linux
|
||||
, bluez
|
||||
, coreutils
|
||||
, gnugrep
|
||||
, gawk
|
||||
, libnotify
|
||||
, procps
|
||||
}:
|
||||
|
||||
# Runtime Wi-Fi + Bluetooth airplane mode (#104). Session-scoped state under XDG
|
||||
# $XDG_RUNTIME_DIR; Waybar status self-hides when off (signal 11).
|
||||
writeShellScriptBin "nomarchy-airplane" ''
|
||||
set -euo pipefail
|
||||
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
state_file="$rt/nomarchy-airplane.state"
|
||||
# Prefer an existing PATH prefix (test mocks, user wrappers) then the
|
||||
# closed store tools so a headless check can inject fake nmcli/rfkill.
|
||||
PATH=$PATH:${lib.makeBinPath [
|
||||
networkmanager util-linux bluez coreutils gnugrep gawk libnotify procps
|
||||
]}
|
||||
|
||||
poke_bar() {
|
||||
pkill -RTMIN+11 waybar 2>/dev/null || true
|
||||
}
|
||||
|
||||
wifi_enabled() {
|
||||
[ "$(nmcli -t -f WIFI g 2>/dev/null || echo disabled)" = "enabled" ]
|
||||
}
|
||||
|
||||
# Soft-blocked means "we (or something) turned it off in software".
|
||||
# Hard-blocked is a laptop kill-switch — we never try to fight that.
|
||||
bt_soft_unblocked() {
|
||||
rfkill list bluetooth 2>/dev/null | grep -q 'Soft blocked: no'
|
||||
}
|
||||
|
||||
bt_powered() {
|
||||
bluetoothctl show 2>/dev/null | grep -q 'Powered: yes'
|
||||
}
|
||||
|
||||
# "Bluetooth was usable" = adapter present and not soft-blocked (or
|
||||
# already powered). Desktops without BT leave this false.
|
||||
bt_was_on() {
|
||||
ls /sys/class/bluetooth/hci* >/dev/null 2>&1 || return 1
|
||||
bt_powered || bt_soft_unblocked
|
||||
}
|
||||
|
||||
engaged() { [ -f "$state_file" ]; }
|
||||
|
||||
engage() {
|
||||
if engaged; then
|
||||
notify-send -a Nomarchy "Airplane mode" "Already on." 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
wifi_prior=0
|
||||
bt_prior=0
|
||||
wifi_enabled && wifi_prior=1
|
||||
bt_was_on && bt_prior=1
|
||||
|
||||
# Wi-Fi via NetworkManager so nm-applet/waybar stay consistent;
|
||||
# fall back to rfkill if nmcli is missing (shouldn't happen on
|
||||
# a Nomarchy desktop).
|
||||
if command -v nmcli >/dev/null 2>&1; then
|
||||
nmcli radio wifi off 2>/dev/null || true
|
||||
else
|
||||
rfkill block wlan 2>/dev/null || true
|
||||
fi
|
||||
# Bluetooth: soft-block the radio (covers adapters that ignore
|
||||
# bluetoothctl) and ask the daemon to power off when present.
|
||||
rfkill block bluetooth 2>/dev/null || true
|
||||
bluetoothctl power off >/dev/null 2>&1 || true
|
||||
|
||||
printf 'wifi_prior=%s\nbt_prior=%s\n' "$wifi_prior" "$bt_prior" > "$state_file"
|
||||
poke_bar
|
||||
notify-send -a Nomarchy "Airplane mode" "On — Wi-Fi and Bluetooth off." 2>/dev/null || true
|
||||
}
|
||||
|
||||
disengage() {
|
||||
if ! engaged; then
|
||||
notify-send -a Nomarchy "Airplane mode" "Already off." 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
. "$state_file"
|
||||
wifi_prior=''${wifi_prior:-0}
|
||||
bt_prior=''${bt_prior:-0}
|
||||
|
||||
if [ "$wifi_prior" = 1 ]; then
|
||||
if command -v nmcli >/dev/null 2>&1; then
|
||||
nmcli radio wifi on 2>/dev/null || true
|
||||
else
|
||||
rfkill unblock wlan 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
if [ "$bt_prior" = 1 ]; then
|
||||
rfkill unblock bluetooth 2>/dev/null || true
|
||||
bluetoothctl power on >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
rm -f "$state_file"
|
||||
poke_bar
|
||||
notify-send -a Nomarchy "Airplane mode" "Off — prior radio state restored." 2>/dev/null || true
|
||||
}
|
||||
|
||||
case "''${1:-toggle}" in
|
||||
status)
|
||||
# Waybar: plane glyph only while engaged; empty → self-hide.
|
||||
if engaged; then
|
||||
printf '{"text":"","tooltip":"Airplane mode on — Wi-Fi and Bluetooth off (click to restore)","class":"on"}\n'
|
||||
fi
|
||||
exit 0 ;;
|
||||
is-active)
|
||||
if engaged; then echo on; else echo off; fi ;;
|
||||
on) engage ;;
|
||||
off) disengage ;;
|
||||
toggle)
|
||||
if engaged; then disengage; else engage; fi ;;
|
||||
*)
|
||||
echo "usage: nomarchy-airplane [toggle|status|on|off|is-active]" >&2
|
||||
exit 64 ;;
|
||||
esac
|
||||
''
|
||||
12
pkgs/nomarchy-battery-notify/default.nix
Normal file
12
pkgs/nomarchy-battery-notify/default.nix
Normal file
@@ -0,0 +1,12 @@
|
||||
# Low-battery notification watcher (nomarchy.batteryNotify). A package
|
||||
# (not an inline script) so the VM check can exercise the crossing logic
|
||||
# on a minimal node against a test_power fake battery — same reasoning
|
||||
# as nomarchy-doctor. libnotify is deliberately NOT a runtimeInput: the
|
||||
# user unit puts it on PATH; the check shims notify-send instead.
|
||||
{ writeShellApplication, coreutils }:
|
||||
|
||||
writeShellApplication {
|
||||
name = "nomarchy-battery-notify";
|
||||
runtimeInputs = [ coreutils ];
|
||||
text = builtins.readFile ./nomarchy-battery-notify.sh;
|
||||
}
|
||||
58
pkgs/nomarchy-battery-notify/nomarchy-battery-notify.sh
Normal file
58
pkgs/nomarchy-battery-notify/nomarchy-battery-notify.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
# Low-battery notifications — the session-side complement to the bar's
|
||||
# battery colors: Waybar paints the module @warn at 25% and @bad at 10%
|
||||
# (waybar.nix battery.states) but nothing *notified*. This watcher polls
|
||||
# the same sysfs state the bar reads and fires exactly one notification
|
||||
# per downward crossing — normal at 25%, critical at 10% (swaync keeps
|
||||
# critical toasts up until dismissed) — re-arming once the charger lands.
|
||||
#
|
||||
# notify-send is resolved from PATH on purpose (not a runtimeInput): the
|
||||
# unit (battery-notify.nix) provides libnotify; the VM check shims it to
|
||||
# capture the calls.
|
||||
#
|
||||
# usage: nomarchy-battery-notify [poll-interval-seconds]
|
||||
|
||||
interval="${1:-30}"
|
||||
warn=25
|
||||
crit=10
|
||||
|
||||
# System batteries only: type Battery, and not scope=Device (how
|
||||
# peripheral batteries — mice, headsets — report). Name-agnostic on
|
||||
# purpose: BAT0, dual-battery BAT0+BAT1, CMB0, test_battery all match.
|
||||
batteries() {
|
||||
local d
|
||||
for d in /sys/class/power_supply/*/; do
|
||||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||||
printf '%s\n' "$d"
|
||||
done
|
||||
}
|
||||
|
||||
# Self-gate: no battery, not a laptop — a clean no-op on desktops.
|
||||
[ -n "$(batteries)" ] || exit 0
|
||||
|
||||
state=ok # ok → warn → crit, one notification per downward crossing
|
||||
while :; do
|
||||
sum=0 n=0 discharging=
|
||||
while IFS= read -r d; do
|
||||
cap=$(cat "$d/capacity" 2>/dev/null) || continue
|
||||
sum=$((sum + cap)); n=$((n + 1))
|
||||
[ "$(cat "$d/status" 2>/dev/null)" = Discharging ] && discharging=1
|
||||
done < <(batteries)
|
||||
|
||||
if [ "$n" -gt 0 ] && [ -n "$discharging" ]; then
|
||||
cap=$((sum / n))
|
||||
if [ "$cap" -le "$crit" ] && [ "$state" != crit ]; then
|
||||
notify-send -u critical -a Nomarchy "Battery critical: ${cap}%" \
|
||||
"Plug in now."
|
||||
state=crit
|
||||
elif [ "$cap" -le "$warn" ] && [ "$state" = ok ]; then
|
||||
notify-send -u normal -a Nomarchy "Battery low: ${cap}%" \
|
||||
"Consider plugging in."
|
||||
state=warn
|
||||
fi
|
||||
else
|
||||
# On the charger (or nothing readable): re-arm for the next drain.
|
||||
state=ok
|
||||
fi
|
||||
sleep "$interval"
|
||||
done
|
||||
43
pkgs/nomarchy-detect-hw/default.nix
Normal file
43
pkgs/nomarchy-detect-hw/default.nix
Normal file
@@ -0,0 +1,43 @@
|
||||
# Post-install hardware re-probe (BACKLOG #58 / HARDWARE.md §8).
|
||||
# Same pure nomarchy_detect_hw protocol as the installer; prints suggested
|
||||
# hardwareProfile + system.nix snippets. Never rewrites the flake.
|
||||
{ lib
|
||||
, stdenvNoCC
|
||||
, makeWrapper
|
||||
, bash
|
||||
, coreutils
|
||||
, pciutils
|
||||
, usbutils
|
||||
, util-linux
|
||||
}:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "nomarchy-detect-hw";
|
||||
version = "0.1.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
share=$out/share/nomarchy-detect-hw
|
||||
install -Dm644 ${../nomarchy-install/hardware-db.sh} "$share/hardware-db.sh"
|
||||
install -Dm755 nomarchy-detect-hw.sh $out/bin/nomarchy-detect-hw
|
||||
patchShebangs $out/bin/nomarchy-detect-hw
|
||||
|
||||
wrapProgram $out/bin/nomarchy-detect-hw \
|
||||
--prefix PATH : ${lib.makeBinPath [ bash coreutils pciutils usbutils util-linux ]} \
|
||||
--set NOMARCHY_DETECT_HW_SHARE "$share"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Probe hardware and print Nomarchy hardwareProfile / system.nix suggestions";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "nomarchy-detect-hw";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
134
pkgs/nomarchy-detect-hw/nomarchy-detect-hw.sh
Normal file
134
pkgs/nomarchy-detect-hw/nomarchy-detect-hw.sh
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# nomarchy-detect-hw — post-install re-probe (BACKLOG #58 / HARDWARE.md §8).
|
||||
#
|
||||
# Runs the same pure nomarchy_detect_hw protocol as the installer and
|
||||
# prints suggested hardwareProfile + system.nix snippets. Never rewrites
|
||||
# the flake (no --apply).
|
||||
#
|
||||
# Usage:
|
||||
# nomarchy-detect-hw # human-readable report + snippets
|
||||
# nomarchy-detect-hw --raw # MODULE / NOMARCHY / DETAIL lines only
|
||||
set -euo pipefail
|
||||
|
||||
SHARE="${NOMARCHY_DETECT_HW_SHARE:?nomarchy-detect-hw: not run via the packaged wrapper}"
|
||||
# shellcheck source=/dev/null
|
||||
source "$SHARE/hardware-db.sh"
|
||||
|
||||
raw=false
|
||||
case "${1:-}" in
|
||||
--raw|-r) raw=true ;;
|
||||
-h|--help)
|
||||
cat <<'EOF'
|
||||
Usage: nomarchy-detect-hw [--raw]
|
||||
|
||||
Probe this machine the same way the live installer does and print
|
||||
suggested flake hardwareProfile and system.nix hardware lines.
|
||||
|
||||
--raw emit only MODULE / NOMARCHY / NOMARCHY-NPU / DETAIL lines
|
||||
(installer protocol). Default is a human report + snippets.
|
||||
|
||||
Does not modify any files. Paste the snippets into ~/.nomarchy after review.
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
"") ;;
|
||||
*)
|
||||
echo "nomarchy-detect-hw: unknown option: $1 (try --help)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if $raw; then
|
||||
nomarchy_detect_hw
|
||||
exit 0
|
||||
fi
|
||||
|
||||
profiles=()
|
||||
nomarchy_lines=()
|
||||
npu=""
|
||||
details=()
|
||||
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
MODULE\ *) profiles+=("${line#MODULE }") ;;
|
||||
NOMARCHY\ *) nomarchy_lines+=("${line#NOMARCHY }") ;;
|
||||
NOMARCHY-NPU\ *) npu="${line#NOMARCHY-NPU }" ;;
|
||||
DETAIL\ *) details+=("${line#DETAIL }") ;;
|
||||
esac
|
||||
done < <(nomarchy_detect_hw)
|
||||
|
||||
echo "nomarchy-detect-hw — suggestions for this machine"
|
||||
echo "(read-only; paste into ~/.nomarchy after review)"
|
||||
echo
|
||||
|
||||
if ((${#details[@]})); then
|
||||
echo "## Detected"
|
||||
for d in "${details[@]}"; do
|
||||
printf ' · %s\n' "$d"
|
||||
done
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "## flake.nix — hardwareProfile"
|
||||
if ((${#profiles[@]})); then
|
||||
echo " hardwareProfile = ["
|
||||
for p in "${profiles[@]}"; do
|
||||
printf ' "%s"\n' "$p"
|
||||
done
|
||||
echo " ];"
|
||||
else
|
||||
echo " # (no modules detected — leave hardwareProfile unset or null)"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "## system.nix — nomarchy.hardware / power (safe defaults active;"
|
||||
echo "## heavier opt-ins stay commented, same as the installer)"
|
||||
if ((${#profiles[@]})) && printf '%s\n' "${profiles[@]}" | grep -qx 'common-pc-laptop'; then
|
||||
echo " nomarchy.system.power.laptop = true;"
|
||||
echo " # nomarchy.system.power.batteryChargeLimit = 80;"
|
||||
fi
|
||||
for nm in "${nomarchy_lines[@]:-}"; do
|
||||
case "$nm" in
|
||||
hardware.intel.enable=true)
|
||||
echo " nomarchy.hardware.intel.enable = true; # GuC/HuC (i915)"
|
||||
echo " # nomarchy.hardware.intel.computeRuntime = true; # OpenCL/oneVPL (opt-in)"
|
||||
;;
|
||||
hardware.intel.guc=false)
|
||||
echo " nomarchy.hardware.intel.guc = false; # xe driver → GuC default-on"
|
||||
;;
|
||||
hardware.amd.enable=true)
|
||||
echo " nomarchy.hardware.amd.enable = true; # amd-pstate + VA-API"
|
||||
echo " # nomarchy.hardware.amd.rocm.enable = true; # ROCm (multi-GB, opt-in)"
|
||||
echo ' # nomarchy.hardware.amd.rocm.gfxOverride = ""; # e.g. "11.0.0" for unlisted iGPU'
|
||||
;;
|
||||
hardware.fingerprint.enable=true)
|
||||
echo " nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)"
|
||||
echo " # nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)"
|
||||
;;
|
||||
hardware.camera.hideIrSensor=true)
|
||||
echo " nomarchy.hardware.camera.hideIrSensor = true; # dual-sensor: hide IR node"
|
||||
;;
|
||||
*)
|
||||
echo " # (unmapped NOMARCHY line: $nm)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [[ -n "$npu" ]]; then
|
||||
echo " # nomarchy.hardware.npu.enable = true; # $npu NPU (experimental; userspace BYO)"
|
||||
echo " # nomarchy.hardware.latestKernel = true; # if the NPU driver needs a newer kernel"
|
||||
fi
|
||||
if printf '%s\n' "${profiles[@]:-}" | grep -qx 'common-gpu-nvidia'; then
|
||||
echo " # NVIDIA: common-gpu-nvidia is in hardwareProfile (flake.nix)."
|
||||
echo " # Hybrid/PRIME, power, open-module — plain NixOS; see docs/HARDWARE.md §6"
|
||||
echo " # hardware.nvidia.prime = { ... };"
|
||||
echo " # hardware.nvidia.powerManagement.enable = true;"
|
||||
echo " # hardware.nvidia.open = false; # or true on newer cards"
|
||||
fi
|
||||
if ((${#nomarchy_lines[@]} == 0)) && [[ -z "$npu" ]] \
|
||||
&& ! printf '%s\n' "${profiles[@]:-}" | grep -qx 'common-gpu-nvidia' \
|
||||
&& ! printf '%s\n' "${profiles[@]:-}" | grep -qx 'common-pc-laptop'; then
|
||||
echo " # (no nomarchy.hardware lines suggested)"
|
||||
fi
|
||||
echo
|
||||
echo "Apply with: sudo nixos-rebuild switch --flake \${NOMARCHY_PATH:-\$HOME/.nomarchy}#default"
|
||||
echo "Docs: docs/HARDWARE.md §8"
|
||||
10
pkgs/nomarchy-doctor/default.nix
Normal file
10
pkgs/nomarchy-doctor/default.nix
Normal file
@@ -0,0 +1,10 @@
|
||||
# One-shot read-only health check (System › Doctor / `nomarchy-doctor`).
|
||||
# A package (not an inline script) so the VM check can exercise it on a
|
||||
# minimal node without pulling in the whole distro module.
|
||||
{ writeShellApplication, coreutils, gawk, git, gnugrep, jq, systemd }:
|
||||
|
||||
writeShellApplication {
|
||||
name = "nomarchy-doctor";
|
||||
runtimeInputs = [ coreutils gawk git gnugrep jq systemd ];
|
||||
text = builtins.readFile ./nomarchy-doctor.sh;
|
||||
}
|
||||
452
pkgs/nomarchy-doctor/nomarchy-doctor.sh
Normal file
452
pkgs/nomarchy-doctor/nomarchy-doctor.sh
Normal file
@@ -0,0 +1,452 @@
|
||||
# nomarchy-doctor — one-shot, READ-ONLY health check: the things that
|
||||
# actually break user machines, as a pass/fail sheet where every
|
||||
# failure prints the one command that fixes it. It never changes
|
||||
# anything itself. Exit 0 = no failures (warnings allowed), 1 = at
|
||||
# least one ✖.
|
||||
|
||||
flake="${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||
fails=0
|
||||
warns=0
|
||||
|
||||
grn=$'\033[32m'; red=$'\033[31m'; ylw=$'\033[33m'; dim=$'\033[2m'; rst=$'\033[0m'
|
||||
ok() { printf ' %b✔%b %s\n' "$grn" "$rst" "$1"; }
|
||||
bad() { printf ' %b✖%b %s\n' "$red" "$rst" "$1"
|
||||
printf ' %bfix: %s%b\n' "$dim" "$2" "$rst"
|
||||
fails=$((fails + 1)); }
|
||||
warn() { printf ' %b●%b %s\n' "$ylw" "$rst" "$1"
|
||||
if [ $# -gt 1 ]; then printf ' %b%s%b\n' "$dim" "$2" "$rst"; fi
|
||||
warns=$((warns + 1)); }
|
||||
skip() { printf ' %b– %s%b\n' "$dim" "$1" "$rst"; }
|
||||
|
||||
printf 'nomarchy-doctor — %s\n\n' "$(date '+%Y-%m-%d %H:%M')"
|
||||
|
||||
# ── systemd units ────────────────────────────────────────────────────
|
||||
mapfile -t sysfailed < <(systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
|
||||
if [ "${#sysfailed[@]}" -eq 0 ]; then
|
||||
ok "no failed system units"
|
||||
else
|
||||
bad "failed system unit(s): ${sysfailed[*]}" \
|
||||
"journalctl -b -u <unit> (read why; sys-rebuild after fixing)"
|
||||
fi
|
||||
|
||||
# A user bus only exists inside a session (not over bare SSH/root).
|
||||
if [ -n "$(systemctl --user is-system-running 2>/dev/null || true)" ]; then
|
||||
mapfile -t usrfailed < <(systemctl --user --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
|
||||
if [ "${#usrfailed[@]}" -eq 0 ]; then
|
||||
ok "no failed user units"
|
||||
else
|
||||
bad "failed user unit(s): ${usrfailed[*]}" \
|
||||
"journalctl --user -b -u <unit>"
|
||||
fi
|
||||
else
|
||||
skip "user units (no user session bus here)"
|
||||
fi
|
||||
|
||||
# ── disk space ───────────────────────────────────────────────────────
|
||||
# Only real on-disk filesystems; skips the tmpfs/9p/overlay mounts of
|
||||
# live systems and test VMs (where usage numbers mean nothing).
|
||||
seen=""
|
||||
for mp in / /boot /nix; do
|
||||
[ -d "$mp" ] || continue
|
||||
line=$(df -PT "$mp" 2>/dev/null | awk 'NR==2 {gsub("%","",$6); print $1, $2, $6, $5}')
|
||||
[ -n "$line" ] || continue
|
||||
read -r dev fstype use availkb <<<"$line"
|
||||
case "$fstype" in
|
||||
ext2|ext3|ext4|btrfs|xfs|vfat|f2fs|zfs) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
case " $seen " in *" $dev "*) continue ;; esac
|
||||
seen="$seen $dev"
|
||||
avail=$(numfmt --to=iec $((availkb * 1024)))
|
||||
if [ "$use" -ge 90 ]; then
|
||||
bad "$mp is ${use}% full (${avail} free)" \
|
||||
"sudo nomarchy-gen-prune (14d + keep ≥3 past system/HM gens; then reclaims store)"
|
||||
else
|
||||
ok "$mp has space (${use}% used, ${avail} free)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── the flake + state file ───────────────────────────────────────────
|
||||
# #107: prefer state.json; theme-state.json still counts until migrated.
|
||||
state_file=
|
||||
if [ -f "$flake/state.json" ]; then
|
||||
state_file="$flake/state.json"
|
||||
elif [ -f "$flake/theme-state.json" ]; then
|
||||
state_file="$flake/theme-state.json"
|
||||
fi
|
||||
if [ -n "$state_file" ]; then
|
||||
state_base=$(basename "$state_file")
|
||||
if jq empty "$state_file" 2>/dev/null; then
|
||||
ok "$state_base parses"
|
||||
else
|
||||
bad "$state_base is not valid JSON (rebuilds will fail)" \
|
||||
"nomarchy-state-sync validate (names the spot; fix it, or re-apply a theme)"
|
||||
fi
|
||||
if git -C "$flake" ls-files --error-unmatch "$state_base" >/dev/null 2>&1; then
|
||||
ok "$state_base is git-tracked"
|
||||
else
|
||||
bad "$state_base is NOT git-tracked (flake evaluation can't see it)" \
|
||||
"git -C $flake add $state_base"
|
||||
fi
|
||||
if [ "$state_base" = "theme-state.json" ]; then
|
||||
warn "state file still named theme-state.json — run any menu write or" \
|
||||
"nomarchy-state-sync set … to migrate to state.json"
|
||||
fi
|
||||
else
|
||||
skip "state.json (no flake checkout at $flake)"
|
||||
fi
|
||||
|
||||
if [ -d "$flake/.git" ]; then
|
||||
dirty=$(git -C "$flake" status --porcelain 2>/dev/null | wc -l)
|
||||
if [ "$dirty" -gt 0 ]; then
|
||||
warn "$dirty uncommitted change(s) in $flake (normal — theme writes land there)" \
|
||||
"commit when happy: git -C $flake add -A && git -C $flake commit -m settings"
|
||||
else
|
||||
ok "flake checkout is clean"
|
||||
fi
|
||||
behind=$(git -C "$flake" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
|
||||
if [ "$behind" -gt 0 ]; then
|
||||
warn "flake is $behind commit(s) behind its upstream" "git -C $flake pull, then sys-rebuild + home-update"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── machine-state drift (survives migration, unseen by the flake) ────
|
||||
# Worked example: systemd linger. The flake never sets it anywhere, so
|
||||
# a "yes" here is leftover state from a previous OS/install, not
|
||||
# something Nomarchy configured — it can cause months of divergent
|
||||
# session behavior that no amount of flake auditing will explain.
|
||||
linger=$(loginctl show-user "${USER:-$(id -un)}" --property=Linger --value 2>/dev/null || true)
|
||||
case "$linger" in
|
||||
'') skip "user linger (user unknown to logind — no session)" ;;
|
||||
yes) warn "user linger is enabled but Nomarchy never sets it (machine state the flake can't see)" \
|
||||
"loginctl disable-linger ${USER:-$(id -un)}" ;;
|
||||
*) ok "no user linger (matches the flake)" ;;
|
||||
esac
|
||||
|
||||
# ── generation age ───────────────────────────────────────────────────
|
||||
# The profile SYMLINK's own mtime is the generation's creation time
|
||||
# (store paths themselves are all epoch-1).
|
||||
link=$(readlink /nix/var/nix/profiles/system 2>/dev/null || true)
|
||||
if [ -n "$link" ] && [ -e "/nix/var/nix/profiles/$link" ]; then
|
||||
ts=$(stat -c %Y "/nix/var/nix/profiles/$link" 2>/dev/null || echo 0)
|
||||
days=$(( ($(date +%s) - ts) / 86400 ))
|
||||
if [ "$days" -gt 30 ]; then
|
||||
warn "last system rebuild was $days days ago" \
|
||||
"sys-update when convenient (security fixes arrive with input bumps)"
|
||||
else
|
||||
ok "system generation is $days day(s) old"
|
||||
fi
|
||||
else
|
||||
skip "system generation age (no system profile)"
|
||||
fi
|
||||
|
||||
# ── generation prune (#128: 14d + keep ≥3 past, system + HM) ─────────
|
||||
if [ -n "$(systemctl list-unit-files nomarchy-gen-prune.timer --no-legend --plain 2>/dev/null)" ]; then
|
||||
if systemctl is-enabled --quiet nomarchy-gen-prune.timer 2>/dev/null; then
|
||||
ok "generation prune timer is enabled (14d, keep ≥3 past)"
|
||||
else
|
||||
bad "nomarchy-gen-prune.timer is installed but not enabled" \
|
||||
"systemctl enable --now nomarchy-gen-prune.timer"
|
||||
fi
|
||||
elif command -v nomarchy-gen-prune >/dev/null 2>&1; then
|
||||
warn "nomarchy-gen-prune is on PATH but no timer unit" \
|
||||
"rebuild the system so gen-prune.nix ships the weekly timer"
|
||||
else
|
||||
skip "generation prune (package not on this machine)"
|
||||
fi
|
||||
|
||||
# ── snapper timeline (when enabled) ──────────────────────────────────
|
||||
if [ -n "$(systemctl list-unit-files snapper-timeline.timer --no-legend --plain 2>/dev/null)" ]; then
|
||||
if systemctl is-active --quiet snapper-timeline.timer; then
|
||||
ok "snapper timeline snapshots are running"
|
||||
else
|
||||
bad "snapper is enabled but the timeline timer is not active" \
|
||||
"systemctl status snapper-timeline.timer (then journalctl -u snapper-timeline.service)"
|
||||
fi
|
||||
else
|
||||
skip "snapper (not enabled on this machine)"
|
||||
fi
|
||||
|
||||
# ── first-boot HM pre-activate (installer fail flag, BACKLOG #83) ────
|
||||
# Installer writes /var/log/nomarchy-hm-preactivate.log on the target.
|
||||
# If that log exists and there is still no Home Manager generation, the
|
||||
# desktop never baked — print the recovery one-liner. Override log path
|
||||
# with NOMARCHY_HM_PREACTIVATE_LOG for checks.doctor.
|
||||
pre_log="${NOMARCHY_HM_PREACTIVATE_LOG:-/var/log/nomarchy-hm-preactivate.log}"
|
||||
hm_gen_present=0
|
||||
uid_name="${USER:-$(id -un 2>/dev/null || echo)}"
|
||||
for d in \
|
||||
"${XDG_STATE_HOME:-${HOME:-}/.local/state}/nix/profiles" \
|
||||
"/nix/var/nix/profiles/per-user/${uid_name}"; do
|
||||
[ -n "$d" ] || continue
|
||||
if [ -e "$d/home-manager" ] \
|
||||
|| ls -d "$d"/home-manager-[0-9]*-link >/dev/null 2>&1; then
|
||||
hm_gen_present=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ ! -r "$pre_log" ]; then
|
||||
skip "first-boot pre-activate (no installer log — not a failed bake)"
|
||||
elif [ "$hm_gen_present" -eq 1 ]; then
|
||||
ok "first-boot pre-activate: desktop generation is present"
|
||||
else
|
||||
bad "desktop was not pre-activated at install (no Home Manager generation)" \
|
||||
"home-manager switch --flake ~/.nomarchy -b bak (details: $pre_log)"
|
||||
fi
|
||||
|
||||
# ══ hardware ═════════════════════════════════════════════════════════
|
||||
# Every check below self-gates: it skips cleanly when the tool, service,
|
||||
# or device isn't present, so the section shrinks to fit the machine and
|
||||
# never fails just because a feature is absent. Still read-only.
|
||||
|
||||
# ── network (NetworkManager) ─────────────────────────────────────────
|
||||
if command -v nmcli >/dev/null 2>&1; then
|
||||
nmstate=$(nmcli -t -f STATE general status 2>/dev/null || true)
|
||||
case "$nmstate" in
|
||||
connected*) ok "NetworkManager: $nmstate" ;;
|
||||
"") skip "NetworkManager (no state reported)" ;;
|
||||
*) warn "NetworkManager state: $nmstate" \
|
||||
"connect in System › Network (or: nmcli device)" ;;
|
||||
esac
|
||||
else
|
||||
skip "NetworkManager (nmcli not present)"
|
||||
fi
|
||||
|
||||
# ── default audio sink (PipeWire) ────────────────────────────────────
|
||||
# wpctl/pactl talk to the user's PipeWire session — only meaningful with
|
||||
# a user bus (not over bare SSH/root).
|
||||
if [ -n "$(systemctl --user is-system-running 2>/dev/null || true)" ]; then
|
||||
if command -v wpctl >/dev/null 2>&1; then
|
||||
if wpctl inspect @DEFAULT_AUDIO_SINK@ >/dev/null 2>&1; then
|
||||
ok "default audio sink present"
|
||||
else
|
||||
warn "no default PipeWire sink" "pick one in System › Audio (or: wpctl status)"
|
||||
fi
|
||||
elif command -v pactl >/dev/null 2>&1; then
|
||||
defsink=$(pactl get-default-sink 2>/dev/null || true)
|
||||
if [ -n "$defsink" ] && [ "$defsink" != "@DEFAULT_SINK@" ]; then
|
||||
ok "default audio sink: $defsink"
|
||||
else
|
||||
warn "no default audio sink" "pick one in System › Audio"
|
||||
fi
|
||||
else
|
||||
skip "audio sink (no wpctl/pactl)"
|
||||
fi
|
||||
else
|
||||
skip "audio sink (no user session bus here)"
|
||||
fi
|
||||
|
||||
# ── GPU acceleration smoke (only if the probes are installed) ────────
|
||||
if command -v vainfo >/dev/null 2>&1; then
|
||||
if vainfo >/dev/null 2>&1; then
|
||||
ok "VA-API acceleration works (vainfo)"
|
||||
else
|
||||
warn "vainfo present but VA-API probe failed" \
|
||||
"check mkFlake.hardwareProfile / GPU drivers (or run: vainfo)"
|
||||
fi
|
||||
else
|
||||
skip "VA-API smoke (vainfo not installed)"
|
||||
fi
|
||||
if command -v glxinfo >/dev/null 2>&1; then
|
||||
if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then
|
||||
if glxinfo -B >/dev/null 2>&1; then
|
||||
ok "OpenGL renderer responds (glxinfo)"
|
||||
else
|
||||
warn "glxinfo present but GL probe failed" \
|
||||
"check mkFlake.hardwareProfile / GPU drivers (or run: glxinfo -B)"
|
||||
fi
|
||||
else
|
||||
skip "OpenGL smoke (no display attached)"
|
||||
fi
|
||||
else
|
||||
skip "OpenGL smoke (glxinfo not installed)"
|
||||
fi
|
||||
|
||||
# ── fingerprint (only when fprintd is enabled) ───────────────────────
|
||||
if [ -n "$(systemctl list-unit-files fprintd.service --no-legend --plain 2>/dev/null || true)" ]; then
|
||||
enrolled=""
|
||||
if command -v fprintd-list >/dev/null 2>&1 && [ -n "${USER:-}" ]; then
|
||||
enrolled=$(fprintd-list "$USER" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -n "$enrolled" ] && ! printf '%s' "$enrolled" | grep -qi 'no fingers'; then
|
||||
ok "fprintd enabled, fingerprint(s) enrolled for ${USER:-user}"
|
||||
elif [ -n "$enrolled" ]; then
|
||||
warn "fprintd enabled but no fingerprints enrolled" "enroll one: fprintd-enroll"
|
||||
else
|
||||
ok "fprintd unit installed (fingerprint enabled)"
|
||||
fi
|
||||
else
|
||||
skip "fingerprint (fprintd not enabled)"
|
||||
fi
|
||||
|
||||
# ── firmware updates (fwupd) ─────────────────────────────────────────
|
||||
# fwupd is D-Bus-activated, so an idle daemon is normal, not a fault.
|
||||
# get-updates reads local metadata (no flashing); a hit is a soft warn.
|
||||
if command -v fwupdmgr >/dev/null 2>&1 \
|
||||
&& [ -n "$(systemctl list-unit-files fwupd.service --no-legend --plain 2>/dev/null || true)" ]; then
|
||||
if systemctl is-active --quiet fwupd.service; then
|
||||
ok "fwupd daemon active"
|
||||
else
|
||||
skip "fwupd daemon (idle — D-Bus-activated on demand)"
|
||||
fi
|
||||
if timeout 20 fwupdmgr get-updates >/dev/null 2>&1; then
|
||||
warn "firmware updates are available" "review, then apply: fwupdmgr update"
|
||||
else
|
||||
ok "firmware up to date (no pending updates)"
|
||||
fi
|
||||
else
|
||||
skip "fwupd (not enabled / fwupdmgr absent)"
|
||||
fi
|
||||
|
||||
# ── laptop battery charge threshold (only when a limit is set) ───────
|
||||
# Name-agnostic (BAT0, CMB0, …) — same type/scope filter as notify (#60).
|
||||
# Dell: Adaptive mode ignores end_threshold while still reporting the
|
||||
# written value — warn if type is not Custom when a limit is active.
|
||||
bat_seen=0; bat_limited=0
|
||||
for bat in /sys/class/power_supply/*/; do
|
||||
[ "$(cat "$bat/type" 2>/dev/null)" = Battery ] || continue
|
||||
[ "$(cat "$bat/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||||
f="$bat/charge_control_end_threshold"
|
||||
[ -r "$f" ] || continue
|
||||
bat_seen=1
|
||||
lim=$(cat "$f" 2>/dev/null || true)
|
||||
case "$lim" in
|
||||
''|*[!0-9]*) continue ;;
|
||||
esac
|
||||
if [ "$lim" -lt 100 ]; then
|
||||
bat_limited=1
|
||||
ctype=$(cat "$bat/charge_types" 2>/dev/null || cat "$bat/charge_type" 2>/dev/null || true)
|
||||
active=$(printf '%s' "$ctype" | sed -n 's/.*\[\([^]]*\)\].*/\1/p')
|
||||
name=$(basename "$bat")
|
||||
if [ -n "$active" ] && [ "$active" != Custom ]; then
|
||||
warn "$name charge limit ${lim}% but type is $active (not Custom)" \
|
||||
"thresholds only apply in Custom — run: systemctl restart nomarchy-battery-charge-limit"
|
||||
else
|
||||
ok "$name charge limit active at ${lim}%${active:+ (type $active)}"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$bat_seen" -eq 1 ] && [ "$bat_limited" -eq 0 ]; then
|
||||
skip "battery charge limit (none set — charges to 100%)"
|
||||
fi
|
||||
|
||||
# ── battery health (report-only, BACKLOG #80) ────────────────────────
|
||||
# cycle_count + retained capacity (charge_* µAh or energy_* µWh). Same
|
||||
# system-battery filter as charge-limit/notify. Self-gates when no
|
||||
# battery or the firmware omits the attrs (desktops, bare VMs). Override
|
||||
# the sysfs root with NOMARCHY_POWER_SUPPLY_ROOT for the checks.doctor
|
||||
# fixture (test_power has no cycle/design attrs).
|
||||
ps_root="${NOMARCHY_POWER_SUPPLY_ROOT:-/sys/class/power_supply}"
|
||||
bat_health_seen=0
|
||||
for bat in "$ps_root"/*/; do
|
||||
[ -d "$bat" ] || continue
|
||||
[ "$(cat "$bat/type" 2>/dev/null)" = Battery ] || continue
|
||||
[ "$(cat "$bat/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||||
name=$(basename "$bat")
|
||||
|
||||
cycles=$(cat "$bat/cycle_count" 2>/dev/null || true)
|
||||
case "$cycles" in ''|*[!0-9]*) cycles= ;; esac
|
||||
# Some firmwares export 0 forever — treat as unknown, not "brand new".
|
||||
if [ -n "$cycles" ] && [ "$cycles" -eq 0 ]; then cycles=; fi
|
||||
|
||||
full=""; design=""
|
||||
if [ -r "$bat/charge_full" ] && [ -r "$bat/charge_full_design" ]; then
|
||||
full=$(cat "$bat/charge_full" 2>/dev/null || true)
|
||||
design=$(cat "$bat/charge_full_design" 2>/dev/null || true)
|
||||
elif [ -r "$bat/energy_full" ] && [ -r "$bat/energy_full_design" ]; then
|
||||
full=$(cat "$bat/energy_full" 2>/dev/null || true)
|
||||
design=$(cat "$bat/energy_full_design" 2>/dev/null || true)
|
||||
fi
|
||||
case "$full" in ''|*[!0-9]*) full= ;; esac
|
||||
case "$design" in ''|*[!0-9]*) design= ;; esac
|
||||
pct=""
|
||||
if [ -n "$full" ] && [ -n "$design" ] && [ "$design" -gt 0 ]; then
|
||||
pct=$(( full * 100 / design ))
|
||||
fi
|
||||
|
||||
[ -n "$cycles" ] || [ -n "$pct" ] || continue
|
||||
bat_health_seen=1
|
||||
|
||||
detail=""
|
||||
[ -n "$cycles" ] && detail="${cycles} cycles"
|
||||
if [ -n "$pct" ]; then
|
||||
[ -n "$detail" ] && detail="$detail, "
|
||||
detail="${detail}${pct}% of design capacity"
|
||||
fi
|
||||
# Soft warn only — wear isn't a doctor "fix", just a heads-up.
|
||||
if [ -n "$pct" ] && [ "$pct" -lt 70 ]; then
|
||||
warn "$name health: $detail" \
|
||||
"battery wear is normal over years — replace when runtime suffers"
|
||||
else
|
||||
ok "$name health: $detail"
|
||||
fi
|
||||
done
|
||||
if [ "$bat_health_seen" -eq 0 ]; then
|
||||
skip "battery health (no system battery / no cycle or design capacity attrs)"
|
||||
fi
|
||||
|
||||
# ── hibernate / sleep (BACKLOG #76) ──────────────────────────────────
|
||||
# Read-only: is there a working hibernate path? zram is RAM-only and can't
|
||||
# hold a resume image, so hibernation needs a disk swap (partition or file)
|
||||
# PLUS resume= wiring; a swapfile additionally needs resume_offset. A
|
||||
# swap=0 machine opts out cleanly and this section skips. Never fails the
|
||||
# sheet — an absent or under-sized swap is advisory, not a running fault.
|
||||
# (|| echo 0 keeps set -euo pipefail happy if a proc file is unreadable.)
|
||||
disk_swap_kb=$(awk 'NR>1 && $1 !~ /zram/ {s+=$3} END{print s+0}' /proc/swaps 2>/dev/null || echo 0)
|
||||
zram_kb=$(awk 'NR>1 && $1 ~ /zram/ {s+=$3} END{print s+0}' /proc/swaps 2>/dev/null || echo 0)
|
||||
ram_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
|
||||
|
||||
if [ "${disk_swap_kb:-0}" -gt 0 ]; then
|
||||
if grep -q 'resume=' /proc/cmdline 2>/dev/null; then
|
||||
ok "hibernate: resume device set on the kernel cmdline"
|
||||
# A swapfile also needs resume_offset to locate the image within it.
|
||||
if awk 'NR>1 && $1 !~ /zram/ && $2=="file"{f=1} END{exit !f}' /proc/swaps 2>/dev/null \
|
||||
&& ! grep -q 'resume_offset=' /proc/cmdline 2>/dev/null; then
|
||||
warn "swapfile in use but no resume_offset= on the cmdline — resume will fail" \
|
||||
"add boot.kernelParams resume_offset (docs/MIGRATION.md → Enabling hibernation)"
|
||||
fi
|
||||
else
|
||||
warn "disk swap present but no resume= on the kernel cmdline — hibernate won't resume" \
|
||||
"set boot.resumeDevice (docs/MIGRATION.md → Enabling hibernation)"
|
||||
fi
|
||||
if [ "${ram_kb:-0}" -gt 0 ] && [ "$disk_swap_kb" -lt "$ram_kb" ]; then
|
||||
warn "disk swap ($(numfmt --to=iec $((disk_swap_kb * 1024)))) is smaller than RAM ($(numfmt --to=iec $((ram_kb * 1024)))) — a full hibernate image may not fit" \
|
||||
"size swap ≥ RAM (docs/MIGRATION.md → Enabling hibernation)"
|
||||
else
|
||||
ok "hibernate: disk swap ≥ RAM ($(numfmt --to=iec $((disk_swap_kb * 1024))))"
|
||||
fi
|
||||
else
|
||||
skip "hibernate (no disk swap — swap=0 opt-out; zram alone can't resume)"
|
||||
fi
|
||||
|
||||
# zram compressed-RAM swap — the memory-pressure layer oom.nix ships on
|
||||
# by default (#76). Its absence on a Nomarchy box means something is off.
|
||||
if [ "${zram_kb:-0}" -gt 0 ]; then
|
||||
ok "zram compressed-RAM swap active ($(numfmt --to=iec $((zram_kb * 1024))))"
|
||||
else
|
||||
warn "zram swap not active (the default memory-pressure layer)" \
|
||||
"expected on by default — check modules/nixos/oom.nix, then sys-rebuild"
|
||||
fi
|
||||
|
||||
# Best-effort: a suspend/hibernate failure recorded in the previous boot's
|
||||
# kernel log. Needs journal read access; silently no-ops without it. The
|
||||
# `|| true` keeps a no-match grep / missing -1 boot from tripping set -e.
|
||||
if command -v journalctl >/dev/null 2>&1; then
|
||||
pmerr=$(journalctl -b -1 -k --no-pager 2>/dev/null \
|
||||
| grep -iE 'PM: .*(hibernat|suspend).*(fail|error)|Failed to (hibernate|suspend)' \
|
||||
| tail -n1 || true)
|
||||
if [ -n "$pmerr" ]; then
|
||||
warn "a suspend/hibernate error is in the previous boot's log" \
|
||||
"review: journalctl -b -1 -k -g 'hibernat|suspend'"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── verdict ──────────────────────────────────────────────────────────
|
||||
echo
|
||||
if [ "$fails" -eq 0 ]; then
|
||||
printf '%b✔ healthy%b — %d warning(s)\n' "$grn" "$rst" "$warns"
|
||||
exit 0
|
||||
else
|
||||
printf '%b✖ %d problem(s)%b, %d warning(s) — each ✖ above shows its fix\n' "$red" "$fails" "$rst" "$warns"
|
||||
exit 1
|
||||
fi
|
||||
11
pkgs/nomarchy-first-boot/default.nix
Normal file
11
pkgs/nomarchy-first-boot/default.nix
Normal file
@@ -0,0 +1,11 @@
|
||||
# First-session "you're set" toast (nomarchy.firstBootWelcome). A package
|
||||
# so checks.first-boot can exercise the gate on a minimal node — same
|
||||
# pattern as nomarchy-battery-notify. libnotify + state-sync stay on PATH
|
||||
# (user unit / VM shim), not runtimeInputs.
|
||||
{ writeShellApplication, coreutils }:
|
||||
|
||||
writeShellApplication {
|
||||
name = "nomarchy-first-boot";
|
||||
runtimeInputs = [ coreutils ];
|
||||
text = builtins.readFile ./nomarchy-first-boot.sh;
|
||||
}
|
||||
117
pkgs/nomarchy-first-boot/nomarchy-first-boot.sh
Normal file
117
pkgs/nomarchy-first-boot/nomarchy-first-boot.sh
Normal file
@@ -0,0 +1,117 @@
|
||||
# nomarchy-first-boot — one-shot "you're set" toast on the first session,
|
||||
# plus a second self-gated toast (hardware hints, VISION § B) pointing at
|
||||
# hardware-specific menu items when the matching tooling is on PATH.
|
||||
# Markers are settings.firstBootShown / settings.hardwareHintsShown in the
|
||||
# flake's state.json (in-checkout state; never ~/.local/state). notify-send
|
||||
# and nomarchy-state-sync come from PATH so the VM check can shim them.
|
||||
|
||||
# Live ISO already has its own welcome (hosts/live.nix); skip there so
|
||||
# users aren't double-toasted and the live seed doesn't get a sticky
|
||||
# firstBootShown write every boot. This also covers the hints stage below.
|
||||
# uname -n is coreutils; avoid depending on a separate hostname package.
|
||||
hn=$(uname -n 2>/dev/null || true)
|
||||
if [ "$hn" = nomarchy-live ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Already shown → skip straight to the hardware-hints stage below. Missing
|
||||
# key / no checkout → treat as not shown (state-sync get exits non-zero
|
||||
# when the key is absent).
|
||||
shown=$(nomarchy-state-sync get settings.firstBootShown 2>/dev/null || true)
|
||||
just_shown=
|
||||
case "$shown" in
|
||||
true|1|yes) : ;;
|
||||
*)
|
||||
# Wait for the notification daemon (swaync). On first login the unit
|
||||
# can race graphical-session and get "Timeout was reached" from
|
||||
# D-Bus — then either no toast, or a toast that never lands while we
|
||||
# still write the marker. Retry notify-send; only persist the marker
|
||||
# after a success.
|
||||
body="SUPER+M menu · SUPER+T themes · SUPER+? keys
|
||||
Wi‑Fi: System › Network (or the bar tray)
|
||||
Anything off? System › Doctor"
|
||||
|
||||
ok=
|
||||
i=0
|
||||
while [ "$i" -lt 8 ]; do
|
||||
if notify-send -a Nomarchy -u normal -t 0 \
|
||||
"You're set" \
|
||||
"$body"; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ -z "$ok" ]; then
|
||||
# Leave firstBootShown alone so the next login can try again. Don't
|
||||
# attempt hardware hints either — they're gated on the card landing.
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Persist in the checkout so re-login is silent. --no-switch: marker
|
||||
# only.
|
||||
if ! nomarchy-state-sync --quiet set settings.firstBootShown true --no-switch; then
|
||||
# No writable flake checkout (or tool missing) — still showed the
|
||||
# toast; without a marker it may reappear next login. Don't fail
|
||||
# the unit, and skip hints too (state-sync isn't writable anyway).
|
||||
exit 0
|
||||
fi
|
||||
just_shown=1
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- hardware hints (post-install hints, VISION § B) ----------------------
|
||||
# At most one additional self-gated toast, once per machine, pointing at
|
||||
# hardware-specific menu items when the matching tooling is on PATH.
|
||||
hints_shown=$(nomarchy-state-sync get settings.hardwareHintsShown 2>/dev/null || true)
|
||||
case "$hints_shown" in
|
||||
true|1|yes) exit 0 ;;
|
||||
esac
|
||||
|
||||
hint_lines=()
|
||||
if command -v fwupdmgr >/dev/null 2>&1; then
|
||||
hint_lines+=("SUPER+M → System › Firmware to check LVFS updates")
|
||||
fi
|
||||
if command -v fprintd-list >/dev/null 2>&1; then
|
||||
hint_lines+=("SUPER+M → System › Fingerprint to enroll a finger")
|
||||
fi
|
||||
|
||||
# No matching hardware/tooling: stay quiet and deliberately don't set the
|
||||
# marker — this re-check is cheap each session, and if the tooling shows
|
||||
# up later (fwupd enabled, a reader added) the hint still fires once.
|
||||
if [ "${#hint_lines[@]}" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
hints=$(printf '%s\n' "${hint_lines[@]}")
|
||||
|
||||
# Just showed the welcome card in this run: give swaync a moment so the
|
||||
# two toasts don't collide.
|
||||
if [ -n "$just_shown" ]; then
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
hok=
|
||||
i=0
|
||||
while [ "$i" -lt 8 ]; do
|
||||
if notify-send -a Nomarchy -u normal -t 0 \
|
||||
"Hardware tips" \
|
||||
"$hints"; then
|
||||
hok=1
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ -z "$hok" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Persist in the checkout so re-login is silent. --no-switch: marker only.
|
||||
if ! nomarchy-state-sync --quiet set settings.hardwareHintsShown true --no-switch; then
|
||||
# No writable flake checkout — still showed the toast; without a marker
|
||||
# it may reappear next login. Don't fail the unit.
|
||||
exit 0
|
||||
fi
|
||||
228
pkgs/nomarchy-gen-prune/default.nix
Normal file
228
pkgs/nomarchy-gen-prune/default.nix
Normal file
@@ -0,0 +1,228 @@
|
||||
{ lib
|
||||
, writeShellScriptBin
|
||||
, coreutils
|
||||
, gnugrep
|
||||
, gawk
|
||||
, nix
|
||||
, findutils
|
||||
}:
|
||||
|
||||
# #128 — prune system + Home Manager generations:
|
||||
# drop only if older than 14 days AND beyond the 3 most recent *past*
|
||||
# gens (current + ≥3 past always kept). Store reclaim is a plain
|
||||
# nix-collect-garbage after (no --delete-older-than — that would ignore
|
||||
# the floor).
|
||||
writeShellScriptBin "nomarchy-gen-prune" ''
|
||||
set -euo pipefail
|
||||
PATH=${lib.makeBinPath [ coreutils gnugrep gawk nix findutils ]}:$PATH
|
||||
|
||||
MAX_AGE_DAYS=''${NOMARCHY_GEN_PRUNE_MAX_AGE_DAYS:-14}
|
||||
KEEP_PAST=''${NOMARCHY_GEN_PRUNE_KEEP_PAST:-3}
|
||||
DRY=0
|
||||
SELFTEST=0
|
||||
|
||||
usage() {
|
||||
echo "usage: nomarchy-gen-prune [--dry-run] [--self-test]" >&2
|
||||
echo " Prune NixOS system + Home Manager profile generations:" >&2
|
||||
echo " delete only if older than ''${MAX_AGE_DAYS}d and beyond the" >&2
|
||||
echo " ''${KEEP_PAST} most recent past gens (current always kept)." >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY=1 ;;
|
||||
--self-test) SELFTEST=1 ;;
|
||||
-h|--help) usage ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { echo "nomarchy-gen-prune: $*" >&2; }
|
||||
|
||||
# Pure selection: stdin lines "NUM EPOCH [current]"
|
||||
# stdout: generation numbers to delete (one per line).
|
||||
# Always protect current + up to KEEP_PAST gens with number < current
|
||||
# (highest first). Others delete only if EPOCH < now - MAX_AGE_DAYS.
|
||||
select_deletions() {
|
||||
local now keep_past max_age
|
||||
now=$(date +%s)
|
||||
keep_past=$KEEP_PAST
|
||||
max_age=$MAX_AGE_DAYS
|
||||
awk -v now="$now" -v keep_past="$keep_past" -v max_age="$max_age" '
|
||||
NF < 2 { next }
|
||||
{
|
||||
num = $1 + 0
|
||||
ts = $2 + 0
|
||||
cur = (NF >= 3 && $3 == "current")
|
||||
nums[n] = num; tss[num] = ts; if (cur) current = num; n++
|
||||
}
|
||||
END {
|
||||
if (n == 0) exit 0
|
||||
if (current == 0) {
|
||||
# No marker: treat highest generation number as current.
|
||||
current = nums[0]
|
||||
for (i = 1; i < n; i++) if (nums[i] > current) current = nums[i]
|
||||
}
|
||||
# Past gens: number < current, sort desc.
|
||||
pn = 0
|
||||
for (i = 0; i < n; i++) {
|
||||
if (nums[i] < current) { past[pn++] = nums[i] }
|
||||
}
|
||||
# bubble-sort past desc (small n)
|
||||
for (i = 0; i < pn; i++)
|
||||
for (j = i + 1; j < pn; j++)
|
||||
if (past[j] > past[i]) { t = past[i]; past[i] = past[j]; past[j] = t }
|
||||
protect[current] = 1
|
||||
for (i = 0; i < pn && i < keep_past; i++) protect[past[i]] = 1
|
||||
cutoff = now - (max_age * 86400)
|
||||
for (i = 0; i < n; i++) {
|
||||
g = nums[i]
|
||||
if (protect[g]) continue
|
||||
if (tss[g] < cutoff) print g
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
if [ "$SELFTEST" = 1 ]; then
|
||||
# Fixture: now-fixed via epoch math in the data itself.
|
||||
# current=10; past 9,8,7 protected; 6 is >14d old → delete; 5 is young → keep.
|
||||
export KEEP_PAST=3 MAX_AGE_DAYS=14
|
||||
now=$(date +%s)
|
||||
old=$((now - 20 * 86400))
|
||||
young=$((now - 2 * 86400))
|
||||
got=$(printf '%s\n' \
|
||||
"10 $young current" \
|
||||
"9 $old" \
|
||||
"8 $old" \
|
||||
"7 $old" \
|
||||
"6 $old" \
|
||||
"5 $young" \
|
||||
| select_deletions)
|
||||
echo "$got" | grep -qx 6 || { echo "self-test: expected only 6, got: [$got]" >&2; exit 1; }
|
||||
# Rarely rebuilt: only old gens → delete none below floor (keep 10+9+8+7)
|
||||
got=$(printf '%s\n' \
|
||||
"10 $old current" \
|
||||
"9 $old" \
|
||||
"8 $old" \
|
||||
"7 $old" \
|
||||
"6 $old" \
|
||||
| select_deletions)
|
||||
echo "$got" | grep -qx 6 || { echo "self-test: floor failed, got: [$got]" >&2; exit 1; }
|
||||
# Only 3 gens total, all old → delete nothing
|
||||
got=$(printf '%s\n' \
|
||||
"3 $old current" \
|
||||
"2 $old" \
|
||||
"1 $old" \
|
||||
| select_deletions)
|
||||
[ -z "$got" ] || { echo "self-test: expected empty, got: [$got]" >&2; exit 1; }
|
||||
log "self-test ok"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Enumerate "NUM EPOCH [current]" for a profile symlink base.
|
||||
# profile is the live link, e.g. /nix/var/nix/profiles/system
|
||||
list_gens() {
|
||||
local profile=$1
|
||||
local dir base cur_base cur_num name num ts
|
||||
[ -e "$profile" ] || [ -L "$profile" ] || return 0
|
||||
dir=$(dirname -- "$profile")
|
||||
base=$(basename -- "$profile")
|
||||
cur_base=$(basename -- "$(readlink "$profile" 2>/dev/null || true)")
|
||||
cur_num=
|
||||
case "$cur_base" in
|
||||
"$base"-*-link)
|
||||
cur_num=''${cur_base#"$base"-}
|
||||
cur_num=''${cur_num%-link}
|
||||
;;
|
||||
esac
|
||||
# shellcheck disable=SC2035
|
||||
for link in "$dir"/"$base"-*-link; do
|
||||
[ -e "$link" ] || [ -L "$link" ] || continue
|
||||
name=$(basename -- "$link")
|
||||
case "$name" in
|
||||
"$base"-*-link) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
num=''${name#"$base"-}
|
||||
num=''${num%-link}
|
||||
case "$num" in *[!0-9]*|"") continue ;; esac
|
||||
ts=$(stat -c %Y -- "$link" 2>/dev/null || echo 0)
|
||||
if [ -n "$cur_num" ] && [ "$num" = "$cur_num" ]; then
|
||||
printf '%s %s current\n' "$num" "$ts"
|
||||
else
|
||||
printf '%s %s\n' "$num" "$ts"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
prune_profile() {
|
||||
local profile=$1 label=$2
|
||||
local list dels
|
||||
if [ ! -e "$profile" ] && [ ! -L "$profile" ]; then
|
||||
log "skip $label (no profile at $profile)"
|
||||
return 0
|
||||
fi
|
||||
list=$(list_gens "$profile" || true)
|
||||
if [ -z "$list" ]; then
|
||||
log "skip $label (no generation links)"
|
||||
return 0
|
||||
fi
|
||||
dels=$(printf '%s\n' "$list" | select_deletions)
|
||||
if [ -z "$dels" ]; then
|
||||
log "$label: nothing to prune"
|
||||
return 0
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
set -- $dels
|
||||
log "$label: delete generations $* (keep current + $KEEP_PAST past; age>$MAX_AGE_DAYS d)"
|
||||
if [ "$DRY" = 1 ]; then
|
||||
log "$label: dry-run — not deleting"
|
||||
return 0
|
||||
fi
|
||||
# nix-env needs a writeable profile; system requires root.
|
||||
nix-env -p "$profile" --delete-generations "$@" \
|
||||
|| log "$label: nix-env --delete-generations failed (need root for system?)"
|
||||
}
|
||||
|
||||
prune_profile /nix/var/nix/profiles/system "system"
|
||||
|
||||
# Home Manager: per-user under /nix/var/nix/profiles and XDG state.
|
||||
if [ -d /nix/var/nix/profiles/per-user ]; then
|
||||
for u in /nix/var/nix/profiles/per-user/*; do
|
||||
[ -d "$u" ] || continue
|
||||
prune_profile "$u/home-manager" "home-manager($(basename "$u"))"
|
||||
done
|
||||
fi
|
||||
# Standalone / modern HM state dir for real users' homes.
|
||||
if [ -d /home ]; then
|
||||
for h in /home/*; do
|
||||
[ -d "$h" ] || continue
|
||||
prune_profile "$h/.local/state/nix/profiles/home-manager" \
|
||||
"home-manager-xdg($(basename "$h"))"
|
||||
done
|
||||
fi
|
||||
# root's XDG state if present
|
||||
prune_profile /root/.local/state/nix/profiles/home-manager "home-manager-xdg(root)"
|
||||
|
||||
if [ "$DRY" = 1 ]; then
|
||||
log "dry-run done (no store GC)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Reclaim store paths freed by gen deletion — no --delete-older-than
|
||||
# (would re-introduce floor-free age GC on any remaining profiles).
|
||||
log "nix-collect-garbage (dead store paths only)"
|
||||
nix-collect-garbage || true
|
||||
|
||||
# Refresh systemd-boot entries so removed system gens disappear from
|
||||
# the menu without waiting for the next rebuild.
|
||||
if [ -x /run/current-system/bin/switch-to-configuration ]; then
|
||||
log "refreshing bootloader entries"
|
||||
/run/current-system/bin/switch-to-configuration boot >/dev/null 2>&1 \
|
||||
|| log "bootloader refresh skipped (non-fatal)"
|
||||
fi
|
||||
|
||||
log "done"
|
||||
''
|
||||
@@ -13,9 +13,11 @@
|
||||
, cryptsetup
|
||||
, lvm2 # dmsetup
|
||||
, pciutils # lspci
|
||||
, usbutils # lsusb (fingerprint-reader VID probe; sysfs is the fallback)
|
||||
, btrfs-progs # inspect-internal map-swapfile (hibernation offset)
|
||||
, xkeyboard_config # human-readable installed layout/variant catalog
|
||||
# Baked metadata — what this installer installs and where it came from.
|
||||
, templateDir # templates/downstream (home.nix, theme-state.json)
|
||||
, templateDir # templates/downstream (home.nix, state.json)
|
||||
, nomarchyLock # the distro's flake.lock (for offline lock composition)
|
||||
, hardwareModuleNames # newline-separated nixos-hardware module names
|
||||
, nixpkgsPath # pinned nixpkgs source (NIX_PATH for disko's eval)
|
||||
@@ -50,16 +52,21 @@ stdenvNoCC.mkDerivation {
|
||||
# Empty flake registry: no network lookups for indirect refs.
|
||||
echo '{"flakes":[],"version":2}' > "$share/registry.json"
|
||||
mkdir -p "$share/template"
|
||||
cp ${templateDir}/home.nix ${templateDir}/theme-state.json "$share/template/"
|
||||
# Full downstream template is the SoT; install script copies + patches.
|
||||
cp ${templateDir}/flake.nix ${templateDir}/system.nix \
|
||||
${templateDir}/home.nix ${templateDir}/state.json \
|
||||
"$share/template/"
|
||||
install -Dm644 patch-template.py "$share/patch-template.py"
|
||||
|
||||
# nixos-install / nixos-generate-config / nixos-enter / nix / systemd
|
||||
# tools come from the live system on purpose — they must match it.
|
||||
wrapProgram $out/bin/nomarchy-install \
|
||||
--prefix PATH : ${lib.makeBinPath [
|
||||
bash gum disko whois git python3
|
||||
util-linux gptfdisk parted cryptsetup lvm2 pciutils btrfs-progs
|
||||
util-linux gptfdisk parted cryptsetup lvm2 pciutils usbutils btrfs-progs
|
||||
]} \
|
||||
--set NOMARCHY_INSTALL_SHARE "$share" \
|
||||
--set NOMARCHY_XKB_RULES ${xkeyboard_config}/share/X11/xkb/rules/base.lst \
|
||||
--set NOMARCHY_NIXPKGS ${nixpkgsPath} \
|
||||
--set NOMARCHY_FLAKE_URL ${lib.escapeShellArg flakeUrl} \
|
||||
--set NOMARCHY_REV ${lib.escapeShellArg (toString rev)} \
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
# proved fragile twice before.
|
||||
{ mainDrive
|
||||
, withLuks ? true
|
||||
, swapSize ? "0" # "0" = no swap; otherwise e.g. "16G" (sized for hibernation)
|
||||
, swapSize ? "0" # "0" or "0G" = no swap; otherwise e.g. "16G" (hibernation-sized)
|
||||
, ...
|
||||
}:
|
||||
|
||||
let
|
||||
btrfsMountOptions = [ "compress=zstd" "noatime" ];
|
||||
# Installer historically always appended "G"; accept both "0" and "0G".
|
||||
noSwap = swapSize == "0" || swapSize == "0G";
|
||||
|
||||
rootBtrfs = {
|
||||
type = "btrfs";
|
||||
@@ -32,7 +34,7 @@ let
|
||||
"@log" = { mountpoint = "/var/log"; mountOptions = btrfsMountOptions; };
|
||||
# snapper timeline snapshots (nomarchy.system.snapper.enable)
|
||||
"@snapshots" = { mountpoint = "/.snapshots"; mountOptions = btrfsMountOptions; };
|
||||
} // (if swapSize == "0" then { } else {
|
||||
} // (if noSwap then { } else {
|
||||
# Hibernation-ready swapfile on its own subvolume; disko's
|
||||
# mkswapfile handles the BTRFS NOCOW requirements.
|
||||
"@swap" = {
|
||||
|
||||
@@ -112,6 +112,7 @@ HARDWARE_DB=(
|
||||
# ----------------------------------------------------------------------------
|
||||
nomarchy_detect_hw() {
|
||||
local sys_vendor product_name cpu_vendor
|
||||
local nvidia=0 amdgpu=0 intelgpu=0
|
||||
sys_vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "")
|
||||
product_name=$(cat /sys/class/dmi/id/product_name 2>/dev/null || echo "")
|
||||
cpu_vendor=$(lscpu 2>/dev/null | awk -F: '/Vendor ID/{gsub(/ /,"",$2); print $2; exit}')
|
||||
@@ -126,7 +127,7 @@ nomarchy_detect_hw() {
|
||||
|
||||
# GPU (lspci may list several; report all)
|
||||
if command -v lspci >/dev/null 2>&1; then
|
||||
local gpu_line nvidia=0 amdgpu=0 intelgpu=0
|
||||
local gpu_line
|
||||
while IFS= read -r gpu_line; do
|
||||
case "$gpu_line" in
|
||||
*"[10de:"*|*"NVIDIA"*) nvidia=1 ;;
|
||||
@@ -140,10 +141,102 @@ nomarchy_detect_hw() {
|
||||
(( intelgpu )) && { echo "MODULE common-gpu-intel"; echo "DETAIL gpu: Intel"; detected=1; }
|
||||
fi
|
||||
|
||||
# Chassis (glob test, not compgen — nixpkgs' non-interactive bash
|
||||
# is built without the completion builtins)
|
||||
local bats=(/sys/class/power_supply/BAT*)
|
||||
if [[ -e "${bats[0]}" ]]; then
|
||||
# ── nomarchy.hardware.* enablement — the gap ABOVE the nixos-hardware
|
||||
# commons (GuC/HuC, amd-pstate, the AMD VA-API env, GPU-compute runtimes,
|
||||
# fprintd, the NPU driver). Emitted as NOMARCHY lines the installer turns
|
||||
# into nomarchy.hardware.* in system.nix; safe bits active, heavy opt-ins
|
||||
# commented.
|
||||
if [[ "$cpu_vendor" == "GenuineIntel" || $intelgpu -eq 1 ]]; then
|
||||
echo "NOMARCHY hardware.intel.enable=true"
|
||||
echo "DETAIL nomarchy.hardware.intel: GuC/HuC on; GPU-compute runtime opt-in"
|
||||
# i915.enable_guc applies to the i915 driver only. The newer `xe` driver
|
||||
# (Lunar Lake / Battlemage / Panther Lake, recent Xe GPUs) enables GuC by
|
||||
# default and ignores the param — so turn the toggle off when xe is bound.
|
||||
if lspci -k 2>/dev/null | grep -qiE 'driver in use: xe\b'; then
|
||||
echo "NOMARCHY hardware.intel.guc=false"
|
||||
echo "DETAIL Intel GPU on the xe driver → GuC default-on (i915 param skipped)"
|
||||
fi
|
||||
fi
|
||||
if [[ "$cpu_vendor" == "AuthenticAMD" || $amdgpu -eq 1 ]]; then
|
||||
echo "NOMARCHY hardware.amd.enable=true"
|
||||
echo "DETAIL nomarchy.hardware.amd: amd-pstate + VA-API on; ROCm opt-in"
|
||||
fi
|
||||
|
||||
# Fingerprint reader — libfprint's common USB vendor IDs (Goodix,
|
||||
# Synaptics/Validity, Elan, EgisTec, Upek, AuthenTec, FocalTech, NB).
|
||||
local have_fp=0
|
||||
if command -v lsusb >/dev/null 2>&1; then
|
||||
local vid
|
||||
for vid in 27c6 06cb 138a 04f3 1c7a 147e 08ff 2808 1fae; do
|
||||
lsusb 2>/dev/null | grep -qiE "ID ${vid}:" && { have_fp=1; break; }
|
||||
done
|
||||
else
|
||||
local f
|
||||
for f in /sys/bus/usb/devices/*/idVendor; do
|
||||
[[ -e "$f" ]] || continue
|
||||
case "$(cat "$f" 2>/dev/null)" in
|
||||
27c6|06cb|138a|04f3|1c7a|147e|08ff|2808|1fae) have_fp=1; break ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
if [[ $have_fp -eq 1 ]]; then
|
||||
echo "NOMARCHY hardware.fingerprint.enable=true"
|
||||
echo "DETAIL fingerprint reader detected → fprintd (PAM login/sudo opt-in)"
|
||||
fi
|
||||
|
||||
# Dual-sensor webcam (RGB + IR face-unlock). Such modules expose the IR
|
||||
# sensor as a SECOND, identically-named "Integrated Camera"; an app that
|
||||
# picks it shows a dark, 8-bit-greyscale image. When an IR-companion node
|
||||
# sits alongside a normal camera node, enable hiding the IR one from
|
||||
# PipeWire (the colour camera stays; the kernel /dev/video* is untouched, so
|
||||
# Howdy can still read the IR sensor directly). Read from /sys — no
|
||||
# v4l2-ctl needed in the installer env.
|
||||
local cam_name have_ir_cam=0 have_color_cam=0 vf
|
||||
local ir_re='Integrated I|IR Camera|Infrared'
|
||||
for vf in /sys/class/video4linux/video*/name; do
|
||||
[[ -e "$vf" ]] || continue
|
||||
cam_name=$(cat "$vf" 2>/dev/null)
|
||||
shopt -s nocasematch
|
||||
if [[ "$cam_name" =~ $ir_re ]]; then
|
||||
have_ir_cam=1
|
||||
elif [[ "$cam_name" =~ (Camera|Webcam) ]]; then
|
||||
have_color_cam=1
|
||||
fi
|
||||
shopt -u nocasematch
|
||||
done
|
||||
if [[ $have_ir_cam -eq 1 && $have_color_cam -eq 1 ]]; then
|
||||
echo "NOMARCHY hardware.camera.hideIrSensor=true"
|
||||
echo "DETAIL dual-sensor webcam (RGB+IR) → hide the IR node so apps get the colour camera"
|
||||
fi
|
||||
|
||||
# NPU (detect-only → a commented, experimental opt-in). Match the PCI
|
||||
# "Processing accelerators" class [1200] (or accelerator keywords) and
|
||||
# attribute by vendor — future-proof vs a per-device-ID list, so new gens
|
||||
# (e.g. Panther Lake) are caught without a code change. Known IDs kept for
|
||||
# reference: Intel VPU 8086:{7d1d,643e,ad1d,b03e}, AMD XDNA 1022:1502.
|
||||
if command -v lspci >/dev/null 2>&1; then
|
||||
local npu_line
|
||||
npu_line=$(lspci -nn 2>/dev/null \
|
||||
| grep -iE '\[1200\]|processing accelerat|neural|\[8086:(7d1d|643e|ad1d|b03e)\]|\[1022:1502\]' \
|
||||
| head -1)
|
||||
case "$npu_line" in
|
||||
*"[8086:"*|*Intel*)
|
||||
echo "NOMARCHY-NPU intel"; echo "DETAIL Intel NPU detected (opt-in, experimental)" ;;
|
||||
*"[1022:"*|*"Advanced Micro Devices"*|*AMD*)
|
||||
echo "NOMARCHY-NPU amd"; echo "DETAIL AMD XDNA NPU detected (opt-in, experimental)" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Chassis: any system battery (type=Battery, not scope=Device) —
|
||||
# name-agnostic BAT0/CMB0/… (BACKLOG #60). Not a bare BAT* glob.
|
||||
local has_bat=0 d
|
||||
for d in /sys/class/power_supply/*/; do
|
||||
[[ "$(cat "$d/type" 2>/dev/null)" == Battery ]] || continue
|
||||
[[ "$(cat "$d/scope" 2>/dev/null || echo System)" == Device ]] && continue
|
||||
has_bat=1
|
||||
break
|
||||
done
|
||||
if (( has_bat )); then
|
||||
echo "MODULE common-pc-laptop"
|
||||
echo "DETAIL chassis: laptop (battery present)"
|
||||
else
|
||||
|
||||
@@ -18,11 +18,129 @@
|
||||
# [NOMARCHY_TIMEZONE=UTC] [NOMARCHY_LUKS_PASSPHRASE=...] \
|
||||
# [NOMARCHY_LOCALE=en_US.UTF-8] [NOMARCHY_KB_LAYOUT=us] [NOMARCHY_KB_VARIANT=] \
|
||||
# [NOMARCHY_SWAP_GB=N (default: RAM size; 0 = none)] \
|
||||
# [NOMARCHY_LUKS_PASSPHRASE=... | NOMARCHY_NO_LUKS=1] \
|
||||
# [NOMARCHY_HW="auto"|"none"|"mod1 mod2"] [NOMARCHY_FINISH=none|reboot|poweroff]
|
||||
# nomarchy-install
|
||||
# Unattended encryption is fail-closed: set a passphrase, or explicit
|
||||
# NOMARCHY_NO_LUKS=1 — never silently install cleartext.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
keyboard_layout_catalog() {
|
||||
local rules
|
||||
rules=$(keyboard_rules_file) || return 1
|
||||
awk '
|
||||
/^! layout/ { in_section=1; next }
|
||||
/^!/ && in_section { exit }
|
||||
in_section && NF { print $1 }
|
||||
' "$rules"
|
||||
}
|
||||
|
||||
keyboard_variant_catalog() {
|
||||
local layout="$1" rules
|
||||
rules=$(keyboard_rules_file) || return 1
|
||||
awk -v wanted_layout="$layout:" '
|
||||
/^! variant/ { in_section=1; next }
|
||||
/^!/ && in_section { exit }
|
||||
in_section && $2 == wanted_layout { print $1 }
|
||||
' "$rules"
|
||||
}
|
||||
|
||||
keyboard_rules_file() {
|
||||
local rules
|
||||
for rules in "${NOMARCHY_XKB_RULES:-}" \
|
||||
/run/current-system/sw/share/X11/xkb/rules/base.lst \
|
||||
/usr/share/X11/xkb/rules/base.lst; do
|
||||
[[ -n "$rules" && -r "$rules" ]] && { echo "$rules"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
keyboard_layout_name() {
|
||||
local layout="$1" rules
|
||||
rules=$(keyboard_rules_file) || { echo "layout $layout"; return; }
|
||||
awk -v wanted="$layout" '
|
||||
/^! layout/ { in_section=1; next }
|
||||
/^!/ && in_section { exit }
|
||||
in_section && $1 == wanted {
|
||||
$1=""; sub(/^[[:space:]]+/, ""); print; found=1; exit
|
||||
}
|
||||
END { if (!found) print "layout " wanted }
|
||||
' "$rules"
|
||||
}
|
||||
|
||||
keyboard_variant_name() {
|
||||
local layout="$1" variant="$2" rules
|
||||
rules=$(keyboard_rules_file) || { echo "$variant key behaviour"; return; }
|
||||
awk -v wanted_layout="$layout:" -v wanted_variant="$variant" '
|
||||
/^! variant/ { in_section=1; next }
|
||||
/^!/ && in_section { exit }
|
||||
in_section && $1 == wanted_variant && $2 == wanted_layout {
|
||||
$1=""; $2=""; sub(/^[[:space:]]+/, ""); print; found=1; exit
|
||||
}
|
||||
END { if (!found) print wanted_variant " key behaviour" }
|
||||
' "$rules"
|
||||
}
|
||||
|
||||
keyboard_layout_choices() {
|
||||
local layout
|
||||
while IFS= read -r layout; do
|
||||
[[ -n "$layout" ]] || continue
|
||||
printf '%s\t%s\n' "$layout" "$(keyboard_layout_name "$layout")"
|
||||
done < <(keyboard_layout_catalog)
|
||||
}
|
||||
|
||||
keyboard_variant_choices() {
|
||||
local layout="$1" variant
|
||||
printf '(none)\tStandard keys (no special variant)\n'
|
||||
while IFS= read -r variant; do
|
||||
[[ -n "$variant" ]] || continue
|
||||
printf '%s\t%s\n' "$variant" "$(keyboard_variant_name "$layout" "$variant")"
|
||||
done < <(keyboard_variant_catalog "$layout")
|
||||
}
|
||||
|
||||
keyboard_catalog_has() {
|
||||
local wanted="$1" candidate
|
||||
while IFS= read -r candidate; do
|
||||
[[ "$candidate" == "$wanted" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Common trust boundary for both gum and unattended input. The picker is a
|
||||
# search over these catalogs, never a text field; this second check means even
|
||||
# surprising gum behaviour or a mistyped NOMARCHY_KB_* value cannot become
|
||||
# generated Nix and fail much later during installation.
|
||||
validate_keyboard_choice() {
|
||||
local layout="$1" variant="$2" layouts variants
|
||||
layouts=$(keyboard_layout_catalog) || {
|
||||
echo "Could not read the installed keyboard-layout catalog." >&2
|
||||
return 2
|
||||
}
|
||||
if [[ -z "$layouts" ]] || ! keyboard_catalog_has "$layout" <<< "$layouts"; then
|
||||
echo "Unknown keyboard layout '$layout'. Choose an installed layout (NOMARCHY_KB_LAYOUT)." >&2
|
||||
return 2
|
||||
fi
|
||||
[[ -z "$variant" ]] && return 0
|
||||
variants=$(keyboard_variant_catalog "$layout") || true
|
||||
if [[ -z "$variants" ]] || ! keyboard_catalog_has "$variant" <<< "$variants"; then
|
||||
echo "Unknown keyboard variant '$variant' for layout '$layout'. Choose an installed variant or no variant (NOMARCHY_KB_VARIANT)." >&2
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
# Small, side-effect-free interface used by the deterministic installer guard.
|
||||
# It deliberately runs before root/live-ISO checks and uses the exact same
|
||||
# validation function as a real install.
|
||||
if [[ "${1:-}" == "--validate-keyboard" ]]; then
|
||||
[[ $# -eq 3 ]] || {
|
||||
echo "usage: nomarchy-install --validate-keyboard <layout> <variant>" >&2
|
||||
exit 64
|
||||
}
|
||||
validate_keyboard_choice "$2" "$3"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# Baked in by the package wrapper:
|
||||
# NOMARCHY_INSTALL_SHARE — disko-config.nix, hardware-db.sh,
|
||||
# compose-lock.py, flake.lock, template/,
|
||||
@@ -54,6 +172,15 @@ if [[ $EUID -ne 0 ]]; then
|
||||
exec sudo --preserve-env "$0" "$@"
|
||||
fi
|
||||
|
||||
# `sudo --preserve-env` (needed to carry the NOMARCHY_* vars) also drags in
|
||||
# the live session user's HOME=/home/nomarchy. Root-run `nix` calls below
|
||||
# would then scribble an eval cache + .nix-defexpr into /home/nomarchy —
|
||||
# and the in-chroot one lands on the TARGET disk as a stray, orphaned
|
||||
# /home/nomarchy (no such user on the installed system). Pin root's own
|
||||
# HOME so every root nix invocation stays in /root; the user activation
|
||||
# sets HOME=/home/$USERNAME explicitly and is unaffected.
|
||||
export HOME=/root
|
||||
|
||||
header "Nomarchy installer" "NixOS, themed and ready to go."
|
||||
|
||||
[[ -d /sys/firmware/efi ]] \
|
||||
@@ -77,7 +204,9 @@ export NIX_CONFIG="flake-registry = $SHARE/registry.json"
|
||||
# substituter resolves to the TARGET store (i.e. itself), so without
|
||||
# this nothing flows from the ISO and nix bootstraps gcc from source.
|
||||
NIXOS_INSTALL_OPTS=()
|
||||
OFFLINE=false
|
||||
if ! timeout 3 bash -c '</dev/tcp/cache.nixos.org/443' 2>/dev/null; then
|
||||
OFFLINE=true
|
||||
info "No network — substituting from the ISO store only."
|
||||
NIX_CONFIG+=$'\nsubstituters =\nextra-substituters = daemon?trusted=1\nbuilders ='
|
||||
# Must ALSO go through nixos-install as a flag: it passes its own
|
||||
@@ -95,13 +224,43 @@ live_disk=""
|
||||
live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true)
|
||||
[[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null || true)
|
||||
|
||||
mapfile -t disks < <(lsblk -dpno NAME,SIZE,MODEL,TYPE \
|
||||
| awk -v skip="/dev/${live_disk:-NONE}" \
|
||||
'$NF == "disk" && $1 != skip && $1 !~ /loop|zram|sr[0-9]/ {NF--; print}')
|
||||
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found."
|
||||
# Installable whole disks only (#112): drop floppy/pseudo/tiny devices that
|
||||
# OVMF and some firmwares expose as TYPE=disk (e.g. /dev/fd0 first in the
|
||||
# list — a blind Enter would erase nothing useful and break the install).
|
||||
# Bytes via lsblk -b; 8 GiB floor catches fd0/USB crumbs; REQUIREMENTS still
|
||||
# recommend ≥40 GiB for a real install.
|
||||
# NOMARCHY_MIN_DISK_BYTES overrides the floor (tests / expert installs).
|
||||
MIN_DISK_BYTES="${NOMARCHY_MIN_DISK_BYTES:-$((8 * 1024 * 1024 * 1024))}"
|
||||
list_installable_disks() {
|
||||
local skip="/dev/${live_disk:-NONE}"
|
||||
local name size type model hsize
|
||||
# NAME SIZE TYPE in bytes (-b); MODEL looked up separately (may contain spaces).
|
||||
{
|
||||
while read -r name size type; do
|
||||
[[ "$type" == "disk" ]] || continue
|
||||
[[ "$name" == "$skip" ]] && continue
|
||||
case "$name" in
|
||||
/dev/loop*|/dev/zram*|/dev/sr*|/dev/fd*|/dev/ram*|/dev/nbd*|/dev/md*)
|
||||
continue ;;
|
||||
esac
|
||||
[[ "$size" =~ ^[0-9]+$ ]] || continue
|
||||
(( size >= MIN_DISK_BYTES )) || continue
|
||||
hsize=$(lsblk -dno SIZE "$name" 2>/dev/null || echo "?")
|
||||
model=$(lsblk -dno MODEL "$name" 2>/dev/null | tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//')
|
||||
printf '%s %s %s\n' "$name" "$hsize" "${model:-}"
|
||||
done < <(lsblk -dpbno NAME,SIZE,TYPE 2>/dev/null)
|
||||
} | sort -k2 -h -r # largest first — never default to a tiny leftover
|
||||
}
|
||||
|
||||
mapfile -t disks < <(list_installable_disks)
|
||||
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found (need a whole disk ≥ $(( MIN_DISK_BYTES / 1024 / 1024 / 1024 )) GiB; floppy/loop/optical excluded)."
|
||||
|
||||
if [[ "$UNATTENDED" == "1" ]]; then
|
||||
TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}"
|
||||
# Unattended still rejects non-installable targets (CI footgun).
|
||||
if ! printf '%s\n' "${disks[@]}" | grep -q "^${TARGET_DISK} "; then
|
||||
fail "$TARGET_DISK is not an installable disk (missing, live medium, or below size floor)."
|
||||
fi
|
||||
else
|
||||
choice=$(printf '%s\n' "${disks[@]}" \
|
||||
| gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)")
|
||||
@@ -110,6 +269,15 @@ fi
|
||||
[[ -b "$TARGET_DISK" ]] || fail "$TARGET_DISK is not a block device."
|
||||
info "Target: $TARGET_DISK"
|
||||
|
||||
# Single whole-disk install only (no dual-boot path) — if the chosen disk
|
||||
# already carries a recognizable OS/filesystem signature, call it out
|
||||
# explicitly before the pre-wipe below destroys it.
|
||||
existing_sig="$(lsblk -no FSTYPE,LABEL "$TARGET_DISK" 2>/dev/null || true
|
||||
blkid "$TARGET_DISK"* 2>/dev/null || true)"
|
||||
if grep -qiE 'ntfs|bitlocker|microsoft|crypto_luks' <<< "$existing_sig"; then
|
||||
warn "$TARGET_DISK has existing data (Windows/BitLocker/NTFS or LUKS) — it will be destroyed."
|
||||
fi
|
||||
|
||||
# ─── Encryption ─────────────────────────────────────────────────────────
|
||||
section "Disk encryption"
|
||||
|
||||
@@ -117,7 +285,15 @@ section "Disk encryption"
|
||||
# logs you straight into the desktop (the passphrase already gates access).
|
||||
LUKS_PASSPHRASE=""
|
||||
if [[ "$UNATTENDED" == "1" ]]; then
|
||||
LUKS_PASSPHRASE="${NOMARCHY_LUKS_PASSPHRASE:-}"
|
||||
# Fail-closed: unattended without a passphrase used to install
|
||||
# cleartext (easy CI footgun). Require an explicit opt-out.
|
||||
if [[ "${NOMARCHY_NO_LUKS:-}" == "1" ]]; then
|
||||
LUKS_PASSPHRASE=""
|
||||
elif [[ -n "${NOMARCHY_LUKS_PASSPHRASE:-}" ]]; then
|
||||
LUKS_PASSPHRASE="$NOMARCHY_LUKS_PASSPHRASE"
|
||||
else
|
||||
fail "Unattended install needs NOMARCHY_LUKS_PASSPHRASE or NOMARCHY_NO_LUKS=1"
|
||||
fi
|
||||
elif gum confirm --default=yes "Encrypt the disk with LUKS? (default — also enables passwordless desktop login)"; then
|
||||
while true; do
|
||||
p1=$(gum input --password --placeholder "LUKS passphrase (min 8 chars)")
|
||||
@@ -133,15 +309,20 @@ info "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 (desktop auto-login)
|
||||
# ─── Swap / hibernation ─────────────────────────────────────────────────
|
||||
# A swapfile ≥ RAM on its own BTRFS subvolume makes hibernation possible;
|
||||
# the resume offset is wired into the config below.
|
||||
section "Swap & hibernation"
|
||||
|
||||
ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)
|
||||
if [[ "$UNATTENDED" == "1" ]]; then
|
||||
SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}"
|
||||
else
|
||||
info "A swapfile sized ≥ RAM lets this machine hibernate (suspend to disk)."
|
||||
info "Default = ${ram_gb} GiB (this machine's RAM). Enter 0 for no swap (disables hibernation)."
|
||||
SWAP_GB=$(gum input --value "$ram_gb" \
|
||||
--header "Swap size in GiB (default ${ram_gb} = RAM, 0 = no swap)" \
|
||||
--placeholder "swap size in GiB (≥ RAM enables hibernation, 0 = none)")
|
||||
fi
|
||||
[[ "$SWAP_GB" =~ ^[0-9]+$ ]] || fail "Swap size must be a whole number of GiB."
|
||||
info "Swap: $([[ "$SWAP_GB" == "0" ]] && echo none || echo "${SWAP_GB}G swapfile (hibernation-ready)")"
|
||||
info "Swap: $([[ "$SWAP_GB" == "0" ]] && echo "none (hibernation disabled)" || echo "${SWAP_GB} GiB swapfile (hibernation-ready)")"
|
||||
|
||||
# ─── User account ───────────────────────────────────────────────────────
|
||||
section "Your account"
|
||||
@@ -161,8 +342,8 @@ else
|
||||
warn "Invalid username (lowercase letters, digits, - and _)."
|
||||
done
|
||||
while true; do
|
||||
PASSWORD=$(gum input --password --placeholder "password for $USERNAME")
|
||||
[[ -n "$PASSWORD" ]] || { warn "Empty password."; continue; }
|
||||
PASSWORD=$(gum input --password --placeholder "password for $USERNAME (min 8 chars)")
|
||||
[[ ${#PASSWORD} -ge 8 ]] || { warn "Too short (min 8 chars)."; continue; }
|
||||
p2=$(gum input --password --placeholder "repeat password")
|
||||
[[ "$PASSWORD" == "$p2" ]] && break
|
||||
warn "Passwords don't match."
|
||||
@@ -183,16 +364,50 @@ else
|
||||
| sed 's/$/.UTF-8/' \
|
||||
| gum filter --placeholder "language / locale (type to search)" \
|
||||
|| echo en_US.UTF-8)
|
||||
KB_LAYOUT=$(localectl list-x11-keymap-layouts 2>/dev/null \
|
||||
| gum filter --placeholder "keyboard layout (type to search)" \
|
||||
|| echo us)
|
||||
keyboard_layouts=$(keyboard_layout_catalog) || \
|
||||
fail "Could not read the installed keyboard-layout catalog."
|
||||
[[ -n "$keyboard_layouts" ]] || \
|
||||
fail "The installed keyboard-layout catalog is empty."
|
||||
if gum confirm --default=yes "Use the standard US English keyboard (no special variant)?"; then
|
||||
KB_LAYOUT="us"
|
||||
KB_VARIANT=""
|
||||
if [[ "$KB_LAYOUT" != "us" ]] || gum confirm --default=No "Pick a keyboard variant (intl, nodeadkeys, …)?"; then
|
||||
KB_VARIANT=$( { echo "(none)"; localectl list-x11-keymap-variants "$KB_LAYOUT" 2>/dev/null; } \
|
||||
| gum filter --placeholder "variant for $KB_LAYOUT (pick '(none)' for the default)" \
|
||||
|| echo "(none)")
|
||||
[[ "$KB_VARIANT" == "(none)" ]] && KB_VARIANT=""
|
||||
else
|
||||
while true; do
|
||||
if keyboard_pick=$(keyboard_layout_choices \
|
||||
| gum filter --strict \
|
||||
--placeholder "keyboard layout — type to search installed choices"); then
|
||||
KB_LAYOUT="${keyboard_pick%%$'\t'*}"
|
||||
if validate_keyboard_choice "$KB_LAYOUT" "" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
warn "Choose one of the listed keyboard layouts; typed text is search only."
|
||||
done
|
||||
while true; do
|
||||
if keyboard_pick=$( \
|
||||
keyboard_variant_choices "$KB_LAYOUT" \
|
||||
| gum filter --strict \
|
||||
--placeholder "key behaviour for $KB_LAYOUT — '(none)' means the standard keys" \
|
||||
); then
|
||||
KB_VARIANT="${keyboard_pick%%$'\t'*}"
|
||||
if [[ "$KB_VARIANT" == "(none)" ]] \
|
||||
|| validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
warn "Choose a listed key behaviour, or '(none)' for the standard keys; typed text is search only."
|
||||
done
|
||||
fi
|
||||
fi
|
||||
# `(none)` is gum's display-only sentinel, never an XKB variant. Keep the
|
||||
# normalization at the common boundary so interactive and unattended installs
|
||||
# cannot write it into either generated Nix file.
|
||||
if [[ "$KB_VARIANT" == "(none)" ]]; then
|
||||
KB_VARIANT=""
|
||||
fi
|
||||
keyboard_error=""
|
||||
if ! keyboard_error=$(validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT" 2>&1); then
|
||||
fail "$keyboard_error"
|
||||
fi
|
||||
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] || fail "Invalid username '$USERNAME'."
|
||||
[[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \
|
||||
@@ -201,7 +416,13 @@ fi
|
||||
HASHED_PASSWORD=$(printf '%s' "$PASSWORD" | mkpasswd -m sha-512 -s)
|
||||
unset PASSWORD
|
||||
info "User: $USERNAME @ $HOSTNAME_ ($TIMEZONE)"
|
||||
info "Locale: $LOCALE · keyboard: $KB_LAYOUT${KB_VARIANT:+ ($KB_VARIANT)}"
|
||||
if [[ -n "$KB_VARIANT" ]]; then
|
||||
KEYBOARD_SUMMARY="$(keyboard_layout_name "$KB_LAYOUT") [$KB_LAYOUT] — $(keyboard_variant_name "$KB_LAYOUT" "$KB_VARIANT") [$KB_VARIANT]"
|
||||
else
|
||||
KEYBOARD_SUMMARY="$(keyboard_layout_name "$KB_LAYOUT") [$KB_LAYOUT] — standard keys (no special variant)"
|
||||
fi
|
||||
info "Locale: $LOCALE"
|
||||
info "Keyboard: $KEYBOARD_SUMMARY"
|
||||
|
||||
# ─── Hardware profile ───────────────────────────────────────────────────
|
||||
section "Hardware detection"
|
||||
@@ -210,6 +431,8 @@ section "Hardware detection"
|
||||
source "$SHARE/hardware-db.sh"
|
||||
|
||||
HW_PROFILES=()
|
||||
HW_NOMARCHY=() # NOMARCHY hardware.* assignments from detection
|
||||
NPU_VENDOR="" # "intel" | "amd" if an NPU was detected (commented opt-in)
|
||||
hw_mode="${NOMARCHY_HW:-auto}"
|
||||
if [[ "$hw_mode" == "none" ]]; then
|
||||
info "Hardware profiles skipped."
|
||||
@@ -221,6 +444,8 @@ else
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
MODULE\ *) HW_PROFILES+=("${line#MODULE }") ;;
|
||||
NOMARCHY-NPU\ *) NPU_VENDOR="${line#NOMARCHY-NPU }" ;;
|
||||
NOMARCHY\ *) HW_NOMARCHY+=("${line#NOMARCHY }") ;;
|
||||
DETAIL\ *) info "→ ${line#DETAIL }" ;;
|
||||
esac
|
||||
done <<< "$detection"
|
||||
@@ -241,16 +466,25 @@ info "Profiles: ${HW_PROFILES[*]:-(none)}"
|
||||
# ─── Review & point of no return ────────────────────────────────────────
|
||||
section "Review"
|
||||
|
||||
# Match the Source line to the same cache.nixos.org probe that sets OFFLINE
|
||||
# (above): offline = ISO store only; online may still hit substituters.
|
||||
if [[ "$OFFLINE" == true ]]; then
|
||||
SOURCE_NET="pinned into the ISO, no network needed"
|
||||
else
|
||||
SOURCE_NET="pinned into the ISO; may use network binary caches"
|
||||
fi
|
||||
|
||||
gum style --border normal --padding "0 2" \
|
||||
"Disk: $TARGET_DISK (WILL BE ERASED)" \
|
||||
"Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 + desktop auto-login" || echo none)" \
|
||||
"Swap: $([[ "$SWAP_GB" == "0" ]] && echo none || echo "${SWAP_GB}G (hibernation)")" \
|
||||
"Swap: $([[ "$SWAP_GB" == "0" ]] && echo "none (hibernation disabled)" || echo "${SWAP_GB} GiB swapfile (enables hibernation)")" \
|
||||
"User: $USERNAME" \
|
||||
"Hostname: $HOSTNAME_" \
|
||||
"Timezone: $TIMEZONE" \
|
||||
"Keyboard: $KEYBOARD_SUMMARY" \
|
||||
"Hardware: ${HW_PROFILES[*]:-none}" \
|
||||
"Snapshots: snapper timeline on /" \
|
||||
"Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")— pinned into the ISO, no network needed"
|
||||
"Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")— $SOURCE_NET"
|
||||
|
||||
if [[ "$UNATTENDED" != "1" ]]; then
|
||||
typed=$(gum input --placeholder "type the disk name ($(basename "$TARGET_DISK")) to confirm the wipe")
|
||||
@@ -304,11 +538,19 @@ if [[ $WITH_LUKS == true ]]; then
|
||||
unset LUKS_PASSPHRASE
|
||||
fi
|
||||
|
||||
# disko-config treats exact "0" as no-swap; "${SWAP_GB}G" would pass "0G"
|
||||
# and still create a useless @swap subvolume (layout vs resume disagreed).
|
||||
if [[ "$SWAP_GB" == "0" ]]; then
|
||||
DISKO_SWAP_SIZE="0"
|
||||
else
|
||||
DISKO_SWAP_SIZE="${SWAP_GB}G"
|
||||
fi
|
||||
|
||||
disko_log=$(mktemp --suffix=.disko.log)
|
||||
if ! disko --mode destroy,format,mount --yes-wipe-all-disks \
|
||||
--argstr mainDrive "$TARGET_DISK" \
|
||||
--arg withLuks "$WITH_LUKS" \
|
||||
--argstr swapSize "${SWAP_GB}G" \
|
||||
--argstr swapSize "$DISKO_SWAP_SIZE" \
|
||||
"$SHARE/disko-config.nix" >"$disko_log" 2>&1; then
|
||||
tail -n 30 "$disko_log"
|
||||
fail "disko failed — full log: $disko_log"
|
||||
@@ -317,21 +559,14 @@ rm -f "$LUKS_KEY_PATH" "$disko_log"
|
||||
success "Disk partitioned and mounted at /mnt"
|
||||
|
||||
# Hibernation plumbing: the swapfile's physical offset goes into the
|
||||
# kernel cmdline. Deactivate swap first so nixos-generate-config doesn't
|
||||
# also emit a swapDevices entry (we write our own, with resume wiring).
|
||||
RESUME_CONFIG=""
|
||||
# kernel cmdline (patched into system.nix). Deactivate swap first so
|
||||
# nixos-generate-config doesn't also emit a swapDevices entry.
|
||||
resume_offset=""
|
||||
root_uuid=""
|
||||
if [[ "$SWAP_GB" != "0" ]]; then
|
||||
swapoff -a 2>/dev/null || true
|
||||
resume_offset=$(btrfs inspect-internal map-swapfile -r /mnt/swap/swapfile)
|
||||
root_uuid=$(findmnt -no UUID /mnt)
|
||||
RESUME_CONFIG=$(cat <<NIX
|
||||
|
||||
# Swapfile (hibernation-ready: resume points into it).
|
||||
swapDevices = [{ device = "/swap/swapfile"; }];
|
||||
boot.resumeDevice = "/dev/disk/by-uuid/$root_uuid";
|
||||
boot.kernelParams = [ "resume_offset=$resume_offset" ];
|
||||
NIX
|
||||
)
|
||||
success "Swapfile created (resume offset $resume_offset)"
|
||||
fi
|
||||
|
||||
@@ -345,138 +580,106 @@ nixos-generate-config --root /mnt
|
||||
mv /mnt/etc/nixos/hardware-configuration.nix "$FLAKE_DIR/"
|
||||
rm -rf /mnt/etc/nixos
|
||||
|
||||
cp "$SHARE/template/theme-state.json" "$FLAKE_DIR/"
|
||||
# templates/downstream is the single source of truth (same files as
|
||||
# `nix flake init -t`). Copy, then patch install-time values only.
|
||||
cp "$SHARE/template/flake.nix" \
|
||||
"$SHARE/template/system.nix" \
|
||||
"$SHARE/template/home.nix" \
|
||||
"$SHARE/template/state.json" \
|
||||
"$FLAKE_DIR/"
|
||||
|
||||
# home.nix is generated (not copied from the template) so the chosen
|
||||
# keyboard layout reaches the Hyprland session — standalone HM cannot
|
||||
# read system.nix.
|
||||
cat > "$FLAKE_DIR/home.nix" <<EOF
|
||||
# Your user environment. The Nomarchy desktop (Hyprland, Waybar,
|
||||
# Ghostty, theming engine, Stylix) comes from homeModules.nomarchy;
|
||||
# tune it via the nomarchy.* options, add your own packages and
|
||||
# programs below.
|
||||
{ pkgs, ... }:
|
||||
# Detected hardware → flags for the patcher (safe defaults active).
|
||||
has_intel=false; has_amd=false; has_fp=false
|
||||
intel_guc_off=false; has_camera_ir=false
|
||||
if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then
|
||||
for nm in "${HW_NOMARCHY[@]}"; do
|
||||
case "$nm" in
|
||||
hardware.intel.enable=true) has_intel=true ;;
|
||||
hardware.intel.guc=false) intel_guc_off=true ;;
|
||||
hardware.amd.enable=true) has_amd=true ;;
|
||||
hardware.fingerprint.enable=true) has_fp=true ;;
|
||||
hardware.camera.hideIrSensor=true) has_camera_ir=true ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
# NVIDIA is a nixos-hardware MODULE only (no nomarchy.hardware.nvidia.*) —
|
||||
# still emit commented plain-NixOS guidance in system.nix (BACKLOG #59).
|
||||
has_nvidia=false
|
||||
[[ " ${HW_PROFILES[*]:-} " == *" common-gpu-nvidia "* ]] && has_nvidia=true
|
||||
is_laptop=false
|
||||
[[ " ${HW_PROFILES[*]:-} " == *" common-pc-laptop "* ]] && is_laptop=true
|
||||
thermald=false
|
||||
[[ $is_laptop == true ]] && grep -q GenuineIntel /proc/cpuinfo 2>/dev/null && thermald=true
|
||||
|
||||
{
|
||||
# Keyboard for the desktop session; console + LUKS prompt get the
|
||||
# same layout from system.nix (xkb + console.useXkbConfig).
|
||||
nomarchy.keyboard.layout = "$KB_LAYOUT";
|
||||
nomarchy.keyboard.variant = "$KB_VARIANT";
|
||||
|
||||
# Examples:
|
||||
# nomarchy.terminal = "kitty"; # swap the default terminal
|
||||
# nomarchy.waybar.enable = false; # bring your own bar
|
||||
# nomarchy.stylix.enable = false; # opt out of GTK/Qt theming
|
||||
|
||||
home.packages = with pkgs; [
|
||||
# firefox
|
||||
];
|
||||
}
|
||||
EOF
|
||||
|
||||
hw_nix=""
|
||||
# JSON for patch-template.py (stdin). Hardware profiles as a JSON array.
|
||||
hw_json="["
|
||||
first=1
|
||||
for p in "${HW_PROFILES[@]:-}"; do
|
||||
[[ -n "$p" ]] && hw_nix+=" \"$p\""
|
||||
[[ -z "$p" ]] && continue
|
||||
if [[ $first -eq 1 ]]; then first=0; else hw_json+=","; fi
|
||||
hw_json+=$(printf '%s' "$p" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
|
||||
done
|
||||
hw_json+="]"
|
||||
|
||||
cat > "$FLAKE_DIR/flake.nix" <<EOF
|
||||
{
|
||||
description = "$HOSTNAME_ — my Nomarchy machine";
|
||||
|
||||
# The only input. nixpkgs, home-manager etc. come pinned through it —
|
||||
# tested together upstream. Generated by nomarchy-install; your machine
|
||||
# lives in system.nix and home.nix, this file is never hand-edited.
|
||||
inputs.nomarchy.url = "${NOMARCHY_FLAKE_URL}";
|
||||
|
||||
outputs = { nomarchy, ... }:
|
||||
nomarchy.lib.mkFlake {
|
||||
src = ./.;
|
||||
username = "$USERNAME";
|
||||
hardwareProfile = [$hw_nix ];
|
||||
};
|
||||
}
|
||||
EOF
|
||||
|
||||
AUTOLOGIN_CONFIG=""
|
||||
if [[ $WITH_LUKS == true ]]; then
|
||||
AUTOLOGIN_CONFIG=$(cat <<NIX
|
||||
|
||||
# The LUKS passphrase already gates this machine — skip the second
|
||||
# password prompt and boot straight into the desktop.
|
||||
nomarchy.system.greeter.autoLogin = "$USERNAME";
|
||||
NIX
|
||||
)
|
||||
resume_json="null"
|
||||
root_uuid_json="null"
|
||||
if [[ -n "$resume_offset" && "$SWAP_GB" != "0" ]]; then
|
||||
resume_json=$(printf '%s' "$resume_offset" | python3 -c 'import json,sys; print(json.dumps(int(sys.stdin.read().strip())))')
|
||||
root_uuid_json=$(printf '%s' "$root_uuid" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
|
||||
fi
|
||||
|
||||
# Laptop power: power-profiles-daemon ships by default; mark this a laptop
|
||||
# so battery-only features apply, and enable thermald on Intel. Keyed off
|
||||
# the same battery probe that chose the common-pc-laptop hardware profile.
|
||||
POWER_CONFIG=""
|
||||
if [[ " ${HW_PROFILES[*]:-} " == *" common-pc-laptop "* ]]; then
|
||||
power_lines=" # Laptop power management (power-profiles-daemon + menu/Waybar
|
||||
# switcher). Uncomment to stop charging at 80% to extend battery life.
|
||||
nomarchy.system.power.laptop = true;
|
||||
# nomarchy.system.power.batteryChargeLimit = 80;"
|
||||
if grep -q GenuineIntel /proc/cpuinfo 2>/dev/null; then
|
||||
power_lines+="
|
||||
nomarchy.system.power.thermal.enable = true; # thermald (Intel)"
|
||||
fi
|
||||
POWER_CONFIG=$(cat <<NIX
|
||||
|
||||
$power_lines
|
||||
NIX
|
||||
)
|
||||
fi
|
||||
|
||||
# initialHashedPassword is safe to template: mkpasswd's alphabet is
|
||||
# [a-zA-Z0-9./$] — no Nix string metacharacters.
|
||||
cat > "$FLAKE_DIR/system.nix" <<EOF
|
||||
# Your machine: hostname, users, services. The distro itself comes from
|
||||
# Nomarchy (via flake.nix); override its defaults here with plain NixOS
|
||||
# options, or the nomarchy.system.* toggles.
|
||||
{ pkgs, username, ... }:
|
||||
|
||||
python3 "$SHARE/patch-template.py" "$FLAKE_DIR" <<PYJSON
|
||||
{
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
|
||||
networking.hostName = "$HOSTNAME_";
|
||||
time.timeZone = "$TIMEZONE";
|
||||
i18n.defaultLocale = "$LOCALE";
|
||||
|
||||
# One keyboard layout everywhere: xkb is the source of truth, and the
|
||||
# distro defaults (console.useXkbConfig + the systemd initrd, set in
|
||||
# the nomarchy module) derive the virtual console and the LUKS
|
||||
# passphrase prompt from it — so only the chosen layout is written here.
|
||||
# The Hyprland session reads the same layout from nomarchy.keyboard.* in
|
||||
# home.nix.
|
||||
services.xserver.xkb.layout = "$KB_LAYOUT";
|
||||
services.xserver.xkb.variant = "$KB_VARIANT";
|
||||
|
||||
# Your login user — \`username\` flows in from flake.nix automatically.
|
||||
users.users.\${username} = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
||||
initialHashedPassword = "$HASHED_PASSWORD";
|
||||
};
|
||||
$AUTOLOGIN_CONFIG$POWER_CONFIG$RESUME_CONFIG
|
||||
# Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.
|
||||
nomarchy.system.snapper.enable = true;
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
"hostname": $(printf '%s' "$HOSTNAME_" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
|
||||
"username": $(printf '%s' "$USERNAME" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
|
||||
"timezone": $(printf '%s' "$TIMEZONE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
|
||||
"locale": $(printf '%s' "$LOCALE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
|
||||
"keyboardLayout": $(printf '%s' "$KB_LAYOUT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
|
||||
"keyboardVariant": $(printf '%s' "$KB_VARIANT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
|
||||
"hashedPassword": $(printf '%s' "$HASHED_PASSWORD" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
|
||||
"autoLogin": $([[ $WITH_LUKS == true ]] && echo true || echo false),
|
||||
"laptop": $is_laptop,
|
||||
"thermald": $thermald,
|
||||
"hardwareProfiles": $hw_json,
|
||||
"hardware": {
|
||||
"intel": $has_intel,
|
||||
"intelGucOff": $intel_guc_off,
|
||||
"amd": $has_amd,
|
||||
"fingerprint": $has_fp,
|
||||
"cameraIr": $has_camera_ir,
|
||||
"nvidia": $has_nvidia,
|
||||
"npu": $(if [[ -n "$NPU_VENDOR" ]]; then printf '%s' "$NPU_VENDOR" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'; else echo null; fi)
|
||||
},
|
||||
"resumeOffset": $resume_json,
|
||||
"rootUuid": $root_uuid_json,
|
||||
"flakeUrl": $(printf '%s' "${NOMARCHY_FLAKE_URL:?NOMARCHY_FLAKE_URL unset}" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
|
||||
}
|
||||
EOF
|
||||
PYJSON
|
||||
|
||||
# The flake.lock: composed offline — nomarchy is path-locked to the very
|
||||
# source the ISO carries (original stays the forge URL, so a later
|
||||
# `nix flake update` on the installed machine re-resolves normally).
|
||||
if ! python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \
|
||||
# NOMARCHY_TEST_FORCE_COMPOSE_FAIL=1 — unattended harness only (#54 V2):
|
||||
# pretends compose-lock failed so the offline fail-closed arm is exercised
|
||||
# without poisoning the ISO store.
|
||||
compose_ok=0
|
||||
if [[ "${NOMARCHY_TEST_FORCE_COMPOSE_FAIL:-}" == 1 ]]; then
|
||||
compose_ok=1
|
||||
elif python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \
|
||||
"$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"; then
|
||||
compose_ok=0
|
||||
else
|
||||
compose_ok=1
|
||||
fi
|
||||
if (( compose_ok != 0 )); then
|
||||
if [[ "$OFFLINE" == true ]]; then
|
||||
fail "Offline lock composition failed and there is no network to fall back to — cannot finish an offline install."
|
||||
fi
|
||||
warn "Offline lock composition failed — resolving over the network."
|
||||
(cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock)
|
||||
fi
|
||||
|
||||
# A flake worktree must be git-tracked (theme-state.json especially).
|
||||
# A flake worktree must be git-tracked (state.json especially).
|
||||
(
|
||||
cd "$FLAKE_DIR"
|
||||
git init -q
|
||||
@@ -485,13 +688,9 @@ fi
|
||||
commit -qm "Initial Nomarchy configuration"
|
||||
)
|
||||
|
||||
# The user must own their flake — libgit2 refuses repositories owned by
|
||||
# someone else, which breaks `home-manager switch` (and theme switching)
|
||||
# outright. The first normal NixOS user is always 1000:users(100); the
|
||||
# account doesn't exist in the target yet, so numeric ids it is.
|
||||
chown -R 1000:100 "$FLAKE_DIR"
|
||||
# The templates come out of the nix store mode 0444 and cp preserves
|
||||
# that — without this the user owns home.nix but can't edit it.
|
||||
# that — without this the user can't edit home.nix after they own it.
|
||||
# Ownership is applied after nixos-install (real uid/gid; see below).
|
||||
chmod -R u+w "$FLAKE_DIR"
|
||||
|
||||
# /etc/nixos on the installed system points at the user-owned flake.
|
||||
@@ -503,7 +702,7 @@ success "Configuration written to ~$USERNAME/.nomarchy"
|
||||
section "Installing (this takes a while)"
|
||||
|
||||
# Seed the target store with the flake source + all inputs so the first
|
||||
# `nomarchy-theme-sync apply` (and the HM pre-activation below) work
|
||||
# `nomarchy-state-sync apply` (and the HM pre-activation below) work
|
||||
# before the machine has ever seen a network. Two steps because
|
||||
# `flake archive --to` enforces signatures and locally-evaluated source
|
||||
# paths have none; plain `nix copy` accepts --no-check-sigs.
|
||||
@@ -543,6 +742,17 @@ fi
|
||||
nixos-install --no-root-passwd "${NIXOS_INSTALL_OPTS[@]}" --flake "path:$FLAKE_DIR#default"
|
||||
success "System installed (bootloader in place)"
|
||||
|
||||
# The user must own their flake — libgit2 refuses repositories owned by
|
||||
# someone else, which breaks `home-manager switch` (and theme switching)
|
||||
# outright. Resolve real uid/gid from the target after nixos-install
|
||||
# created the account (do not hard-code 1000:100 — first free uid or
|
||||
# primary group can differ).
|
||||
USER_UID=$(nixos-enter --root /mnt -- id -u "$USERNAME") \
|
||||
|| fail "Could not resolve uid for install user '$USERNAME' on target"
|
||||
USER_GID=$(nixos-enter --root /mnt -- id -g "$USERNAME") \
|
||||
|| fail "Could not resolve gid for install user '$USERNAME' on target"
|
||||
chown -R "$USER_UID:$USER_GID" "$FLAKE_DIR"
|
||||
|
||||
# Pre-activate the Home Manager generation so the FIRST boot lands in the
|
||||
# fully themed desktop, not bare Hyprland. Best-effort: a failure here
|
||||
# only costs the user one `home-manager switch` after logging in.
|
||||
@@ -573,6 +783,8 @@ cat > /mnt/root/nomarchy-hm-activate.sh <<EOF
|
||||
set -ex
|
||||
exec > /var/log/nomarchy-hm-preactivate.log 2>&1
|
||||
export PATH=/run/current-system/sw/bin:\$PATH
|
||||
# Keep root's nix state in /root, not a stray /home/nomarchy on the target.
|
||||
export HOME=/root
|
||||
# Normally pre-built in the live env and copied over; the in-chroot
|
||||
# build (default substituters — the live-side build already proved the
|
||||
# no-network case) is a last-resort fallback.
|
||||
@@ -590,18 +802,62 @@ nix-daemon &
|
||||
daemon_pid=\$!
|
||||
trap 'kill \$daemon_pid 2>/dev/null || true' EXIT
|
||||
sleep 2
|
||||
# Pre-activate has no graphical session: without XDG_RUNTIME_DIR, HM's
|
||||
# dconfSettings step dies ("Unable to create directory /run/user/UID/dconf:
|
||||
# Permission denied") and the rest of activation never runs — first boot
|
||||
# then lands half-themed (#123). Create a private runtime dir the target
|
||||
# user owns, and wrap activate in a session bus (dconf needs one).
|
||||
uid=$USER_UID
|
||||
install -d -o "$USERNAME" -g users -m 700 "/run/user/\$uid"
|
||||
# BACKUP_EXT: collisions can't abort the activation (a stray
|
||||
# autogenerated config gets moved aside instead).
|
||||
runuser -u "$USERNAME" -- bash -lc \
|
||||
"USER=$USERNAME HOME=/home/$USERNAME NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \$out/activate"
|
||||
if command -v dbus-run-session >/dev/null 2>&1; then
|
||||
runuser -u "$USERNAME" -- env \
|
||||
USER="$USERNAME" HOME="/home/$USERNAME" \
|
||||
XDG_RUNTIME_DIR="/run/user/\$uid" \
|
||||
NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \
|
||||
dbus-run-session -- "\$out/activate"
|
||||
else
|
||||
runuser -u "$USERNAME" -- env \
|
||||
USER="$USERNAME" HOME="/home/$USERNAME" \
|
||||
XDG_RUNTIME_DIR="/run/user/\$uid" \
|
||||
NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \
|
||||
"\$out/activate"
|
||||
fi
|
||||
EOF
|
||||
if nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then
|
||||
# NOMARCHY_TEST_FORCE_HM_FAIL=1 — unattended harness only (#54 V2): take
|
||||
# the failure arm so the durable recovery hint is exercised without a
|
||||
# real activation breakage.
|
||||
hm_activate_ok=0
|
||||
if [[ "${NOMARCHY_TEST_FORCE_HM_FAIL:-}" == 1 ]]; then
|
||||
hm_activate_ok=1
|
||||
warn "NOMARCHY_TEST_FORCE_HM_FAIL=1 — skipping real pre-activate (test harness)"
|
||||
elif nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then
|
||||
hm_activate_ok=0
|
||||
else
|
||||
hm_activate_ok=1
|
||||
fi
|
||||
if (( hm_activate_ok == 0 )); then
|
||||
success "Desktop pre-activated — first boot is fully themed"
|
||||
else
|
||||
warn "Desktop pre-activation failed (see /var/log/nomarchy-hm-preactivate.log"
|
||||
warn "on the installed system); after first login run:"
|
||||
warn " home-manager switch --flake ~/.nomarchy -b bak"
|
||||
tail -n 5 /mnt/var/log/nomarchy-hm-preactivate.log 2>/dev/null || true
|
||||
# The live session (and this warning) ends with this install — drop a
|
||||
# durable hint on the TARGET so the fix still surfaces on first login.
|
||||
# Numeric ids from the target account (USER_UID/USER_GID above) — the
|
||||
# name does not exist in the live ISO's passwd.
|
||||
hint_file="/mnt/home/$USERNAME/NOMARCHY-DESKTOP-NOT-THEMED.txt"
|
||||
cat > "$hint_file" <<HINT
|
||||
Desktop pre-activation failed during install — see
|
||||
/var/log/nomarchy-hm-preactivate.log for details. Finish it with:
|
||||
|
||||
home-manager switch --flake ~/.nomarchy -b bak
|
||||
|
||||
(delete this file once done)
|
||||
HINT
|
||||
chown "$USER_UID:$USER_GID" "$hint_file"
|
||||
fi
|
||||
rm -f /mnt/root/nomarchy-hm-activate.sh
|
||||
|
||||
|
||||
298
pkgs/nomarchy-install/patch-template.py
Normal file
298
pkgs/nomarchy-install/patch-template.py
Normal file
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch a copied templates/downstream machine flake with install-time values.
|
||||
|
||||
The template is the single source of truth for commented opt-ins and the
|
||||
starter app suite. The installer copies it, then this script only:
|
||||
|
||||
* replaces known placeholders (hostname, username, locale, keyboard, …)
|
||||
* fills the __NOMARCHY_INSTALLER__ region with detected/active config
|
||||
* sets hardwareProfile on flake.nix
|
||||
|
||||
Usage:
|
||||
patch-template.py <flake-dir> # reads a JSON object from stdin
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BEGIN = " # __NOMARCHY_INSTALLER_BEGIN__"
|
||||
END = " # __NOMARCHY_INSTALLER_END__"
|
||||
|
||||
|
||||
def nix_str(s: str) -> str:
|
||||
"""Escape a string for a Nix double-quoted literal."""
|
||||
return (
|
||||
s.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("${", "\\${")
|
||||
.replace("\n", "\\n")
|
||||
)
|
||||
|
||||
|
||||
def keyboard_variant(v: dict) -> str:
|
||||
"""Return the XKB value, never the picker's display-only sentinel."""
|
||||
variant = v.get("keyboardVariant") or ""
|
||||
return "" if variant == "(none)" else variant
|
||||
|
||||
|
||||
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
||||
if old not in text:
|
||||
sys.exit(f"patch-template: missing placeholder for {label}: {old!r}")
|
||||
return text.replace(old, new, 1)
|
||||
|
||||
|
||||
def patch_flake(text: str, v: dict) -> str:
|
||||
text = replace_once(
|
||||
text,
|
||||
'description = "My Nomarchy machine";',
|
||||
f'description = "{nix_str(v["hostname"])} — my Nomarchy machine";',
|
||||
"flake description",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
'username = "me"; # <- your login name',
|
||||
f'username = "{nix_str(v["username"])}"; # <- your login name',
|
||||
"flake username",
|
||||
)
|
||||
# ISO build bakes the ref it was built from (main vs v1). Must match the
|
||||
# option surface the installer writes into system.nix (#124).
|
||||
if v.get("flakeUrl"):
|
||||
text, n = re.subn(
|
||||
r'inputs\.nomarchy\.url = "[^"]*";',
|
||||
f'inputs.nomarchy.url = "{nix_str(v["flakeUrl"])}";',
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
if n != 1:
|
||||
sys.exit("patch-template: could not patch inputs.nomarchy.url")
|
||||
profiles = v.get("hardwareProfiles") or []
|
||||
if profiles:
|
||||
items = " ".join(f'"{nix_str(p)}"' for p in profiles)
|
||||
hw_line = f" hardwareProfile = [ {items} ];"
|
||||
else:
|
||||
hw_line = " # hardwareProfile = null; # no nixos-hardware profiles selected"
|
||||
# Replace the optional hardwareProfile comment block with the install choice.
|
||||
text, n = re.subn(
|
||||
r"\n # Optional: a nixos-hardware module name for your machine, e\.g\.\n"
|
||||
r" # hardwareProfile = \"framework-13-7040-amd\";\n"
|
||||
r" # Names: https://github.com/NixOS/nixos-hardware\n"
|
||||
r" # \(the future installer fills this in automatically from DMI data\)\n",
|
||||
f"\n{hw_line}\n"
|
||||
f" # Names: https://github.com/NixOS/nixos-hardware\n",
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
if n != 1:
|
||||
sys.exit("patch-template: could not patch hardwareProfile block in flake.nix")
|
||||
return text
|
||||
|
||||
|
||||
def patch_home(text: str, v: dict) -> str:
|
||||
layout = nix_str(v["keyboardLayout"])
|
||||
variant = nix_str(keyboard_variant(v))
|
||||
text = replace_once(
|
||||
text,
|
||||
' nomarchy.keyboard.layout = "us";',
|
||||
f' nomarchy.keyboard.layout = "{layout}";',
|
||||
"home keyboard layout",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' nomarchy.keyboard.variant = "";',
|
||||
f' nomarchy.keyboard.variant = "{variant}";',
|
||||
"home keyboard variant",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def build_installer_region(v: dict) -> str:
|
||||
lines: list[str] = [
|
||||
BEGIN,
|
||||
" # Written by nomarchy-install from live detection. Safe defaults are",
|
||||
" # active; heavier opt-ins stay in the commented catalog below.",
|
||||
]
|
||||
|
||||
# Auto-login is deliberately NOT emitted here — it is seeded into
|
||||
# state.json instead (patch_state). A line in system.nix outranks
|
||||
# the state default, which would make the System › Auto-login toggle
|
||||
# write JSON that nothing reads.
|
||||
|
||||
if v.get("laptop"):
|
||||
lines += [
|
||||
" # Laptop power (PPD + menu/Waybar). Uncomment to cap charge at 80%.",
|
||||
" nomarchy.system.power.laptop = true;",
|
||||
" # nomarchy.system.power.batteryChargeLimit = 80;",
|
||||
]
|
||||
if v.get("thermald"):
|
||||
lines.append(
|
||||
" nomarchy.system.power.thermal.enable = true; # thermald (Intel)"
|
||||
)
|
||||
|
||||
hw = v.get("hardware") or {}
|
||||
if any(
|
||||
hw.get(k)
|
||||
for k in ("intel", "amd", "fingerprint", "cameraIr", "npu", "nvidia")
|
||||
):
|
||||
lines.append(" # Hardware enablement (auto-detected).")
|
||||
if hw.get("intel"):
|
||||
lines.append(
|
||||
" nomarchy.hardware.intel.enable = true; # GuC/HuC (i915)"
|
||||
)
|
||||
if hw.get("intelGucOff"):
|
||||
lines.append(
|
||||
" nomarchy.hardware.intel.guc = false; # xe driver → GuC default-on"
|
||||
)
|
||||
lines.append(
|
||||
" # nomarchy.hardware.intel.computeRuntime = true; # OpenCL/oneVPL (opt-in)"
|
||||
)
|
||||
if hw.get("amd"):
|
||||
lines += [
|
||||
" nomarchy.hardware.amd.enable = true; # amd-pstate + VA-API",
|
||||
" # nomarchy.hardware.amd.rocm.enable = true; # ROCm (multi-GB, opt-in)",
|
||||
' # nomarchy.hardware.amd.rocm.gfxOverride = ""; # e.g. "11.0.0" for unlisted iGPU',
|
||||
]
|
||||
if hw.get("fingerprint"):
|
||||
lines += [
|
||||
" nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)",
|
||||
" # nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)",
|
||||
]
|
||||
if hw.get("cameraIr"):
|
||||
lines.append(
|
||||
" nomarchy.hardware.camera.hideIrSensor = true; # dual-sensor: hide IR node"
|
||||
)
|
||||
if hw.get("npu"):
|
||||
vendor = nix_str(hw["npu"])
|
||||
lines += [
|
||||
f" # nomarchy.hardware.npu.enable = true; # {vendor} NPU (experimental; userspace BYO)",
|
||||
" # nomarchy.hardware.latestKernel = true; # if the NPU driver needs a newer kernel",
|
||||
]
|
||||
# NVIDIA: profile is in flake.nix (common-gpu-nvidia). Hybrid/PRIME
|
||||
# knobs are plain NixOS — comment-only guidance, same pattern as ROCm.
|
||||
if hw.get("nvidia"):
|
||||
lines += [
|
||||
" # NVIDIA: common-gpu-nvidia is in hardwareProfile (flake.nix).",
|
||||
" # Hybrid/PRIME, power, open-module — plain NixOS; see docs/HARDWARE.md §6",
|
||||
" # and https://wiki.nixos.org/wiki/Nvidia (bus IDs are machine-specific).",
|
||||
" # hardware.nvidia.prime = { ... }; # offload/sync",
|
||||
" # hardware.nvidia.powerManagement.enable = true; # suspend/resume",
|
||||
" # hardware.nvidia.open = false; # true = open module (newer cards)",
|
||||
]
|
||||
|
||||
if v.get("resumeOffset") is not None:
|
||||
root_uuid = nix_str(v["rootUuid"])
|
||||
offset = v["resumeOffset"]
|
||||
lines += [
|
||||
" # Swapfile (hibernation-ready: resume points into it).",
|
||||
' swapDevices = [{ device = "/swap/swapfile"; }];',
|
||||
f' boot.resumeDevice = "/dev/disk/by-uuid/{root_uuid}";',
|
||||
f' boot.kernelParams = [ "resume_offset={offset}" ];',
|
||||
]
|
||||
|
||||
# Always on for installer layout (BTRFS + @snapshots).
|
||||
lines += [
|
||||
" # Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.",
|
||||
" nomarchy.system.snapper.enable = true;",
|
||||
END,
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def patch_system(text: str, v: dict) -> str:
|
||||
text = replace_once(
|
||||
text,
|
||||
' networking.hostName = "my-nomarchy";',
|
||||
f' networking.hostName = "{nix_str(v["hostname"])}";',
|
||||
"hostName",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' time.timeZone = "UTC";',
|
||||
f' time.timeZone = "{nix_str(v["timezone"])}";',
|
||||
"timeZone",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' i18n.defaultLocale = "en_US.UTF-8";',
|
||||
f' i18n.defaultLocale = "{nix_str(v["locale"])}";',
|
||||
"locale",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' services.xserver.xkb.layout = "us";',
|
||||
f' services.xserver.xkb.layout = "{nix_str(v["keyboardLayout"])}";',
|
||||
"xkb layout",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' services.xserver.xkb.variant = "";',
|
||||
f' services.xserver.xkb.variant = "{nix_str(keyboard_variant(v))}";',
|
||||
"xkb variant",
|
||||
)
|
||||
|
||||
# Inject password into the user attrset (template has no password for flake-init).
|
||||
user_block = """ users.users.${username} = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
||||
};"""
|
||||
# HASHED_PASSWORD is sha-512 crypt; alphabet is safe in Nix double quotes.
|
||||
hashed = nix_str(v["hashedPassword"])
|
||||
user_patched = f""" users.users.${{username}} = {{
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
||||
initialHashedPassword = "{hashed}";
|
||||
}};"""
|
||||
text = replace_once(text, user_block, user_patched, "user password")
|
||||
|
||||
if BEGIN not in text or END not in text:
|
||||
sys.exit("patch-template: system.nix missing __NOMARCHY_INSTALLER__ markers")
|
||||
region = build_installer_region(v)
|
||||
text = re.sub(
|
||||
re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?",
|
||||
region,
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def patch_state(text: str, v: dict) -> str:
|
||||
"""Seed menu-owned settings into state.json.
|
||||
|
||||
These live in the state rather than system.nix precisely so the menu can
|
||||
change them later: a baked Nix assignment would outrank the state default
|
||||
and strand the toggle. Auto-login is on when the disk is encrypted — the
|
||||
LUKS passphrase already gates the machine, so a greeter password is a
|
||||
second prompt for the same thing; without LUKS it stays off, where the
|
||||
greeter is the only thing standing between power-on and the desktop.
|
||||
"""
|
||||
state = json.loads(text)
|
||||
settings = state.setdefault("settings", {})
|
||||
if v.get("autoLogin"):
|
||||
settings.setdefault("greeter", {})["autoLogin"] = v["username"]
|
||||
return json.dumps(state, indent=2) + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit("usage: patch-template.py <flake-dir>")
|
||||
flake_dir = Path(sys.argv[1])
|
||||
vals = json.load(sys.stdin)
|
||||
|
||||
mapping = {
|
||||
"flake.nix": patch_flake,
|
||||
"home.nix": patch_home,
|
||||
"system.nix": patch_system,
|
||||
"state.json": patch_state,
|
||||
}
|
||||
for name, fn in mapping.items():
|
||||
path = flake_dir / name
|
||||
path.write_text(fn(path.read_text(), vals))
|
||||
print(f"patch-template: patched {', '.join(mapping)} in {flake_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
220
pkgs/nomarchy-lifecycle/default.nix
Normal file
220
pkgs/nomarchy-lifecycle/default.nix
Normal file
@@ -0,0 +1,220 @@
|
||||
# Day-to-day lifecycle CLIs for a Nomarchy machine flake (~/.nomarchy).
|
||||
# Installed on both NixOS (systemPackages) and Home Manager so a home
|
||||
# switch can refresh them without waiting for a full system rebuild —
|
||||
# otherwise a broken nomarchy-pull can never update itself.
|
||||
{ lib, writeShellScriptBin, nvd, jq, symlinkJoin, nomarchy-what-changed }:
|
||||
|
||||
let
|
||||
# Shared preamble: refuse root, resolve flake path.
|
||||
preamble = name: ''
|
||||
set -euo pipefail
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "${name}: run as your normal user" >&2
|
||||
exit 1
|
||||
fi
|
||||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||
'';
|
||||
|
||||
# Opt-in sweep (settings.autoCommit — the same flag state-sync honours):
|
||||
# commit EVERYTHING dirty in the machine flake before a pull/rebuild/
|
||||
# home switch, so hand edits to system.nix/home.nix and lock bumps land
|
||||
# in history at the moment they become live — `git log` mirrors the
|
||||
# generation list. Complements nomarchy-state-sync's auto_commit, which
|
||||
# is pathspec-limited to state.json on menu writes precisely so
|
||||
# half-finished hand edits never ride a settings-named commit; here the
|
||||
# sweep is the point, and the commit body lists what was swept. Never
|
||||
# fatal — callers `|| true` so a git hiccup can't block a rebuild. Not
|
||||
# in the symlinkJoin paths (internal; callers use the store path) but
|
||||
# exposed as passthru.autocommit for checks.lifecycle-autocommit.
|
||||
nomarchy-autocommit = writeShellScriptBin "nomarchy-autocommit" ''
|
||||
${preamble "nomarchy-autocommit"}
|
||||
label="''${1:-rebuild}"
|
||||
[ -d "$flake/.git" ] || exit 0
|
||||
command -v git >/dev/null 2>&1 || exit 0
|
||||
# #107: prefer state.json; still honour a legacy theme-state.json
|
||||
# until the next menu write migrates the checkout.
|
||||
state="$flake/state.json"
|
||||
[ -r "$state" ] || state="$flake/theme-state.json"
|
||||
flag=$(${jq}/bin/jq -r '.settings.autoCommit // false' \
|
||||
"$state" 2>/dev/null || true)
|
||||
[ "$flag" = "true" ] || exit 0
|
||||
dirty=$(git -C "$flake" status --porcelain || true)
|
||||
[ -n "$dirty" ] || exit 0
|
||||
g=(git -C "$flake")
|
||||
if [ -z "$("''${g[@]}" config user.email 2>/dev/null || true)" ]; then
|
||||
g+=(-c user.name=Nomarchy -c user.email=nomarchy@localhost)
|
||||
fi
|
||||
"''${g[@]}" add -A
|
||||
if "''${g[@]}" commit --quiet -m "nomarchy: auto-commit before $label" \
|
||||
-m "$dirty"; then
|
||||
echo "nomarchy-autocommit: committed pending flake changes before $label:"
|
||||
echo "$dirty" | sed 's/^/ /'
|
||||
else
|
||||
echo "nomarchy-autocommit: commit failed — continuing without it" >&2
|
||||
fi
|
||||
'';
|
||||
|
||||
nomarchy-pull = writeShellScriptBin "nomarchy-pull" ''
|
||||
${preamble "nomarchy-pull"}
|
||||
if [ ! -e "$flake/flake.nix" ]; then
|
||||
echo "nomarchy-pull: no flake.nix at $flake" >&2
|
||||
echo " Set NOMARCHY_PATH or put your machine flake in ~/.nomarchy." >&2
|
||||
exit 1
|
||||
fi
|
||||
# With settings.autoCommit on, sweep pending hand edits into a commit
|
||||
# first — also keeps the ff-only pull below safe on a dirty tree.
|
||||
${nomarchy-autocommit}/bin/nomarchy-autocommit pull || true
|
||||
# Optional: pull *your* machine config only if this checkout tracks a
|
||||
# remote. Distro updates come from the nomarchy flake *input* below —
|
||||
# many installs have no upstream on ~/.nomarchy (local auto-commits).
|
||||
if [ -d "$flake/.git" ] \
|
||||
&& git -C "$flake" rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1; then
|
||||
echo "nomarchy-pull: git pull --ff-only (machine flake tracks upstream)"
|
||||
git -C "$flake" pull --ff-only
|
||||
elif [ -d "$flake/.git" ]; then
|
||||
echo "nomarchy-pull: machine flake has no upstream branch — skipping git pull"
|
||||
echo " (normal for a local-only ~/.nomarchy; distro updates use flake inputs)"
|
||||
fi
|
||||
echo "nomarchy-pull: nix flake update in $flake"
|
||||
nix flake update --flake "$flake"
|
||||
nom=$(nix flake metadata "$flake" --json 2>/dev/null \
|
||||
| ${jq}/bin/jq -r '
|
||||
.locks.nodes.nomarchy.locked
|
||||
| if . == null then empty
|
||||
elif .rev then "nomarchy @ \(.rev[0:12])"
|
||||
else empty end
|
||||
' 2>/dev/null || true)
|
||||
[ -n "''${nom:-}" ] && echo "nomarchy-pull: $nom"
|
||||
echo "nomarchy-pull: done — next: nomarchy-rebuild then nomarchy-home"
|
||||
'';
|
||||
|
||||
nomarchy-rebuild = writeShellScriptBin "nomarchy-rebuild" ''
|
||||
${preamble "nomarchy-rebuild"}
|
||||
if [ ! -e "$flake/flake.nix" ]; then
|
||||
echo "nomarchy-rebuild: no flake.nix at $flake" >&2
|
||||
exit 1
|
||||
fi
|
||||
# With settings.autoCommit on, sweep pending hand edits (system.nix,
|
||||
# lock bumps, …) into a commit so `git log` mirrors the generations.
|
||||
${nomarchy-autocommit}/bin/nomarchy-autocommit rebuild || true
|
||||
before=$(readlink -f /run/current-system)
|
||||
log=$(mktemp)
|
||||
trap 'rm -f "$log"' EXIT
|
||||
set +e
|
||||
if command -v nixos-rebuild-snap >/dev/null 2>&1; then
|
||||
sudo env NOMARCHY_PATH="$flake" nixos-rebuild-snap "$@" 2>&1 | tee "$log"
|
||||
else
|
||||
sudo nixos-rebuild switch --flake "$flake#default" "$@" 2>&1 | tee "$log"
|
||||
fi
|
||||
rc=''${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo
|
||||
echo "nomarchy-rebuild: FAILED (exit $rc). Last lines:"
|
||||
tail -n 40 "$log" || true
|
||||
echo
|
||||
echo "Diagnose: nomarchy-doctor"
|
||||
echo "Recovery: docs/RECOVERY.md (boot menu generations / snapper)"
|
||||
exit "$rc"
|
||||
fi
|
||||
after=$(readlink -f /run/current-system)
|
||||
if [ "$before" = "$after" ]; then
|
||||
echo "nomarchy-rebuild: no changes — the system is identical."
|
||||
else
|
||||
echo "nomarchy-rebuild: what changed:"
|
||||
${nvd}/bin/nvd diff "$before" "$after" || true
|
||||
# One-line toast for the session (full report already on the terminal).
|
||||
if command -v notify-send >/dev/null 2>&1 \
|
||||
&& command -v nomarchy-what-changed >/dev/null 2>&1; then
|
||||
body=$(nomarchy-what-changed --summary --diff "$before" "$after" 2>/dev/null \
|
||||
| sed 's/^Diff: //' || true)
|
||||
[ -n "''${body:-}" ] && notify-send -a Nomarchy "System rebuild" "$body" || true
|
||||
fi
|
||||
fi
|
||||
'';
|
||||
|
||||
nomarchy-home = writeShellScriptBin "nomarchy-home" ''
|
||||
${preamble "nomarchy-home"}
|
||||
if [ ! -e "$flake/flake.nix" ]; then
|
||||
echo "nomarchy-home: no flake.nix at $flake" >&2
|
||||
exit 1
|
||||
fi
|
||||
# With settings.autoCommit on, sweep pending hand edits (home.nix, …)
|
||||
# into a commit so `git log` mirrors the generations.
|
||||
${nomarchy-autocommit}/bin/nomarchy-autocommit "home switch" || true
|
||||
# Snapshot the active HM generation before the switch so we can nvd it.
|
||||
hm_before=""
|
||||
for d in \
|
||||
"''${XDG_STATE_HOME:-$HOME/.local/state}/nix/profiles" \
|
||||
"/nix/var/nix/profiles/per-user/$(id -un)"; do
|
||||
if [ -L "$d/home-manager" ]; then
|
||||
hm_before=$(readlink -f "$d/home-manager" 2>/dev/null || true)
|
||||
break
|
||||
fi
|
||||
done
|
||||
log=$(mktemp)
|
||||
trap 'rm -f "$log"' EXIT
|
||||
set +e
|
||||
home-manager switch --flake "$flake" "$@" 2>&1 | tee "$log"
|
||||
rc=''${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo
|
||||
echo "nomarchy-home: FAILED (exit $rc). Last lines:"
|
||||
tail -n 40 "$log" || true
|
||||
echo
|
||||
echo "Diagnose: nomarchy-doctor"
|
||||
echo "Recovery: home-manager generations (or docs/RECOVERY.md)"
|
||||
exit "$rc"
|
||||
fi
|
||||
hm_after=""
|
||||
for d in \
|
||||
"''${XDG_STATE_HOME:-$HOME/.local/state}/nix/profiles" \
|
||||
"/nix/var/nix/profiles/per-user/$(id -un)"; do
|
||||
if [ -L "$d/home-manager" ]; then
|
||||
hm_after=$(readlink -f "$d/home-manager" 2>/dev/null || true)
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$hm_before" ] && [ -n "$hm_after" ] && [ "$hm_before" != "$hm_after" ]; then
|
||||
echo "nomarchy-home: what changed:"
|
||||
${nvd}/bin/nvd diff "$hm_before" "$hm_after" || true
|
||||
if command -v notify-send >/dev/null 2>&1 \
|
||||
&& command -v nomarchy-what-changed >/dev/null 2>&1; then
|
||||
body=$(nomarchy-what-changed --summary --diff "$hm_before" "$hm_after" 2>/dev/null \
|
||||
| sed 's/^Diff: //' || true)
|
||||
[ -n "''${body:-}" ] && notify-send -a Nomarchy "Desktop updated" "$body" || true
|
||||
fi
|
||||
else
|
||||
echo "nomarchy-home: desktop applied."
|
||||
fi
|
||||
'';
|
||||
|
||||
# Legacy aliases
|
||||
sys-update = writeShellScriptBin "sys-update" ''
|
||||
nomarchy-pull && nomarchy-rebuild "$@"
|
||||
'';
|
||||
sys-rebuild = writeShellScriptBin "sys-rebuild" ''
|
||||
exec nomarchy-rebuild "$@"
|
||||
'';
|
||||
home-update = writeShellScriptBin "home-update" ''
|
||||
exec nomarchy-home "$@"
|
||||
'';
|
||||
in
|
||||
symlinkJoin {
|
||||
name = "nomarchy-lifecycle";
|
||||
paths = [
|
||||
nomarchy-pull
|
||||
nomarchy-rebuild
|
||||
nomarchy-home
|
||||
sys-update
|
||||
sys-rebuild
|
||||
home-update
|
||||
nomarchy-what-changed
|
||||
];
|
||||
passthru.autocommit = nomarchy-autocommit;
|
||||
meta = {
|
||||
description = "Nomarchy machine-flake lifecycle: pull, rebuild, home";
|
||||
mainProgram = "nomarchy-pull";
|
||||
};
|
||||
}
|
||||
53
pkgs/nomarchy-state-sync/default.nix
Normal file
53
pkgs/nomarchy-state-sync/default.nix
Normal file
@@ -0,0 +1,53 @@
|
||||
{ lib
|
||||
, stdenvNoCC
|
||||
, python3
|
||||
, makeWrapper
|
||||
, awww
|
||||
, libnotify
|
||||
, git
|
||||
# Shipped theme presets, baked into the package as a fallback so
|
||||
# `list`/`apply` work even when $NOMARCHY_PATH has no themes/ dir.
|
||||
# Already a store artifact (nomarchy-default-themes merges the repo's
|
||||
# ./themes with backgrounds/ symlinked from nomarchy-wallpapers) — the
|
||||
# wrapper points straight at it, no copy, so it stays in the closure
|
||||
# without duplicating it into $out.
|
||||
, themesDir ? null
|
||||
}:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "nomarchy-state-sync";
|
||||
version = "0.5.1";
|
||||
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
buildInputs = [ python3 ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 nomarchy-state-sync.py $out/bin/nomarchy-state-sync
|
||||
patchShebangs $out/bin/nomarchy-state-sync
|
||||
|
||||
# Stdlib-only Python. home-manager is deliberately NOT wrapped in —
|
||||
# the rebuild must use the user's own home-manager from their PATH.
|
||||
wrapProgram $out/bin/nomarchy-state-sync \
|
||||
--prefix PATH : ${lib.makeBinPath [ awww libnotify git ]} \
|
||||
${lib.optionalString (themesDir != null)
|
||||
"--set NOMARCHY_DEFAULT_THEMES ${themesDir}"}
|
||||
|
||||
# #107: old CLI name kept as a symlink so muscle memory and scripts
|
||||
# survive a pull (drop after the next stable release notes say so).
|
||||
# After wrapProgram so it points at the wrapper, not the raw script.
|
||||
ln -s nomarchy-state-sync $out/bin/nomarchy-theme-sync
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Nomarchy state writer + Home Manager rebuild dispatcher";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "nomarchy-state-sync";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user