Compare commits
251 Commits
cb659ebb4c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 374f70c76f | |||
| 3536abc656 | |||
| baf2cd2e13 | |||
|
|
dbe07ea429 | ||
| fdf9790a73 | |||
| 5b95f6444f | |||
|
|
757fa873b8 | ||
| 0cd9ace669 | |||
| 3abeccf071 | |||
| 76941c55b7 | |||
| 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 |
@@ -1,272 +0,0 @@
|
||||
---
|
||||
name: nomarchy
|
||||
description: Development and maintenance workflow for the Nomarchy NixOS-based distribution (this repository). Use this skill for ANY change to this repo — new features, bug fixes, theming or visual work (Hyprland, Waybar, rofi, wallpapers, palettes), NixOS/Home-Manager module changes, flake lock bumps, theme imports, docs updates, backlog grooming, or loop iterations. Trigger even for "small" or "obvious" changes; the whole point of this skill is that no change ships without climbing the verification ladder, and small changes are where verification gets skipped.
|
||||
---
|
||||
|
||||
# Nomarchy Development Skill
|
||||
|
||||
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.
|
||||
|
||||
## 0. Read the authoritative docs first
|
||||
|
||||
This skill is an enforcer, not the workflow itself. The workflow lives in the
|
||||
repo and is the single source of truth:
|
||||
|
||||
- `CLAUDE.md` — entry point, project conventions
|
||||
- `agent/LOOP.md` — the iteration loop and the V0–V3 verification ladder
|
||||
- `agent/` (remaining files) — philosophy, conventions, memory, backlog
|
||||
- `docs/TESTING.md` — VM instructions, the regression checklist, known
|
||||
environment gotchas
|
||||
|
||||
At the start of any work session in this repo, read `CLAUDE.md` and
|
||||
`agent/LOOP.md` before touching code. Read `docs/TESTING.md` before running
|
||||
any VM test. If this skill and those docs ever disagree, the repo docs win —
|
||||
then 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" (see §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`. This skill adds three
|
||||
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 human-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 is in scope for this skill and 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".
|
||||
- **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:
|
||||
|
||||
- **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.
|
||||
|
||||
## 6.5 Token economy: delegate machinery, keep judgment
|
||||
|
||||
Not every step needs the smartest model. Use subagents to route mechanical
|
||||
work to cheap models and keep expensive reasoning where it pays. The repo
|
||||
defines two in `.claude/agents/`:
|
||||
|
||||
- **nomarchy-scout** (haiku, read-only): finding where things are defined,
|
||||
mapping which files touch a subsystem, scanning build logs and serial
|
||||
output for error lines, docs-vs-code drift sweeps. Use it for any pure
|
||||
information-gathering step instead of reading files into the main
|
||||
context yourself — it keeps the main context small, which matters more
|
||||
than the token price on long loops.
|
||||
- **nomarchy-runner** (haiku, executes the harness): builds, VM boots,
|
||||
screenshot capture, the scripted `tools/` checks. It runs commands and
|
||||
returns exit codes, logs, and artifact paths — nothing more.
|
||||
|
||||
The dividing line is **evidence vs. judgment**. Cheap models gather
|
||||
evidence; they never make verification claims. The following always stay
|
||||
with the main (smart) model and are never delegated downward:
|
||||
|
||||
- deciding what to test and what the regression scope is (§2)
|
||||
- interpreting an ambiguous or flaky failure (§2 rule 3)
|
||||
- viewing screenshots and judging visual quality (§3) — aesthetic judgment
|
||||
is exactly the kind of work small models do badly, and it's load-bearing
|
||||
for this distro
|
||||
- writing non-trivial Nix (module structure, overlays, cross-cutting
|
||||
refactors)
|
||||
- the final review of the diff and the §7 report
|
||||
|
||||
Practical guidance: delegation is not free — each subagent has its own
|
||||
context and the tokens multiply — so delegate when the work is mechanical
|
||||
*or* would pollute the main context with bulk (log files, wide file
|
||||
scans), not reflexively for every small step. A one-file read is cheaper
|
||||
done directly. When a scout/runner 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.
|
||||
|
||||
### Fanning out worktree agents (parallel V0/V1 work)
|
||||
|
||||
When several NEXT items are independent and don't need the VM, spread them
|
||||
across worktree-isolated subagents that run in parallel — but keep the
|
||||
token cost down with these habits (each is a real lever, not a nicety):
|
||||
|
||||
- **Match the model to the task, not the tier.** A backlog item whose spec
|
||||
is already written (exact files, exact fixes) is *well-specified* →
|
||||
**sonnet**, not opus. Reserve opus for genuine multi-step reasoning
|
||||
(novel Nix, cross-cutting design, an ambiguous failure). Haiku for bulk
|
||||
mechanical. Picking sonnet over opus for a spec'd task is the single
|
||||
biggest saving.
|
||||
- **Point at the spec; don't restate it.** The brief should say "the spec
|
||||
is in `agent/BACKLOG.md` #NN — implement it, don't re-derive," plus only
|
||||
the *constraints* (scope files, branch, no-VM, no-push). A cold agent
|
||||
re-reading the whole design into its own context is the tokens you're
|
||||
trying to avoid.
|
||||
- **Disjoint file lanes.** Partition the items so no two agents touch the
|
||||
same file (map the touched files first). Zero shared files = zero merge
|
||||
conflicts at landing = no reconciliation tokens. 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.** Run each agent with `isolation:
|
||||
worktree`; it commits to its own branch and **never pushes or touches
|
||||
`main`/`v1`**. You review each diff, cherry-pick onto `main`, and do the
|
||||
bookkeeping. Parallel pushers race on `main`; a single landing agent
|
||||
doesn't. (Clean up: `git worktree remove --force` + `git branch -D` the
|
||||
branch and its `worktree-…` sibling after landing.)
|
||||
- **Lean on scriptable checks as the primary evidence.** Where a
|
||||
deterministic `tools/` check or a `checks.*` guard already proves the
|
||||
property (e.g. `check-theme-contrast.py` for a palette fix,
|
||||
`option-docs` for a doc row), that near-free run *is* the V0/V1
|
||||
evidence — don't spend a VM render to re-confirm what the script already
|
||||
asserts.
|
||||
- **Batch V2 at the end, once.** Visual/behavioural items delegated at
|
||||
V0/V1 come back "V2 pending." Don't boot the VM per item — collect the
|
||||
landed changes and do **one** `theme-shot`/`test-install` pass covering
|
||||
all of them. The VM render + screenshot review is the most expensive
|
||||
token sink in the loop; amortise it.
|
||||
- **Re-verify on `main`, but leanly.** After cherry-picking, confirm the
|
||||
agent's V0/V1 on the merged tree — but a tiny, obviously-correct diff
|
||||
needs a targeted build, not a full re-run of everything. Trust-but-spot-
|
||||
check scales; blind re-running doesn't.
|
||||
|
||||
The judgment that never delegates (§ above) still holds: *you* review every
|
||||
diff before it lands, and *you* make the product calls (a "finish vs.
|
||||
quarantine" decision is yours or Bernardo's, never a cheap agent's).
|
||||
|
||||
## 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.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Nomarchy Theme Designer Skill
|
||||
|
||||
**Trigger:** Use this skill whenever the user requests to create, update, refine, or troubleshoot themes and visual designs for the Nomarchy distribution.
|
||||
|
||||
## System Prompt / Instructions
|
||||
|
||||
You are an elite UI/UX designer and Linux ricing expert specializing in Wayland environments. Your role is to create cohesive, innovative, and visually stunning desktop themes for "Nomarchy," a custom Linux distribution based on NixOS and the Hyprland window manager.
|
||||
|
||||
### Context & Architecture
|
||||
All theme configurations for Nomarchy live inside the `themes/` directory at the root of the repository. Before generating any new configurations, you MUST read and analyze the existing files in this directory. Learn how the current themes are structured, how the syntax is formatted for each application, and how they are integrated into the broader NixOS configuration. Always match this established architectural pattern.
|
||||
|
||||
### Your Design Responsibilities
|
||||
1. **Holistic Design:** Develop unified themes that span across Hyprland (borders, shadows, animations), Waybar, Ghostty terminal, btop, fastfetch, Rofi/fuzzel, and desktop wallpapers.
|
||||
2. **Color Theory & Aesthetics:** Create or adapt advanced color palettes. You may draw inspiration from established aesthetics (e.g., Everforest, Nord, Gruvbox) or r/unixporn trends, but you should innovate. Ensure perfect harmony between background, foreground, accents, and warning/error colors.
|
||||
3. **Typography & Iconography:** Select and pair fonts (UI vs. Monospace) and icon themes that match the specific vibe of the color palette.
|
||||
4. **Accessibility:** Follow best practices for UI design. Ensure high contrast for text readability and avoid eye strain.
|
||||
|
||||
### Process
|
||||
1. Read the `themes/` directory to understand the codebase.
|
||||
2. Explain the "vibe," primary color palette (with hex codes), and typography choices of your proposed design.
|
||||
3. Implement the theme by generating or updating the necessary files within the `themes/` structure.
|
||||
@@ -1,11 +1,12 @@
|
||||
# Nomarchy scheduled lock bump — the automated half of "rock-stable
|
||||
# without rotting": once a week, update flake.lock (inputs track pinned
|
||||
# without rotting": once a day, 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.
|
||||
# workflow manually from the Actions tab instead of waiting for the
|
||||
# next daily run.
|
||||
#
|
||||
# Gate scope: `nix flake check --no-build` (eval tier) followed by a V1 build
|
||||
# (home-manager activation package + nixos toplevel) to catch compilation errors.
|
||||
@@ -18,7 +19,7 @@
|
||||
#
|
||||
# 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.
|
||||
# also just fails the final push — rerun or wait a day.
|
||||
#
|
||||
# Container recipe (nixbld users, sandbox=false, pinned Nix, plain-shell
|
||||
# install) inherited from check.yml — the gotchas are documented there.
|
||||
@@ -27,7 +28,7 @@ name: Lock bump
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 5 * * 1' # Mondays 05:17 UTC
|
||||
- cron: '17 5 * * *' # daily 05:17 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -36,6 +37,8 @@ jobs:
|
||||
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
|
||||
@@ -68,9 +71,12 @@ jobs:
|
||||
fi
|
||||
id: update
|
||||
|
||||
- name: Gate — nix flake check (eval only)
|
||||
- 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: nix flake check --no-build
|
||||
run: bash tools/ci-eval.sh
|
||||
|
||||
- name: Gate — V1 build (toplevel + home-manager)
|
||||
if: steps.update.outputs.changed == '1'
|
||||
|
||||
@@ -37,6 +37,8 @@ jobs:
|
||||
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
|
||||
@@ -55,11 +57,15 @@ jobs:
|
||||
curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon
|
||||
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: nix flake check (eval only)
|
||||
# Full module-system evaluation of every output — both nixos
|
||||
- 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.
|
||||
run: nix flake check --no-build
|
||||
# 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
|
||||
@@ -86,12 +92,11 @@ jobs:
|
||||
done < <(git ls-files '*.sh')
|
||||
exit "$fail"
|
||||
|
||||
# ── vm-checks (DISABLED until a KVM runner exists) ────────────────────
|
||||
# The real prize: running the checks.* VM suite + a toplevel build in
|
||||
# CI. Needs a runner on a NixOS (or at least nix + /dev/kvm) host —
|
||||
# register one with a dedicated label, then uncomment and set runs-on
|
||||
# to that label. Do NOT enable this against a label that doesn't
|
||||
# exist: Gitea queues such jobs forever instead of failing.
|
||||
# ── 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
|
||||
|
||||
9
.gitignore
vendored
9
.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,6 +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/skills/
|
||||
.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 |
|
||||
52
CLAUDE.md
52
CLAUDE.md
@@ -1,52 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## Where things live (read this first)
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| **`agent/`** | Loop state — **only executable queue is `BACKLOG.md`** |
|
||||
| **`docs/VISION.md`** | Product themes toward **v1.0** (not a queue) |
|
||||
| **`docs/ROADMAP.md`** | Design history + shipped log |
|
||||
| **`docs/README.md`** | Full documentation map |
|
||||
| **`agent/README.md`** | Map of loop files |
|
||||
| **`.claude/`** | Claude Code only (permissions + subagents) — 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
|
||||
- **Honesty rule** (`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`).
|
||||
- **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
|
||||
|
||||
Push mechanical work down; keep judgment. Brief every child cold.
|
||||
|
||||
| Model | Best for | Delegate? | Effort |
|
||||
|---|---|---|---|
|
||||
| Haiku | bulk mechanical | never | low |
|
||||
| Sonnet | scoped research | when it helps | medium |
|
||||
| Opus 4.8 | multi-step reasoning | on clear benefit | xhigh |
|
||||
| Fable 5 | judgment, taste | by default | medium |
|
||||
|
||||
Fable goes xhigh only for the hardest calls. Skip high.
|
||||
|
||||
## Escalation
|
||||
|
||||
An Opus parent can spawn a Fable child for one hard call. Work above your
|
||||
tier? Return it — don't burn tokens.
|
||||
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.
|
||||
216
README.md
216
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/ # 24 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,7 +56,7 @@ 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
|
||||
@@ -72,10 +72,9 @@ 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-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-control-center/ # TUI settings front-end over nomarchy-theme-sync (gum)
|
||||
│ └── 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
|
||||
@@ -83,10 +82,10 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
|
||||
│ ├── ROADMAP.md # design/decision records + shipped log
|
||||
│ ├── HARDWARE.md # firmware, profiles, drivers
|
||||
│ ├── TESTING.md · RECOVERY.md · OVERRIDES.md · MIGRATION.md
|
||||
├── agent/ # agent loop state — map: agent/README.md
|
||||
│ # BACKLOG (executable queue), LOOP, GOALS, …
|
||||
├── CLAUDE.md # agent harness entry (points at agent/ + docs/)
|
||||
├── .claude/ # Claude Code only: permissions + subagents
|
||||
├── 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
|
||||
@@ -102,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
|
||||
```
|
||||
|
||||
@@ -115,6 +118,13 @@ 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
|
||||
@@ -150,7 +160,7 @@ 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
|
||||
@@ -182,25 +192,43 @@ 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)
|
||||
sys-rebuild # system rebuild against the CURRENT lock (config changes only, no update)
|
||||
# …both end with a package-level diff of what the rebuild changed (nvd)
|
||||
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)**.
|
||||
@@ -214,24 +242,27 @@ two tables below are split along exactly that line.
|
||||
|
||||
| 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; 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.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.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.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 15 min — battery-only) |
|
||||
| `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 |
|
||||
@@ -240,10 +271,19 @@ two tables below are split along exactly that line.
|
||||
| `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. The menu's Auto-switch row (`settings.displayProfileAuto`) makes plugging/unplugging outputs apply the matching profile instantly (driven by a dedicated Hyprland IPC socket watcher, no polling). A profile can also pin workspaces to outputs (`workspaces = { "1" = "DP-3"; }`) — moved instantly on switch, baked as Hyprland `workspace` rules |
|
||||
| `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-theme-sync` | The theme/state tool package, overridable if you fork it |
|
||||
| `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
|
||||
@@ -258,28 +298,30 @@ option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
|
||||
| Option | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `nomarchy.system.plymouth.enable` | `true` | Branded boot splash, background from the theme JSON (recolors on system rebuilds) |
|
||||
| `nomarchy.system.stateFile` | `null` | theme-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.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.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 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 |
|
||||
| `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 |
|
||||
@@ -317,6 +359,14 @@ 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
|
||||
|
||||
@@ -326,24 +376,31 @@ 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)
|
||||
|
||||
@@ -363,7 +420,7 @@ 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` theme-state.json in your flake — *only* that
|
||||
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
|
||||
@@ -377,29 +434,32 @@ 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 |
|
||||
|
||||
Four themes ship a `waybar.css` identity (summer-day, summer-night,
|
||||
executive-slate, boreal). 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 # 24 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)
|
||||
sys-rebuild # rebuild the system, current lock (no update)
|
||||
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)
|
||||
```
|
||||
|
||||
@@ -407,7 +467,7 @@ 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+D` launcher · `SUPER+T` theme
|
||||
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+SHIFT+C` color picker (hyprpicker) · `SUPER+Q`
|
||||
@@ -429,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
|
||||
@@ -443,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**
|
||||
@@ -459,6 +522,17 @@ 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
|
||||
|
||||
@@ -471,3 +545,5 @@ reload # exec zsh (reload the shell)
|
||||
| **[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).
|
||||
|
||||
445
agent/BACKLOG.md
445
agent/BACKLOG.md
@@ -24,108 +24,82 @@ in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in
|
||||
|
||||
## NOW
|
||||
|
||||
*(empty — NEXT's top item is the queue head)*
|
||||
### 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
|
||||
|
||||
### 64. MIGRATION.md snapshot layout vs installer
|
||||
Installer creates disko `@snapshots` → `/.snapshots` and a first-boot
|
||||
oneshot for nested `/home/.snapshots`. MIGRATION still speaks of
|
||||
top-level `@home-snapshots` (TuringMachine vocabulary). One paragraph
|
||||
clarifying installer layout vs migration machines. Cost: docs only; V0.
|
||||
### 151. `[human]` v1 launch plan — devise with Bernardo (includes the GitHub move)
|
||||
|
||||
### 65. Installer soft gap: chown flake dir
|
||||
`chown -R 1000:100` on the generated flake assumes first-user UID/GID.
|
||||
Use the real install username’s uid/gid (or `id` after user create).
|
||||
Cost: small; V0–V1.
|
||||
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.
|
||||
|
||||
### 66. Fail-closed / friendlier theme-state errors in `mkFlake`
|
||||
Missing or invalid `theme-state.json` should fail early with a pointer
|
||||
to schema/docs, not a raw `readFile`/`fromJSON` stack. Related:
|
||||
`settings.displayProfile*` defaults; unknown border role. Cost: medium
|
||||
lib UX; V0–V1.
|
||||
|
||||
### 67. Theme fidelity / hierarchy nits (popular ports)
|
||||
gruvbox Material vs classic name+btop split; nord inverted subtext/text;
|
||||
tokyo-night btop warm fg; ethereal surface=base; retro-82 surface
|
||||
darker than base; ristretto subtext=text + btop bg drift; flexoki-light
|
||||
bright-black. Cost: small JSON/btop each; V0 contrast + V1.
|
||||
|
||||
### 68. #52 residual ANSI
|
||||
**vantablack** `ansi[8]` still `#fdfdfd` (diverges from `muted #666666`);
|
||||
**osaka-jade** `ansi[3]` still warn-green `#459451` while `warn` is
|
||||
`#E5C736`. Treat as fidelity bugs (align with muted/warn). Cost: tiny
|
||||
JSON; V0 contrast.
|
||||
|
||||
### 69. audit-theme-design identity exemptions
|
||||
Document expected CVD/hue noise for white/vantablack/lumon/hackerman/
|
||||
matte-black/miasma so audits don’t “fix” identity into traffic lights.
|
||||
Optional special-case in the report script. Cost: docs or small script; V0.
|
||||
|
||||
### 70. Import pipeline hierarchy when ANSI 0==8
|
||||
`tools/import-palettes.py` maps surface←color0, overlay←color8; when
|
||||
0==8 (gruvbox, everforest) surface=overlay; light themes get sludge
|
||||
mantle. Allow role overrides independent of ANSI; don’t re-import
|
||||
without a hierarchy pass. Cost: tool + convention; V0.
|
||||
|
||||
### 71. Portal/Flatpak IR camera — document (a)
|
||||
Ship the recommended path only: document that the WirePlumber *v4l2*
|
||||
IR-hide does not cover libcamera/Flatpak pickers (ROADMAP § Webcam).
|
||||
Leave (b)/(c) engineering for a T14s session. Cost: docs; V0.
|
||||
|
||||
### 72. Installer ↔ template SoT — CI parity check
|
||||
Optional pure check that the install share ships the same template
|
||||
files as `templates/downstream`. Cost: small tool + flake check; V0.
|
||||
|
||||
### 73. Post-install hardware hints residual
|
||||
#43 shipped Firmware MOTD/tip. Still open: fingerprint + doctor
|
||||
one-liners (once or until dismissed). Cost: small greeter/MOTD/notify;
|
||||
V1–V2.
|
||||
|
||||
### 74. Unattended-install test matrix
|
||||
`test-install.sh` always LUKS+swap; add (or document) no-swap and
|
||||
explicit no-LUKS paths via env flags only. Cost: script + KVM time; V2.
|
||||
|
||||
### 75. Installer template SoT — V2 install after ISO rebuild
|
||||
HM pre-activate now pulls starter packages (larger closure). Run
|
||||
`test-install.sh` (or equivalent) on a rebuilt live ISO and confirm
|
||||
first boot still themed. Cost: ISO + KVM; V2.
|
||||
|
||||
### 20. KVM runner → VM suite in CI `[human]`
|
||||
The remaining stretch of the CI item — checks-on-push is live and
|
||||
**green** (run #58; runner = gitea/act_runner docker, eval tier).
|
||||
Register a second runner on a host with `/dev/kvm` + nix (host-mode
|
||||
label `nix-kvm`), then an agent uncomments the workflow's `vm-checks`
|
||||
job: the `checks.*` VM suite + real toplevel/HM builds on every push
|
||||
that later upgrades the bump gate from eval-only to the full suite.
|
||||
|
||||
### 41. Floating-window audit — residual GTK portal class `[blocked:hw]`
|
||||
(Raised by Bernardo, 2026-07-07 — item 11.) Conservative cut + 2026-07-10
|
||||
slice shipped: mixer, blueman, system-config-printer, calendar,
|
||||
**hyprpolkitagent** + **pinentry-qt** (classes from package app-id
|
||||
strings; softGL capture harness could not materialize dialogs).
|
||||
**Still open:** GTK file-chooser portal (`xdg-desktop-portal-gtk`) —
|
||||
class not discoverable headlessly. On hardware: open a portal file
|
||||
picker, `hyprctl clients`, append float/center rules if needed; confirm
|
||||
polkit/pinentry/blueman actually float.
|
||||
`v1` stays human-only throughout — agents prepare evidence and checklists,
|
||||
never the tag or branch.
|
||||
|
||||
## LATER
|
||||
|
||||
- **Wallpapers artifact split** (ROADMAP § Faster switches — decided,
|
||||
deferred): pinned `Nomarchy-wallpapers` input so a state write stops
|
||||
re-copying 86 MB. Follow-on: pre-built theme variants if switches are
|
||||
still slow after.
|
||||
- **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.
|
||||
- **Night-light geo mode**: lat/long auto sunset/sunrise (means wlsunset).
|
||||
- **Per-theme icon overrides** / more icon packs (ROADMAP § Icon themes).
|
||||
- **MIPI/IPU software-ISP camera** support (no-UVC machines).
|
||||
- **VPN exit-node richer display** (country/city) (optional).
|
||||
- **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 (NOW#3 should
|
||||
also soften that blocker class).
|
||||
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)
|
||||
|
||||
@@ -137,102 +111,243 @@ high-ROI, etc.) live in the journal + ROADMAP — not here.*
|
||||
|
||||
### Product / day-2
|
||||
|
||||
- **Battery charge-limit — make the toggle instant** `[blocked:hw]`
|
||||
Menu/CC write `settings.power.batteryChargeLimit` but need a rebuild.
|
||||
Instant path: udev group-writable `charge_control_end_threshold` on
|
||||
system batteries + live `echo` alongside state persist. Decide first
|
||||
whether a local user may set the charge cap. Cost: medium; hw confirm.
|
||||
### 153. Desktop widgets (clock / system stats) as an opt-in per-theme surface
|
||||
|
||||
_(Portal/Flatpak IR camera → **#71** docs (a); (b)/(c) still need T14s.)_
|
||||
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.
|
||||
|
||||
_(Post-install hardware hints residual → **#73**.)_
|
||||
### 146. `[watch]` hypridle hangs instead of exiting when its compositor vanishes
|
||||
|
||||
- **Look & Feel submenu** (ROADMAP optional) — night-light, wallpaper
|
||||
cycle, blur/gaps with Theme; root stays six entries. Product call.
|
||||
**Re-measured 2026-07-20 (dev box; user journal is persistent back to 2026-05-01,
|
||||
so this is the full window, not a fragment).** The linger fix landed between 17:25
|
||||
and 20:13 on 2026-07-16: the last zombie-shaped disconnect is the known one
|
||||
(17:25:39, PID 1698 — logout→greeter with **no** following `Stopped hypridle`), and
|
||||
**every** `Disconnected from pollfd id 1` since is a clean teardown bracketed by both
|
||||
a logind `Session N logged out` and a systemd `Stopped hypridle` within ≤11s
|
||||
(07-16 20:13:04, 07-17 17:04:38, 07-18 13:35:25, 07-19 16:46:49). **Genuine zombies
|
||||
post-fix: 0.** The live daemon is healthy too — PID 1707: `active`, `NRestarts=0`,
|
||||
`ExecMainPID`==pid, `ss` Recv-Q 0 on its D-Bus socket (reading, not the 48 KB-unread
|
||||
wedge), `WCHAN futex_do_wait`, logged activity <10 min before inspection. **Why this
|
||||
stays open a little longer, not closed today:** all four post-fix disconnects were
|
||||
*deliberate* logouts — no Hyprland *crash* (the zombie's actual trigger; the nearby
|
||||
coredumps were xdg-desktop-portal/swww/swayosd during teardown, not the compositor)
|
||||
has occurred since the fix, so the crash path is confirmed by argument (a crash ends
|
||||
the logind session too → no-linger manager teardown reaps hypridle) but not yet by an
|
||||
observed crash, and ~4 days is under the "few weeks" bar. **Close condition:** if
|
||||
genuine zombies stay 0 by ~2026-08-10 (≈3 weeks post-fix), close unbuilt; one wedged
|
||||
process (`ps` alive + `ss` Recv-Q high + `NRestarts=0` while `active`) reopens it as
|
||||
real work. Everything below is prior context, unchanged.
|
||||
|
||||
- **NVIDIA first-class options** `[big]` `[blocked:hw]` — thin
|
||||
`nomarchy.hardware.nvidia.*` only with hybrid maintainer + queue.
|
||||
Prefer shipped #59 commented guidance until then.
|
||||
**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.
|
||||
|
||||
### Installer / template
|
||||
**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.
|
||||
|
||||
_(Installer ↔ template SoT CI → **#72**; V2 install → **#75**.)_
|
||||
_(Installer chown flake dir → **#65**.)_
|
||||
_(MIGRATION.md snapshot layout → **#64**.)_
|
||||
_(Unattended-install test matrix → **#74**.)_
|
||||
_(Friendlier mkFlake theme-state errors → **#66**.)_
|
||||
**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.
|
||||
|
||||
### Theme polish
|
||||
**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.
|
||||
|
||||
_(#52 residual ANSI → **#68**.)_
|
||||
_(Fidelity / hierarchy nits → **#67**.)_
|
||||
_(Import pipeline hierarchy → **#70**.)_
|
||||
_(audit-theme-design identity exemptions → **#69**.)_
|
||||
**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.
|
||||
|
||||
- **summer-day / summer-night pair polish** — waybar CSS vs JSON dual
|
||||
SoT; night `subtext`=`muted`=`overlay`; day/night font + battery
|
||||
threshold + VPN pill drift. Pick JSON as SoT (or document EF split).
|
||||
Cost: CSS/JSON; V3.
|
||||
### 149. `[watch]` Waybar's exec-once workaround may be defending against nothing post-linger
|
||||
|
||||
- **summer-\* CSS status module states** — missing `.on` / `.available`
|
||||
/ `.recording` polish vs boreal/generated. Cost: CSS; V3.
|
||||
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).
|
||||
|
||||
- **theme preview recapture nicety** — executive-slate + neon-glass
|
||||
previews are bare desktop+bar, not the floating-terminal composition
|
||||
of the other 21; optional identity `btop.theme`s. Cost: asset/V3.
|
||||
**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.
|
||||
|
||||
- **Identity optional taste retunes** — white / lumon / hackerman /
|
||||
matte-black / miasma hierarchy or status L-steps only (no traffic-light
|
||||
forced on identity themes). Product call per theme.
|
||||
### 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**. Remaining PROPOSED: charge-limit instant, Look & Feel,
|
||||
NVIDIA first-class, summer-* / preview / identity taste polish.
|
||||
|
||||
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.
|
||||
decide. **Resolved** entries stay for history; agents treat them as closed.
|
||||
|
||||
- **Formatter adoption:** repo deliberately has none; `nixfmt-rfc-style`
|
||||
would flatten the aligned hand-formatting of ~33 files. Adopt or
|
||||
declare never?
|
||||
- **Docs site vs Markdown-in-repo** (from the docs-review item).
|
||||
### Resolved (2026-07-17)
|
||||
|
||||
- **zram swap:** faster under pressure and pairs with NOW#3, but it
|
||||
interacts with the hibernation-swapfile story (resume device/priority
|
||||
ordering) — adopt, adopt-with-hibernation-guard, or skip?
|
||||
- **Default browser:** the template comments Firefox out. The shipped
|
||||
mime defaults (item 8, done) point `text/html`/http(s) at
|
||||
`firefox.desktop` as *inert* entries — they activate the moment
|
||||
Firefox is installed and are skipped otherwise, so the remaining call
|
||||
is only: ship a browser active in the suite, or stay
|
||||
browserless-by-default?
|
||||
- **#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.
|
||||
|
||||
- **Default power backend — PPD vs TLP:** (raised by Bernardo 2026-07-08:
|
||||
would TLP be more power-efficient, and should it be the default?)
|
||||
TLP does get more *idle* battery life, but only from device-level knobs
|
||||
PPD deliberately omits — PCIe ASPM, disk/NVMe link PM, USB autosuspend,
|
||||
runtime PM — which are the same knobs behind NVMe/USB flakiness that
|
||||
pillar 1 (rock-stable, never fight your machine) guards against.
|
||||
**Evidence (2026-07-08):** no credible hard-watt benchmarks exist — TLP's
|
||||
own maintainer declines to quote any ("measure on your hardware") and says
|
||||
PPD's power-saver gives *similar* savings under load; TLP's edge is
|
||||
idle-only (linrunner.de/tlp/faq/ppd.html — PPD covers a *subset* of TLP,
|
||||
"no settings to reduce consumption when the CPU is idle"). Our sibling
|
||||
distro **Omarchy** ran this exact experiment — a "replace Power Profiles
|
||||
with TLP for battery" guide — and the author **reverted to PPD** ("more
|
||||
headaches and weird issues"); Omarchy shipped PPD auto-switching instead
|
||||
(basecamp/omarchy#3907). PPD 3.4.0 now does AC/battery auto-switching,
|
||||
closing part of TLP's UX gap. **Cost of TLP-default:** TLP has no live
|
||||
D-Bus profile API, so the `powermgmt` menu + Waybar profile indicator +
|
||||
low-battery auto power-saver (all `powerprofilesctl`) would need a rework —
|
||||
it's not a one-line backend swap. **Agent rec (evidence, not a decision):
|
||||
keep PPD default;** chase battery via targeted low-risk tweaks (PCIe ASPM
|
||||
policy, battery-side EPP, the charge *start* threshold, PPD auto-switching)
|
||||
and keep TLP the documented one-line opt-in it already is
|
||||
(`nomarchy.system.power.backend = "tlp"`). Options: (i) status quo +
|
||||
targeted tweaks [rec]; (ii) TLP default (reworks the profile UX);
|
||||
(iii) nothing.
|
||||
### 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**.
|
||||
|
||||
@@ -24,7 +24,7 @@ those, it probably shouldn't exist.
|
||||
|
||||
## Feature design
|
||||
- **In-flake state:** any user-settable config gets a menu writer that
|
||||
lands it in `theme-state.json` (`settings.*`), git-tracked. No
|
||||
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
|
||||
@@ -46,7 +46,8 @@ those, it probably shouldn't exist.
|
||||
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 and boreal (the parity rule).
|
||||
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.
|
||||
@@ -68,5 +69,7 @@ those, it probably shouldn't exist.
|
||||
- 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, and only within
|
||||
the pinned release branches.
|
||||
- `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.
|
||||
@@ -14,7 +14,7 @@ confidence, default identity, release bar) live in
|
||||
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 (`theme-state.json` `settings.*`) — never
|
||||
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
|
||||
@@ -22,7 +22,7 @@ confidence, default identity, release bar) live in
|
||||
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, Ghostty, btop, rofi, GTK/Qt, boot splash, greeter. Every new
|
||||
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.
|
||||
|
||||
@@ -3,40 +3,302 @@
|
||||
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. Machines: the **AMD dev box**
|
||||
(Ryzen AI laptop: AMD + fingerprint + NPU), the **Latitude 5410** (Intel
|
||||
QA machine), the **T14s** (webcam case).
|
||||
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)
|
||||
- [ ] **#41 float classes (polkit / pinentry / portal / blueman)** — after
|
||||
rebuild: (1) `pkexec true` or mount a disk → polkit dialog should
|
||||
float+center; if not, note `hyprctl clients` class while open.
|
||||
(2) `gpg --sign` or any pinentry prompt → same. (3) App file picker
|
||||
via portal → capture class for residual rule. (4) blueman-manager /
|
||||
system-config-printer still float (`.…-wrapped` tolerance).
|
||||
|
||||
- [ ] **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; optional
|
||||
Use for login (on) → `sys-rebuild` → login/sudo accept fingerprint.
|
||||
Menu surface is V1/V2 without a reader (self-gate + dry paths).
|
||||
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 (already covered by existing charge-limit re-apply item).
|
||||
a no-op (charge-limit re-apply passed on `BAT0` — Latitude
|
||||
2026-07-10).
|
||||
- [ ] **btop theme fidelity (#52 residual)** — softGL theme-shot cannot
|
||||
open Ghostty, 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-theme-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.
|
||||
- [ ] **Battery charge limit re-apply on unplug** — `sudo nixos-rebuild
|
||||
switch` with `power.batteryChargeLimit = 80`, physically unplug/replug
|
||||
AC, confirm `charge_control_end_threshold` re-reads 80. (udev trigger
|
||||
already VM-verified.)
|
||||
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.
|
||||
@@ -49,17 +311,6 @@ QA machine), the **T14s** (webcam case).
|
||||
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.
|
||||
- [x] **Night-light full cycle** — first menu enable rebuilds + hyprsunset
|
||||
starts; later toggles are instant (no rebuild); an *off* survives
|
||||
reboot (ExecCondition); stopping hyprsunset restores gamma.
|
||||
— 2026-07-04 Bernardo: PASS enable/disable on hardware
|
||||
(off-survives-reboot + gamma-restore not explicitly checked).
|
||||
- [x] **Auto-timezone** — enable from the menu; confirm geoclue finds the
|
||||
zone, `/etc/localtime` updates, and the Waybar clock refreshes
|
||||
(SIGUSR2 watcher) — also after a manual `timedatectl set-timezone`.
|
||||
— 2026-07-04 Bernardo: PASS — enabled from the menu, `timedatectl`
|
||||
shows Europe/London (the manual set-timezone re-trigger wasn't
|
||||
exercised).
|
||||
- [ ] **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
|
||||
@@ -67,16 +318,8 @@ QA machine), the **T14s** (webcam case).
|
||||
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 IS a real bug → BACKLOG item 26 (fix shipped — see the
|
||||
dedicated SUPER+? entry below).
|
||||
- [ ] **SUPER+? opens the cheatsheet** (item 32 re-fix — supersedes the
|
||||
item 26 attempt) — the bind is now `$mod SHIFT, slash` (the BASE
|
||||
keysym: Hyprland 0.55 resolves the sym with Shift consumed, so the
|
||||
old `question` keysym never matched while Shift was down — same
|
||||
pattern as the working `$mod SHIFT, 1` workspace binds). After
|
||||
`home-update` + relogin (or `hyprctl reload`), SUPER+? must open the
|
||||
keybindings cheatsheet, whose row still reads `SUPER + ?` (not
|
||||
`SUPER + SHIFT + ?`).
|
||||
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
|
||||
@@ -96,56 +339,21 @@ QA machine), the **T14s** (webcam case).
|
||||
(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.
|
||||
- [ ] **Network menu has no blank rows** (item 22 fix, needs Wi-Fi) —
|
||||
after `home-update`, open Tools › Network: no empty separator
|
||||
rows between the ethernet/wifi/VPN sections (compact = True).
|
||||
KNOWN RESIDUAL: if exactly one nameless wifi row remains showing
|
||||
only security+bars, that's a hidden-SSID AP — config can't filter
|
||||
it; report it and the follow-up is a small source patch skipping
|
||||
empty-name APs in create_ap_list.
|
||||
- [ ] **Keyboard hotplug picker (re-verify after in-flake graduation)** —
|
||||
plug an external keyboard post-login, pick a layout in rofi, confirm
|
||||
it applies per-device only, persists in `settings.keyboard.devices`,
|
||||
and graduates into a `device{}` block on the next rebuild.
|
||||
- [x] **Snapshots restore/rollback** — ⚠ PRECONDITION: update to main ≥
|
||||
a47aa3a and RELOGIN first (the polkit agent starts with the session).
|
||||
Bernardo's 2026-07-04 Latitude findings 5/6 ("btrfs-assistant still
|
||||
crashes", "menu snapshots shows nothing") match the PRE-fix behavior
|
||||
exactly: no agent → pkexec fails silently (= menu does nothing), and
|
||||
a direct unprivileged run crashes (= the libbtrfsutil bug). If either
|
||||
still reproduces ON THE FIXED BUILD after relogin, reopen BACKLOG
|
||||
item 4 as [stuck]. Then: `nomarchy-menu snapshot` now opens
|
||||
the **btrfs-assistant GUI**: a *themed* polkit prompt must appear
|
||||
(hyprpolkitagent — first on-hardware outing) and the GUI must open
|
||||
as root. Also exercise the fzf fallback in a terminal
|
||||
(`sudo nomarchy-snapshots`): browse/diff, restore a file
|
||||
(`undochange`), and a root-config rollback behind the typed-`yes`
|
||||
gate. Bonus check: any other pkexec flow now prompts instead of
|
||||
silently failing. — 2026-07-04 Bernardo: PASS on the fixed build —
|
||||
polkit prompt appears, GUI opens as root, fzf fallback works.
|
||||
Restore/rollback not exercised → residual item below.
|
||||
- [ ] **Snapshots restore + rollback exercise** (residual from the item
|
||||
above — the GUI/polkit half passed 2026-07-04): in
|
||||
- [ ] **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.
|
||||
- [ ] **Caffeine toggle (item 13 slice)** — click in the bar → turns
|
||||
(warm tint on the generated bar) and hypridle must NOT lock /
|
||||
blank while it's on (wait past the 5-min lock); click again
|
||||
releases. Also present on both summer bars now.
|
||||
- [ ] **Screen recording (item 12)** — Tools › Capture: Record region
|
||||
(slurp) and Record screen produce a playable .mp4 in
|
||||
~/Videos/Recordings; the red ⏺ REC appears in the bar instantly
|
||||
and CLICKING IT stops + notifies the path; the + audio variants
|
||||
carry sound. While recording, the Capture menu offers only
|
||||
"■ Stop recording". On the AMD box wl-screenrec (VAAPI) should
|
||||
do the work; if it dies at start the wf-recorder fallback kicks
|
||||
in silently — check the notification says which. Summer bars
|
||||
have the same ⏺ (parity).
|
||||
- [ ] **Doctor (item 10)** — menu › System › Doctor opens the sheet in
|
||||
a terminal; `nomarchy-doctor` over SSH shows the same minus user
|
||||
units. On a healthy machine everything is ✔/– (dev-box run
|
||||
2026-07-04 correctly flagged a genuinely failed user unit).
|
||||
- [ ] **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,
|
||||
@@ -154,12 +362,10 @@ QA machine), the **T14s** (webcam case).
|
||||
snapshot flow, "boot an older generation (how)" fires an
|
||||
instruction notification.
|
||||
- [ ] **Open-a-file smoke (viewers + mime defaults, item 8)** — after
|
||||
`home-update`: from yazi/Thunar, open a PDF (→ zathura, themed to
|
||||
`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. With no browser installed,
|
||||
clicking a link must still fall through to *something sane* (the
|
||||
firefox.desktop entries are inert); if you've uncommented a
|
||||
browser, links go there.
|
||||
`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
|
||||
@@ -167,6 +373,10 @@ QA machine), the **T14s** (webcam case).
|
||||
- [ ] **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:
|
||||
@@ -175,14 +385,6 @@ QA machine), the **T14s** (webcam case).
|
||||
- [ ] **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.
|
||||
- [x] **Auto-commit on a real machine** — System › Auto-commit toggles on
|
||||
(row shows state, notification fires); a theme apply from SUPER+T then
|
||||
shows a `nomarchy: apply theme …` commit in `~/.nomarchy` (`git log`);
|
||||
unrelated dirty files in the checkout stay uncommitted; toggle off is
|
||||
itself the last commit. Also: the "Auto timezone (on/off)" row label
|
||||
now reflects the real state (the `= true` comparison fix).
|
||||
— 2026-07-04 Bernardo: PASS — theme changes committed and the
|
||||
toggle self-committed.
|
||||
- [ ] **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:
|
||||
@@ -217,10 +419,6 @@ QA machine), the **T14s** (webcam case).
|
||||
(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.
|
||||
- [ ] **Clock zone tooltip (LATER item)** — hover the bar clock: the
|
||||
tooltip's first line shows the zone ("BST (UTC+0100)") above the
|
||||
calendar, on the generated AND summer bars; with auto-timezone
|
||||
on, a zone change updates it (the SIGUSR2 reload).
|
||||
- [ ] **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
|
||||
@@ -260,95 +458,155 @@ QA machine), the **T14s** (webcam case).
|
||||
draining. (Crossing logic VM-verified — this checks the real
|
||||
swaync rendering in a session.)
|
||||
|
||||
- [ ] **Battery charge-limit toggle** (iteration #55) — control-center
|
||||
(menu › System › Control Center › System Toggles › Battery Limit):
|
||||
the preset picker (Off / 80% / 90% / 60% / Custom…) writes the value
|
||||
(`nomarchy-theme-sync get settings.power.batteryChargeLimit` reflects
|
||||
the pick); after a `sys-rebuild` the sysfs
|
||||
`charge_control_end_threshold` reads it. (UX-only over the already
|
||||
VM-verified writer — a session sanity pass, not a deep check.)
|
||||
- [ ] **Waybar hides under fullscreen video** (item 30) — after
|
||||
`home-update` + relogin, put a browser video (YouTube) into
|
||||
fullscreen (F): the bar is now *covered* by the video, not drawn
|
||||
on top; exit fullscreen → the bar returns. Normal tiling
|
||||
unchanged (the bar still reserves its space). Accepted trade-off
|
||||
of `layer: bottom`: a floating window dragged over the top strip
|
||||
can now overlap the bar — confirm that's the only regression.
|
||||
- [ ] **Battery limit in rofi System** (iteration #64, item 36a) — after
|
||||
`home-update`: menu › System shows a "Battery limit" row (this laptop
|
||||
exposes the threshold node); picking a preset (80/90/60/Off/Custom)
|
||||
writes it (`nomarchy-theme-sync get settings.power.batteryChargeLimit`
|
||||
reflects the pick) and after a `sys-rebuild` the sysfs
|
||||
`charge_control_end_threshold` matches. The gum control-center no
|
||||
longer lists Battery Limit (moved, not duplicated).
|
||||
- [ ] **Config dialogs float** (iteration #63, item 41 cut) — after
|
||||
`home-update` + relogin: open Bluetooth (blueman-manager) and, if
|
||||
printing is on, System ▸ Printers (system-config-printer) — each
|
||||
opens floating + centered, not tiled. If either still tiles, run
|
||||
`hyprctl clients` while it's open, read the real `class`, and fix the
|
||||
regex. While there, capture the polkit prompt's and a GTK file
|
||||
dialog's class for the next 41 slice.
|
||||
- [ ] **Right-click volume → floating mixer** (iteration #62, item 35) —
|
||||
after `home-update` + relogin: right-click the Waybar volume module
|
||||
opens pwvucontrol as a floating, centered window (not tiled);
|
||||
left-click still mutes. Verify across a whole-swap theme too
|
||||
(summer/boreal/executive-slate) since the jsoncs got the same wiring.
|
||||
- [ ] **Window focus on arrows** (iteration #61) — after `home-update` +
|
||||
relogin: SUPER+←/→/↑/↓ move focus between tiled windows; SUPER+H/J/K/L
|
||||
no longer move focus; SUPER+? cheatsheet shows the arrow glyphs.
|
||||
- [ ] **Audio opens in Amberol** (iteration #60, item 37) — after
|
||||
`home-update`: double-click an mp3/flac/ogg (or `xdg-open song.mp3`)
|
||||
`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.)
|
||||
- [ ] **Capture-to-file keybinds** (iteration #59, item 38) — after
|
||||
`home-update` + relogin: SHIFT+Print prompts a region select then
|
||||
saves a PNG under ~/Pictures/Screenshots (toast shows the path);
|
||||
CTRL+Print saves the whole screen the same way; bare Print still
|
||||
copies a region to the clipboard. Both new rows appear in SUPER+?.
|
||||
Also (iteration #66): Tools ▸ Capture now shows the matching dim key
|
||||
hint on the Region → clipboard / Region → file / Full screen → file
|
||||
rows (Print / SHIFT + Print / CTRL + Print) — pango renders, no raw
|
||||
`<span>` leaks.
|
||||
- [ ] **rofi menu polish** (iteration #58, item 39 + #56/#57 34/40) —
|
||||
after `home-update`: (a) menu › System › Power profile shows a
|
||||
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).
|
||||
- [ ] **Combined power menu** (iteration #67, item 36b) — after
|
||||
`home-update` + relogin: clicking EITHER the Waybar battery icon OR
|
||||
the power-profile icon opens one "Power" menu listing Profile: rows
|
||||
+ Charge limit: rows; picking a profile calls `powerprofilesctl set`
|
||||
(verify `powerprofilesctl get` changes), picking a charge preset
|
||||
writes `settings.power.batteryChargeLimit` (applies next rebuild).
|
||||
The prompt shows the current profile + limit. System ▸ Power profile
|
||||
/ Battery limit still work directly; the whole-swap bars (summer-day/
|
||||
night, executive-slate, boreal) behave the same and their shutdown
|
||||
power-button (if present) still opens the lock/logout menu.
|
||||
|
||||
- [ ] **Calendar on the clock click** (iteration #68, item 42) — after
|
||||
`home-update` + relogin: left-click the Waybar date/clock → calcurse
|
||||
opens in a **floating, centered** ghostty window (~60×65% of screen),
|
||||
showing the month calendar; closing calcurse (`q`) closes the window.
|
||||
The `--class=com.nomarchy.calendar` must match the windowrule (if it
|
||||
tiles, run `hyprctl clients` while open and check the real app-id).
|
||||
Hover the clock → tooltip shows the long date + zone + month grid.
|
||||
Same on a whole-swap bar (summer/boreal/executive-slate). If calcurse
|
||||
was removed from home.packages, the click toasts "calcurse isn't
|
||||
installed" instead of doing nothing.
|
||||
- [ ] **Screenshot annotation (satty)** (iteration #73) — after `home-update`:
|
||||
- [ ] **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).
|
||||
- [ ] **#150 hypridle wake path now uses the FULL undock** — after pulling
|
||||
the #150 commit + `nomarchy-home`: the hyprland-side transition/keyboard
|
||||
tools are byte-identical (proven by store-path equality), so only the
|
||||
hypridle wake behaviour changed. Two checks: **(a) suspend/resume while
|
||||
docked** (lid closed, external on) — suspend, resume; pass = you land
|
||||
back docked on the external with the panel still off and remembered
|
||||
external-keyboard layouts intact (the wake now runs the full undock,
|
||||
which `restore_keyboards` after any reload; the old mini did not). **(b)
|
||||
zero-output rescue** — the #127 case (dock mode, external gone/black,
|
||||
eDP disabled): pass = the internal panel comes back lit, workspaces move
|
||||
to it, and `~/nomarchy-display-dump-*.txt` is written iff it stays dark.
|
||||
Watch for a regression: the full undock clears dock-intent, so confirm a
|
||||
*transient* zero-output blip on resume while genuinely still docked does
|
||||
not leave you undocked with the laptop panel on (the external re-add
|
||||
should re-dock via the watcher — verify it does).
|
||||
|
||||
## 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
|
||||
@@ -358,22 +616,42 @@ QA machine), the **T14s** (webcam case).
|
||||
proves the menu row + flow renders; a real capsule write is
|
||||
hardware-only.)
|
||||
|
||||
## Latitude 5410 only
|
||||
- [x] **Waybar theme-switch resilience** (finding #1 re-test, needs main ≥
|
||||
the supervisor commit + relogin) — switch themes repeatedly (incl.
|
||||
summer-day/night whole-swaps): the bar restarts cleanly each time;
|
||||
if anything kills it, it's back within ~a second. `pgrep -f
|
||||
nomarchy-waybar` shows the supervisor. — 2026-07-04 Bernardo:
|
||||
PASS — theme switches clean; pkill respawn so fast the bar never
|
||||
visibly disappears.
|
||||
- [x] **Media keys + gestures** (from dccceb4) — volume/brightness keys
|
||||
drive the OSD; touchpad gestures work. — 2026-07-04 Bernardo:
|
||||
PASS, volume/brightness keys and touchpad gestures all working.
|
||||
## 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.
|
||||
|
||||
- [ ] **Theme-switch bar round-trip (2026-07-19 waybar-poke + gtk-pin fix)** —
|
||||
after `nomarchy-pull` + `nomarchy-home`, switch dark → light → dark
|
||||
(e.g. boreal → kiln-clay → kiln). Pass = the bar visibly restarts on
|
||||
each switch and the workspace digits land per theme (dark-on-accent
|
||||
active pill, dimmed inactives on dark themes) with no color carried
|
||||
over from the previous theme; then start/stop a screen recording and
|
||||
toggle airplane mode — the supervisor must survive both pokes
|
||||
(`pgrep -lx nomarchy-waybar` still lists it).
|
||||
|
||||
## Latitude 5310 / 5410 only
|
||||
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —
|
||||
general smoke before the next `main → v1` promotion.
|
||||
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
3768
agent/JOURNAL.md
3768
agent/JOURNAL.md
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,12 @@
|
||||
|
||||
How an AI agent works on Nomarchy unattended. One **iteration** = pick one
|
||||
task, do it, verify it, commit it, record it. The protocol is
|
||||
runner-agnostic; the same iteration works under any of:
|
||||
harness- and vendor-agnostic; the same iteration works under any of:
|
||||
|
||||
- **Interactive `/loop`** in a Claude Code session in this repo — the agent
|
||||
self-paces iterations until stopped.
|
||||
- **Headless** (`claude -p`, cron/systemd-timer) — one invocation runs one
|
||||
iteration (or a small fixed number) and exits.
|
||||
- **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.
|
||||
@@ -26,23 +26,25 @@ outside the checkout (the distro's own philosophy, applied to its agents).
|
||||
| `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.
|
||||
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
|
||||
top-tier model running the loop (Fable 5).
|
||||
- **Delegate mechanical subtasks to cheaper models.** When a subtask is
|
||||
fully specified and needs no design judgment — grep/audit sweeps,
|
||||
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 subagent
|
||||
with a `model` override: `haiku` for search/summarize/audit, `sonnet`
|
||||
for routine well-specified edits. The strong model writes the spec,
|
||||
reviews the result, and owns the commit. Only delegate when writing
|
||||
the spec is cheaper than doing the work — a spawned agent starts cold
|
||||
and must re-derive context.
|
||||
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
|
||||
@@ -50,7 +52,7 @@ Spend expensive tokens on judgment, not mechanics.
|
||||
- **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
|
||||
docs-drift passes) on a cheaper model; iterations touching
|
||||
`modules/` or `pkgs/` behavior keep the strong model.
|
||||
|
||||
## One iteration, step by step
|
||||
@@ -130,6 +132,14 @@ the reusable recipes).
|
||||
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
|
||||
|
||||
130
agent/MEMORY.md
130
agent/MEMORY.md
@@ -7,7 +7,12 @@ here the moment a debugging session teaches you something a future
|
||||
iteration would otherwise rediscover.
|
||||
|
||||
## Testing & VM recipes
|
||||
- **theme-shot softGL cannot start Ghostty** — `btop.png` is best-effort
|
||||
- **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).
|
||||
@@ -25,17 +30,41 @@ iteration would otherwise rediscover.
|
||||
- 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).
|
||||
- CI (`.gitea/workflows/check.yml`) is **eval-tier only**: the act_runner
|
||||
is a docker container (no systemd, no /dev/kvm). Container gotchas are
|
||||
documented 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) — learned over the legacy repo's 57 runs; read
|
||||
them before touching the workflow.
|
||||
- **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).
|
||||
@@ -46,14 +75,17 @@ iteration would otherwise rediscover.
|
||||
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
|
||||
uevent, `InvocationID` change proves the restart).
|
||||
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/Ghostty need guest GL (`virtio-vga-gl`, `gl=on`) in
|
||||
interactive QEMU or the session won't start; black screen ≈ missing GL
|
||||
(docs/TESTING.md § gotchas).
|
||||
- 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
|
||||
@@ -68,11 +100,26 @@ iteration would otherwise rediscover.
|
||||
- **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.
|
||||
- `theme-state.json` is git-tracked inside an 86 MB flake tree, so every
|
||||
- `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
|
||||
@@ -80,8 +127,44 @@ iteration would otherwise rediscover.
|
||||
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
|
||||
@@ -110,9 +193,17 @@ iteration would otherwise rediscover.
|
||||
- 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: `sys-update` (lock) before
|
||||
`home-update`, or desktop changes are silently skipped against the old
|
||||
- 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
|
||||
@@ -120,8 +211,9 @@ iteration would otherwise rediscover.
|
||||
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).
|
||||
- `.claude/skills/*/SKILL.md` are now **tracked** (the `.gitignore`
|
||||
`.claude/skills/` line was un-commented→removed, 7d52d4b) — commit skill
|
||||
edits like any repo doc. Still 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).
|
||||
- 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).
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
# Agent loop state
|
||||
# Agent instructions + loop state
|
||||
|
||||
Git-tracked state for autonomous and assisted work on Nomarchy.
|
||||
Protocol: **[LOOP.md](LOOP.md)**. Entry for most harnesses: repo-root
|
||||
**[CLAUDE.md](../CLAUDE.md)**.
|
||||
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)**.
|
||||
|
||||
## Files
|
||||
## Instructions (how to work)
|
||||
|
||||
| File | Who writes | Role |
|
||||
|------|------------|------|
|
||||
| [LOOP.md](LOOP.md) | Human | One-iteration protocol (orient → pick → work → verify → commit → record) |
|
||||
| [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) |
|
||||
| [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 |
|
||||
|
||||
@@ -24,15 +32,19 @@ Protocol: **[LOOP.md](LOOP.md)**. Entry for most harnesses: repo-root
|
||||
| [../docs/ROADMAP.md](../docs/ROADMAP.md) | Design history + shipped log |
|
||||
| [../docs/README.md](../docs/README.md) | Full docs map |
|
||||
|
||||
## Claude Code only
|
||||
## Harness adapters (vendor-specific, thin)
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| [../.claude/settings.json](../.claude/settings.json) | Tool permissions |
|
||||
| [../.claude/agents/](../.claude/agents/) | `nomarchy-scout` / `nomarchy-runner` subagent defs |
|
||||
Shared content never lives in an adapter — adapters only register/route
|
||||
into the files above, in whatever format their harness requires.
|
||||
|
||||
Do **not** put backlog items or vision text under `.claude/` — it is not
|
||||
shared with other agent runners.
|
||||
| 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
|
||||
|
||||
|
||||
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.
|
||||
191
docs/HARDWARE.md
191
docs/HARDWARE.md
@@ -3,6 +3,10 @@
|
||||
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).
|
||||
@@ -120,9 +124,10 @@ Disable on VMs/headless: `services.fwupd.enable = false;` in `system.nix`.
|
||||
|
||||
| Present | Missing |
|
||||
|---------|---------|
|
||||
| Daemon + package | First-boot / MOTD hint |
|
||||
| README one-liner | Doctor check (“updates available”) |
|
||||
| **System ▸ Firmware menu** (`nomarchy-menu firmware`) | Waybar / updates panel integration |
|
||||
| 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)
|
||||
@@ -131,8 +136,15 @@ 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.
|
||||
|
||||
**Still queued:** a post-install / MOTD one-liner when `fwupd` is active,
|
||||
a Doctor “updates available” check, and Waybar/updates-panel integration.
|
||||
**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
|
||||
|
||||
@@ -161,25 +173,79 @@ 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) — Enroll / List / Verify / Delete all, plus **Use for login**
|
||||
which writes `settings.fingerprint.pam` and applies on the next
|
||||
`sys-rebuild` (option default follows theme-state.json).
|
||||
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"
|
||||
# or: System › Fingerprint › Use for login (on) → sys-rebuild
|
||||
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.
|
||||
@@ -227,18 +293,19 @@ 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):** 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). A full `nomarchy.hardware.nvidia.*` stack stays optional and
|
||||
needs a maintainer + hardware-queue coverage.
|
||||
**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 / `settings.power.batteryChargeLimit` | System (sysfs apply is separate backlog) |
|
||||
| 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 |
|
||||
@@ -250,6 +317,36 @@ needs a maintainer + hardware-queue coverage.
|
||||
| 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
|
||||
@@ -305,7 +402,40 @@ 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. Adding your model to the distro
|
||||
## 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):
|
||||
|
||||
@@ -333,15 +463,15 @@ For contributors (and power users who will PR):
|
||||
`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).
|
||||
|
||||
## 10. Doctor and hardware health (current vs target)
|
||||
## 11. Doctor and hardware health (current vs target)
|
||||
|
||||
**Today** (`nomarchy-doctor`): failed units, disk space, theme-state
|
||||
validity/git, generation age, snapper timer. **No** Wi‑Fi, GPU, fwupd,
|
||||
or fingerprint checks.
|
||||
|
||||
**Target checks** (queued, all read-only, each failure prints one fix
|
||||
command — same doctor contract):
|
||||
**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 |
|
||||
|-------|----------------|--------------------|
|
||||
@@ -350,9 +480,12 @@ command — same doctor contract):
|
||||
| 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 writable | power docs |
|
||||
| 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 |
|
||||
|
||||
## 11. Option quick reference
|
||||
## 12. Option quick reference
|
||||
|
||||
Full tables: [README § options](../README.md). Hardware-shaped surface:
|
||||
|
||||
@@ -361,16 +494,16 @@ Full tables: [README § options](../README.md). Hardware-shaped surface:
|
||||
| `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` | fprintd + PAM |
|
||||
| `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 |
|
||||
| `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) |
|
||||
|
||||
## 12. Ease summary
|
||||
## 13. Ease summary
|
||||
|
||||
| Situation | Effort |
|
||||
|-----------|--------|
|
||||
@@ -382,7 +515,7 @@ Full tables: [README § options](../README.md). Hardware-shaped surface:
|
||||
| ROCm / NPU userspace | High — opt-in + expert |
|
||||
| Contribute a new DMI line | Medium for someone who can PR |
|
||||
|
||||
## 13. Related files
|
||||
## 14. Related files
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
|
||||
@@ -30,6 +30,17 @@ assumptions. Check each:
|
||||
| **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.
|
||||
|
||||
@@ -158,6 +169,12 @@ ryzenadj)**, **no Secure Boot**, and the **stateVersion override**.
|
||||
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";
|
||||
}
|
||||
@@ -189,9 +206,9 @@ emacs setup).
|
||||
}
|
||||
```
|
||||
|
||||
### `theme-state.json`
|
||||
### `state.json`
|
||||
|
||||
Copy the template's `theme-state.json` (or let `nomarchy-menu theme` write
|
||||
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.
|
||||
@@ -215,14 +232,23 @@ Expect to resolve a few 25.11→26.05 option renames.
|
||||
# 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:
|
||||
home-manager switch --flake ~/nomarchy-migrate#bernardo
|
||||
# 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. Confirm the
|
||||
machine‑specific things you care about still work — suspend/hibernate, the
|
||||
AMD GPU (`vainfo` → radeonsi), display brightness.
|
||||
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.
|
||||
@@ -234,10 +260,80 @@ default yet.
|
||||
- **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 **nord** (writes
|
||||
`theme-state.json`).
|
||||
- **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
|
||||
@@ -288,6 +384,146 @@ lock).
|
||||
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
|
||||
|
||||
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
|
||||
@@ -43,9 +99,8 @@ in your `home.nix` wins — no `mkForce` needed:
|
||||
animations.enabled = false;
|
||||
};
|
||||
|
||||
programs.ghostty.settings = {
|
||||
window-padding-x = 4; # was 12
|
||||
window-decoration = true;
|
||||
programs.kitty.settings = {
|
||||
window_padding_width = 4; # was 12
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -143,7 +198,7 @@ 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) |
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 |
|
||||
@@ -13,14 +14,15 @@ tree for the same facts.
|
||||
| [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 |
|
||||
|------|------|
|
||||
| [../agent/README.md](../agent/README.md) | **Executable** agent loop state: BACKLOG, LOOP, MEMORY, … |
|
||||
| [../CLAUDE.md](../CLAUDE.md) | Agent entry point (any harness that reads it) |
|
||||
| [../.claude/](../.claude/) | Claude Code only: permissions + subagent defs |
|
||||
| [../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
|
||||
|
||||
|
||||
@@ -21,17 +21,21 @@ 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 › Rollback** lists the
|
||||
recent desktop generations — pick one and it activates.
|
||||
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-theme-sync apply
|
||||
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 `home-update`).
|
||||
`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: `git -C ~/.nomarchy log` to see what changed,
|
||||
`git revert` the culprit, then `home-update`.
|
||||
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
|
||||
|
||||
@@ -45,6 +49,9 @@ 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
|
||||
@@ -53,22 +60,59 @@ journalctl --user -b # Hyprland + the user services
|
||||
## 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; the
|
||||
last 10 generations are kept). That boots yesterday's system unchanged.
|
||||
(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 `sys-rebuild`.
|
||||
back), then `nomarchy-rebuild`.
|
||||
|
||||
## 4. Files went missing or wrong — snapshots (BTRFS installs)
|
||||
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 › Snapshots (btrfs-assistant; expects a polkit
|
||||
password prompt).
|
||||
- **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
|
||||
@@ -77,7 +121,7 @@ BTRFS), the root filesystem has hourly/daily history, and
|
||||
Snapshots are the undo for *data on disk*; the Nix config model is
|
||||
undone by generations (§1/§3) — use each for its half.
|
||||
|
||||
## 5. Last resort — from the outside
|
||||
## 6. Last resort — from the outside
|
||||
|
||||
Boot the Nomarchy ISO (any NixOS ISO works), then:
|
||||
|
||||
@@ -88,5 +132,5 @@ 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
|
||||
§4. If you get this far with something Nomarchy shipped broken, please
|
||||
§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 |
|
||||
1197
docs/ROADMAP.md
1197
docs/ROADMAP.md
File diff suppressed because it is too large
Load Diff
@@ -47,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
|
||||
@@ -56,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
|
||||
|
||||
@@ -67,12 +70,12 @@ Work through these in order; each one exercises a different layer.
|
||||
|---|---|---|
|
||||
| 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 the default theme colors (Boreal) | 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 24 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 |
|
||||
| 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-theme-sync apply summer-night` → after the switch the bar has its own identity (light bar, different styling) | whole-swap waybar.css assets |
|
||||
| 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 |
|
||||
|
||||
@@ -105,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
|
||||
@@ -114,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`.
|
||||
|
||||
@@ -51,17 +51,19 @@ 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 | Likely home |
|
||||
|------|--------|-------------|
|
||||
| **System › Firmware** | fwupd is on but CLI-only; menu: refresh → list → confirm → update (never auto-flash) | BACKLOG Hardware product |
|
||||
| **Fingerprint menu** | Enroll/list when fprintd present; optional PAM + rebuild note | BACKLOG Hardware product |
|
||||
| **Doctor hardware section** | NM, sink, optional GPU smoke, fprintd, fwupd pending, charge threshold | BACKLOG Hardware product |
|
||||
| **Machine health entry** | One System row → doctor (not five submenus) | Menu / doctor |
|
||||
| **Human rebuild errors** | On failed switch, point at last log lines + `nomarchy-doctor` | pkgs / menu |
|
||||
| **HM pre-activate fail flag** | Durable recovery one-liner on target if bake failed | Installer |
|
||||
| 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, §10.
|
||||
Reference: [HARDWARE.md](HARDWARE.md) §4–§5, §11 (doctor).
|
||||
|
||||
---
|
||||
|
||||
@@ -69,10 +71,10 @@ Reference: [HARDWARE.md](HARDWARE.md) §4–§5, §10.
|
||||
|
||||
| Idea | Intent | Notes |
|
||||
|------|--------|-------|
|
||||
| **Silent first-week card** | One dismissible “you’re set” (menu, theme, wallpaper, network) — not a wizard | Prefer notify or doctor “first boot” section |
|
||||
| **Boreal as default** | Identity on first boot | BACKLOG PROPOSED already |
|
||||
| **Generation readability** | “What changed last rebuild” in plain language | Rollback exists; story is incomplete |
|
||||
| **Post-install hints** | One-shot MOTD/notify for fwupd / fprintd when relevant | Avoid permanent nag |
|
||||
| **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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -80,10 +82,10 @@ Reference: [HARDWARE.md](HARDWARE.md) §4–§5, §10.
|
||||
|
||||
| Idea | Intent | Notes |
|
||||
|------|--------|-------|
|
||||
| **Dock life** | Lid closed + external only, wake, default audio sink on undock | Display profiles exist — polish edge cases |
|
||||
| **Hibernate/sleep confidence** | Doctor: resume device, swap size, clean suspend journal | Trust > new power UI |
|
||||
| **Battery health readout** | Cycles / charge limit where sysfs allows | Report-only |
|
||||
| **Charge-limit instant apply** | Already PROPOSED `[blocked:hw]` | Keep privilege tradeoff explicit |
|
||||
| **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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -91,8 +93,8 @@ Reference: [HARDWARE.md](HARDWARE.md) §4–§5, §10.
|
||||
|
||||
| Idea | Intent | Notes |
|
||||
|------|--------|-------|
|
||||
| **Fewer themes, sharper** | Finish or quarantine neon-glass; invest in whole-swap quality | Contrast stays gated |
|
||||
| **Time-of-day pair** | Auto light/dark (e.g. summer-day ↔ summer-night) from schedule | Still one theme engine |
|
||||
| **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 |
|
||||
|
||||
---
|
||||
@@ -111,8 +113,8 @@ Reference: [HARDWARE.md](HARDWARE.md) §4–§5, §10.
|
||||
|
||||
| Idea | Intent | Notes |
|
||||
|------|--------|-------|
|
||||
| **Omarchy migrant one-pager** | Bindings/theme/install map | `docs/` short guide |
|
||||
| **Hardware hall of fame** | Install-tested models; invite DMI PRs | Ties to HARDWARE.md §9 |
|
||||
| **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 |
|
||||
|
||||
---
|
||||
@@ -150,18 +152,14 @@ From GOALS non-goals and installer audits — do not “fill the roadmap” with
|
||||
|
||||
## Suggested agent slices (promote via PROPOSED → NEXT)
|
||||
|
||||
Small enough for one iteration each; reference this file:
|
||||
**Still open** (as of 2026-07-17). Shipped items above are *not* open work.
|
||||
|
||||
1. `VISION § v1.0` — default theme → boreal (seed state + fallbacks)
|
||||
2. `VISION § A` — System › Firmware (fwupd wrapper)
|
||||
3. `VISION § A` — doctor hardware section (read-only checks)
|
||||
4. `VISION § A` — fingerprint enroll menu (self-gated)
|
||||
5. `VISION § B` — first-boot dismissible tips (one surface)
|
||||
6. `VISION § A` — human-facing rebuild failure hint
|
||||
7. `VISION § v1.0` — install P0 contracts (swap / unattended LUKS)
|
||||
8. `VISION § D` — neon-glass quarantine or finish
|
||||
9. `VISION § F` — Omarchy migrant doc (short)
|
||||
10. `VISION § H` — HARDWARE-QUEUE session notes only Bernardo runs
|
||||
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)
|
||||
|
||||
Larger themes (dock life, time-of-day theme, wallpapers split) stay
|
||||
`[big]` until split.
|
||||
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": 1783221248,
|
||||
"narHash": "sha256-ESQnuNHEDChsB4IxoLRhscVahqkDWkTb+qdIz8euYt4=",
|
||||
"lastModified": 1784350909,
|
||||
"narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "af2beae5f0fae0a4310cc0e6aef2572f56090353",
|
||||
"rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -166,11 +166,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783370751,
|
||||
"narHash": "sha256-E+3MIMvKuo9k+K+qLQ9YXzsBzkgHuyVLnsEbN2DFfuc=",
|
||||
"lastModified": 1784310968,
|
||||
"narHash": "sha256-rkSPTePrKqs4dg+i7ZFCq93+HrClac6oSwXX927SVjA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixos-hardware",
|
||||
"rev": "662bd6e312d2c8b212e32cb377abaee190749320",
|
||||
"rev": "779c32a00155994c86cde8213a8dd4df139d4355",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -182,11 +182,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1783148766,
|
||||
"narHash": "sha256-uslt2pqShTIXDdAHRHv2QkYLsVdY8Oqwz0EA48/RSM8=",
|
||||
"lastModified": 1784432872,
|
||||
"narHash": "sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4+mhPoA34rrqS0GkA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a50de1b7d8a586adc18d2395c19de7d6058e6030",
|
||||
"rev": "fd1462031fdee08f65fd0b4c6b64e22239a77870",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -196,6 +196,39 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1784497964,
|
||||
"narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nomarchy-wallpapers": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1784478251,
|
||||
"narHash": "sha256-sEBx2ce6g0398FlclNwfv5FuwZ1WzLYNHK6jX/2kV88=",
|
||||
"ref": "main",
|
||||
"rev": "5c361e17b524b6ef27fb7ba948269d0e0c6e954e",
|
||||
"revCount": 5,
|
||||
"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": 1783359251,
|
||||
"narHash": "sha256-HUiCnEVlJ4n+qJlZojiz/zv+P0cqM5zsg1dxpz2J7Mg=",
|
||||
"lastModified": 1784060273,
|
||||
"narHash": "sha256-14rIy2kTs5CufmDpgJrwkR+7IzuCAXyLYDAx6ixfRFc=",
|
||||
"owner": "nix-community",
|
||||
"repo": "stylix",
|
||||
"rev": "e602ad042f00409f33c8ad2829cd8d59ba345c7e",
|
||||
"rev": "2245fa9e16034149b6501834b99863a486e94725",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
# 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, username, nomarchySrc, ... }:
|
||||
{ lib, pkgs, config, username, nomarchySrc, ... }:
|
||||
|
||||
let
|
||||
# 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 (Boreal by default). The same composed image
|
||||
# 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 ../theme-state.json);
|
||||
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";
|
||||
@@ -84,13 +84,19 @@ in
|
||||
isoImage.edition = lib.mkForce "live";
|
||||
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
|
||||
@@ -148,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
|
||||
'';
|
||||
@@ -158,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.
|
||||
'';
|
||||
|
||||
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" ];
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,11 @@
|
||||
{
|
||||
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,6 +18,8 @@
|
||||
./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
|
||||
@@ -27,6 +29,8 @@
|
||||
./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
|
||||
];
|
||||
|
||||
@@ -38,7 +42,31 @@
|
||||
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).
|
||||
@@ -53,6 +81,11 @@
|
||||
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";
|
||||
@@ -61,17 +94,31 @@
|
||||
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;
|
||||
}
|
||||
191
modules/home/display-transition.nix
Normal file
191
modules/home/display-transition.nix
Normal file
@@ -0,0 +1,191 @@
|
||||
# The full dock / undock / enable display-transition primitive
|
||||
# (nomarchy-display-transition) — the one safety-critical copy. Extracted
|
||||
# from hyprland.nix (#150) so hyprland.nix and idle.nix share it instead of
|
||||
# idle.nix carrying a lossy mini that lacked logging, the lid-inhibitor
|
||||
# gate, restore_keyboards, and the failure dump.
|
||||
#
|
||||
# One transition primitive for the interactive Dock mode, abrupt undock,
|
||||
# and named profiles that disable the internal panel. Dock is one Hyprland
|
||||
# batch: enable the target, move every internal workspace, focus it, then
|
||||
# disable the panel. Undock deliberately enables the panel FIRST, waits
|
||||
# for it to become an active target, then restores every workspace.
|
||||
#
|
||||
# sync — nomarchy-state-sync exe (dock-intent writes)
|
||||
# keyboardTool — nomarchy-keyboard-layout (restore after reload)
|
||||
# displayDumpTool — nomarchy-display-dump (evidence on enable failure)
|
||||
{ pkgs, sync, keyboardTool, displayDumpTool }:
|
||||
pkgs.writeShellScriptBin "nomarchy-display-transition" ''
|
||||
set -u
|
||||
LOGGER=${pkgs.util-linux}/bin/logger
|
||||
log() { "$LOGGER" -t nomarchy-display-transition -- "$*"; }
|
||||
valid_output() {
|
||||
case "$1" in *[!A-Za-z0-9_.:-]*|"") return 1 ;; *) return 0 ;; esac
|
||||
}
|
||||
workspaces_on() {
|
||||
hyprctl workspaces -j 2>/dev/null \
|
||||
| jq -r --arg m "$1" '.[] | select(.monitor == $m and .id > 0) | .id'
|
||||
}
|
||||
all_workspaces() {
|
||||
hyprctl workspaces -j 2>/dev/null | jq -r '.[] | select(.id > 0) | .id'
|
||||
}
|
||||
lid_inhibitor_held() {
|
||||
${pkgs.systemd}/bin/systemd-inhibit --list --json=short 2>/dev/null \
|
||||
| ${pkgs.jq}/bin/jq -e 'any(.[]; .what == "handle-lid-switch" and .why == "Safe dock/undock display transition" and .mode == "block")' \
|
||||
>/dev/null
|
||||
}
|
||||
batch_moves() { # stdin workspace ids, $1 target
|
||||
target="$1"; commands=
|
||||
while IFS= read -r ws; do
|
||||
case "$ws" in *[!0-9]*|"") continue ;; esac
|
||||
commands="$commands dispatch moveworkspacetomonitor $ws $target;"
|
||||
done
|
||||
printf '%s' "$commands"
|
||||
}
|
||||
# `monitors` (enabled-only) listing the output is the ONLY proof it is
|
||||
# really on: every path here issues keywords hyprctl answers "ok" to
|
||||
# without acting. $1 = output, $2 = polls of 0.2s.
|
||||
output_enabled() {
|
||||
tries=0
|
||||
while [ "$tries" -lt "$2" ]; do
|
||||
hyprctl monitors -j 2>/dev/null \
|
||||
| jq -e --arg m "$1" 'any(.[]; .name == $m)' >/dev/null && return 0
|
||||
tries=$((tries+1)); sleep 0.2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# `hyprctl reload` re-reads the config, and every runtime keyword dies
|
||||
# with it — including the `device[<name>]:kb_layout` the keyboard watcher
|
||||
# applies to an external board. Measured on hardware 2026-07-14: a bare
|
||||
# reload took a keyboard remembered as `us` straight back to the session's
|
||||
# `gb`, so without this the undock's escalation would silently break the
|
||||
# layout of any keyboard that stays behind when the dock leaves (a
|
||||
# wireless receiver in the laptop; only boards on the dock's own hub are
|
||||
# unaffected, and only because they leave with it). The remembered
|
||||
# choices live in the in-flake state, so `restore` is authoritative.
|
||||
restore_keyboards() {
|
||||
hyprctl devices -j 2>/dev/null | jq -r '.keyboards[].name' 2>/dev/null \
|
||||
| while IFS= read -r kb; do
|
||||
[ -n "$kb" ] || continue
|
||||
${keyboardTool}/bin/nomarchy-keyboard-layout restore "$kb" \
|
||||
>/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
|
||||
# Dock mode is an *intent*, and until #142 it lived only in the compositor:
|
||||
# `keyword monitor <internal>, disable` and nothing written down. Any config
|
||||
# reload re-applies the catch-all `monitor=,preferred,auto,1` and re-lights
|
||||
# the panel — which is exactly why `undock` below uses `hyprctl reload` as
|
||||
# its hammer — so every `nomarchy-home` silently performed an undock and the
|
||||
# user re-picked Dock mode by hand.
|
||||
#
|
||||
# So record the intent where every other setting lives (the in-flake state,
|
||||
# menu-writable), and let the watcher re-assert it. `--no-switch`: the
|
||||
# watcher reads this at run time; a rebuild here would be both pointless and
|
||||
# recursive. Failure to write is never fatal — a session that cannot reach
|
||||
# the state file must still dock.
|
||||
set_dock_intent() { # $1 = true|false
|
||||
${sync} --quiet set settings.display.dockMode "$1" --no-switch \
|
||||
>/dev/null 2>&1 || log "dock-intent=$1 result=state-write-failed"
|
||||
}
|
||||
|
||||
case "''${1:-}" in
|
||||
dock)
|
||||
internal="''${2:-}"; external="''${3:-}"
|
||||
rule="''${4:-$external, preferred, auto, 1}"
|
||||
valid_output "$internal" && valid_output "$external" \
|
||||
|| { echo "invalid monitor name" >&2; exit 64; }
|
||||
# Fail closed: disabling the panel without this lock recreates the
|
||||
# exact cable-removal → logind suspend race #100 is fixing.
|
||||
lid_inhibitor_held \
|
||||
|| { log "transition=dock internal=$internal external=$external result=no-lid-inhibitor"; exit 1; }
|
||||
moves=$(workspaces_on "$internal" | batch_moves "$external")
|
||||
if hyprctl --batch \
|
||||
"keyword monitor $rule; $moves dispatch focusmonitor $external; keyword monitor $internal, disable" \
|
||||
>/dev/null 2>&1; then
|
||||
set_dock_intent true
|
||||
log "transition=dock internal=$internal external=$external result=ok"
|
||||
else
|
||||
log "transition=dock internal=$internal external=$external result=failed"
|
||||
exit 1
|
||||
fi ;;
|
||||
undock)
|
||||
internal="''${2:-}"
|
||||
valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; }
|
||||
# Clear the intent *first*: this path runs when the external is already
|
||||
# gone, and if anything below fails the last thing the session needs is
|
||||
# a watcher that keeps trying to dock to a monitor that left.
|
||||
set_dock_intent false
|
||||
# Safety-critical ordering: never remove/move away from an external
|
||||
# before the internal panel is active and can receive workspaces.
|
||||
#
|
||||
# With ZERO enabled outputs — an abrupt undock, the panel already
|
||||
# disabled by the dock — Hyprland 0.55.4 accepts `keyword monitor`
|
||||
# (prints "ok", exits 0) and then never flushes it: the rule is held
|
||||
# until some DRM event arrives, and the panel stays dark. This is
|
||||
# deterministic, not a race. Probed on hardware 2026-07-14: the
|
||||
# keyword was inert across 4s and 5s windows in two separate runs,
|
||||
# `dispatch forcerendererreload` did not flush it either, and only
|
||||
# `hyprctl reload` did — in 99ms and 289ms, with the cable out.
|
||||
#
|
||||
# Round 6 read the same symptom as a teardown race and re-issued the
|
||||
# keyword 25 times a poll. Retrying an inert command cannot work: it
|
||||
# bought 30s of black screen and every `result=ok` in that journal was
|
||||
# really the user plugging the cable back in, whose hotplug flushed
|
||||
# the queued rule and let the next poll take the credit.
|
||||
#
|
||||
# So issue the keyword once — enough whenever another output is still
|
||||
# enabled, e.g. the menu's Dock mode toggle — and escalate to the
|
||||
# reload hammer only when it proves inert.
|
||||
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
|
||||
if ! output_enabled "$internal" 5; then
|
||||
log "transition=undock internal=$internal keyword=inert escalate=reload"
|
||||
hyprctl reload >/dev/null 2>&1
|
||||
if ! output_enabled "$internal" 25; then
|
||||
log "transition=undock internal=$internal result=enable-failed"
|
||||
# #127: leave evidence on disk without SSH (TTY or next login).
|
||||
${displayDumpTool}/bin/nomarchy-display-dump undock-enable-failed \
|
||||
>/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
# The config's monitor rules own the panel now. Re-assert the
|
||||
# runtime rule — which flushes normally, an output being enabled
|
||||
# again — so a config that parks the panel off cannot undo the
|
||||
# undock, and give the keyboards back what the reload took.
|
||||
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
|
||||
restore_keyboards
|
||||
log "transition=undock internal=$internal enable=via-reload"
|
||||
fi
|
||||
moves=$(all_workspaces | batch_moves "$internal")
|
||||
hyprctl --batch "$moves dispatch focusmonitor $internal" >/dev/null 2>&1 \
|
||||
|| { log "transition=undock internal=$internal result=move-failed"; exit 1; }
|
||||
# #127: undock after idle DPMS left the seat black even when the
|
||||
# panel was re-enabled — always light the chain after enable.
|
||||
hyprctl dispatch dpms on >/dev/null 2>&1 || true
|
||||
log "transition=undock internal=$internal result=ok" ;;
|
||||
enable)
|
||||
internal="''${2:-}"
|
||||
valid_output "$internal" || exit 64
|
||||
# Menu ▸ Display ▸ "Screen on". Turning the laptop panel back on *is*
|
||||
# leaving dock mode, and saying so is what stops the watcher from
|
||||
# re-docking the panel out from under the user a second later (#142).
|
||||
# Only the internal clears the intent: lighting some third monitor is
|
||||
# not a statement about the lid.
|
||||
case "$internal" in
|
||||
eDP*|LVDS*|DSI*) set_dock_intent false ;;
|
||||
esac
|
||||
# Reachable only from the menu, i.e. with a screen already on, so the
|
||||
# keyword flushes and no reload escalation is needed — but still make
|
||||
# `monitors` the proof, or the menu's "back on" notification cheers
|
||||
# for a keyword hyprctl merely said "ok" to.
|
||||
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
|
||||
if output_enabled "$internal" 10; then
|
||||
hyprctl dispatch dpms on >/dev/null 2>&1 || true
|
||||
true
|
||||
else
|
||||
false
|
||||
fi ;;
|
||||
*)
|
||||
echo "usage: nomarchy-display-transition [dock <internal> <external> [external-rule]|undock <internal>|enable <internal>]" >&2
|
||||
exit 64 ;;
|
||||
esac
|
||||
''
|
||||
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,48 +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
|
||||
{
|
||||
# Ghostty is ALWAYS installed — it's Nomarchy's default terminal and is
|
||||
# load-bearing (SUPER+E file manager, the calendar launcher's classed
|
||||
# window, etc.), so the distro enforces it. `nomarchy.ghostty.enable` now
|
||||
# gates only whether Nomarchy's theming/config is applied — a user can
|
||||
# keep ghostty but drop our config, they just can't remove ghostty itself.
|
||||
programs.ghostty = {
|
||||
enable = true;
|
||||
enableBashIntegration = true;
|
||||
|
||||
settings = lib.mkIf config.nomarchy.ghostty.enable {
|
||||
# ── 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Hyprland — fully driven by config.nomarchy.theme (theme-state.json).
|
||||
# Hyprland — fully driven by config.nomarchy.theme (state.json).
|
||||
# Gaps, borders and colors are baked from the JSON at eval time; theme
|
||||
# changes arrive via `home-manager switch`.
|
||||
{ config, lib, pkgs, ... }:
|
||||
@@ -8,7 +8,7 @@ let
|
||||
c = t.colors;
|
||||
inherit (config.nomarchy.lib) rgb rgba;
|
||||
|
||||
# nomarchy-theme-sync — the in-flake state writer the keyboard watcher uses
|
||||
# nomarchy-state-sync — the in-flake state writer the keyboard watcher uses
|
||||
# to remember per-device layouts (same path night-light's toggle takes).
|
||||
sync = lib.getExe config.nomarchy.package;
|
||||
|
||||
@@ -20,14 +20,29 @@ let
|
||||
# --custom-progress is the mic level (0.0-1.0), --custom-icon the
|
||||
# muted/active glyph. Pure bash builtins for parsing so the script needs no
|
||||
# PATH beyond the two pinned tools.
|
||||
#
|
||||
# Hardware mic-mute LEDs (ThinkPad platform::micmute, HDA *::micmute, …)
|
||||
# are driven by the kernel's audio-micmute trigger, which tracks ALSA —
|
||||
# not PipeWire software mute. Sync the LED from the resulting mute state
|
||||
# when the node is group-writable (udev rule in modules/nixos/default.nix).
|
||||
micMute = pkgs.writeShellScript "nomarchy-mic-mute" ''
|
||||
${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_SOURCE@ toggle
|
||||
out=$(${pkgs.wireplumber}/bin/wpctl get-volume @DEFAULT_SOURCE@)
|
||||
set -- $out # "Volume: 0.85 [MUTED]" -> $2 is the level
|
||||
case "$out" in
|
||||
*MUTED*) icon=microphone-sensitivity-muted-symbolic ;;
|
||||
*) icon=microphone-sensitivity-high-symbolic ;;
|
||||
*MUTED*) icon=microphone-sensitivity-muted-symbolic; led=1 ;;
|
||||
*) icon=microphone-sensitivity-high-symbolic; led=0 ;;
|
||||
esac
|
||||
for led_dir in /sys/class/leds/*micmute* /sys/class/leds/*::micmute; do
|
||||
[ -e "$led_dir/brightness" ] || continue
|
||||
if [ -w "$led_dir/brightness" ]; then
|
||||
# Drop the kernel trigger so a stuck audio-micmute state cannot
|
||||
# override our write (verified: ALSA Capture mute alone does not
|
||||
# flip platform::micmute on ThinkPad T14s).
|
||||
[ -w "$led_dir/trigger" ] && echo none > "$led_dir/trigger" 2>/dev/null || true
|
||||
echo "$led" > "$led_dir/brightness" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
${pkgs.swayosd}/bin/swayosd-client --custom-icon "$icon" --custom-progress "$2"
|
||||
'';
|
||||
|
||||
@@ -59,15 +74,18 @@ let
|
||||
(e: "${e.mods}, ${e.key}, exec, ${focusOrLaunch}/bin/nomarchy-focus-or-launch ${e.class} ${e.command}")
|
||||
lofEntries;
|
||||
|
||||
# SUPER+1..9 / SUPER+SHIFT+1..9 workspace binds, generated.
|
||||
# SUPER+1..9,0 / SUPER+SHIFT+1..9,0 workspace binds, generated
|
||||
# (the 0 key is workspace 10).
|
||||
workspaceBinds = builtins.concatLists (builtins.genList
|
||||
(i:
|
||||
let ws = toString (i + 1);
|
||||
let
|
||||
ws = toString (i + 1);
|
||||
key = if i == 9 then "0" else ws;
|
||||
in [
|
||||
"$mod, ${ws}, workspace, ${ws}"
|
||||
"$mod SHIFT, ${ws}, movetoworkspace, ${ws}"
|
||||
"$mod, ${key}, workspace, ${ws}"
|
||||
"$mod SHIFT, ${key}, movetoworkspace, ${ws}"
|
||||
])
|
||||
9);
|
||||
10);
|
||||
|
||||
# The keyboard binds — single source shared with the SUPER+? cheatsheet
|
||||
# (rofi.nix renders the same list). Edit them in ./keybinds.nix.
|
||||
@@ -106,6 +124,22 @@ let
|
||||
profileWorkspaceRules =
|
||||
lib.mapAttrsToList monitorLib.workspaceRule activeProfile.workspaces;
|
||||
|
||||
# #127 dump tool first (no transition dep); wake is wired after transition.
|
||||
displayDumpTool =
|
||||
(import ./display-tools.nix { inherit pkgs; }).displayDumpTool;
|
||||
|
||||
# The full dock/undock/enable transition primitive lives in its own file
|
||||
# so idle.nix shares this exact safety-critical copy (#150), not a mini.
|
||||
displayTransitionTool = import ./display-transition.nix {
|
||||
inherit pkgs sync keyboardTool displayDumpTool;
|
||||
};
|
||||
|
||||
displayWakeTool =
|
||||
(import ./display-tools.nix {
|
||||
inherit pkgs;
|
||||
displayTransition = displayTransitionTool;
|
||||
}).displayWakeTool;
|
||||
|
||||
# Profile applier — on PATH only when profiles are declared (the menu
|
||||
# row self-gates on it). apply: the profile's rules live via hyprctl +
|
||||
# the in-flake state write; base: clear the state, then hyprctl reload
|
||||
@@ -121,10 +155,38 @@ ${lib.concatMapStringsSep "\n" (n: " echo ${lib.escapeShellArg n}") (lib.
|
||||
case "''${2:-}" in
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
(name: p:
|
||||
let
|
||||
isInternal = m: builtins.match "^(eDP|LVDS|DSI).*" m.name != null;
|
||||
enabled = lib.filter (m: m.resolution != "disable") p.monitors;
|
||||
disabled = lib.filter (m: m.resolution == "disable") p.monitors;
|
||||
internalOff = lib.findFirst isInternal null disabled;
|
||||
dockTarget = lib.findFirst (m: ! isInternal m) null enabled;
|
||||
safeDock = internalOff != null && dockTarget != null;
|
||||
ordinaryEnabled = if safeDock
|
||||
then lib.filter (m: m.name != dockTarget.name) enabled
|
||||
else enabled;
|
||||
ordinaryDisabled = if safeDock
|
||||
then lib.filter (m: m.name != internalOff.name) disabled
|
||||
else disabled;
|
||||
in
|
||||
" ${lib.escapeShellArg name})\n"
|
||||
# Outputs that will remain active always come first. If this is a
|
||||
# clamshell profile, the helper performs target-enable + workspace
|
||||
# handoff + internal-disable as one batch, avoiding the zero-output and
|
||||
# dangling-workspace states the old per-rule loop could create.
|
||||
+ lib.concatMapStringsSep "\n"
|
||||
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
|
||||
p.monitors
|
||||
ordinaryEnabled
|
||||
+ lib.optionalString safeDock
|
||||
("\n ${displayTransitionTool}/bin/nomarchy-display-transition dock "
|
||||
+ lib.escapeShellArg internalOff.name + " "
|
||||
+ lib.escapeShellArg dockTarget.name + " "
|
||||
+ lib.escapeShellArg (monitorRule dockTarget)
|
||||
+ " >/dev/null 2>&1 || { notify-send \"Display profile\" \"Could not safely disable the laptop panel.\"; exit 1; }")
|
||||
+ lib.optionalString (ordinaryDisabled != [ ]) "\n"
|
||||
+ lib.concatMapStringsSep "\n"
|
||||
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
|
||||
ordinaryDisabled
|
||||
# Workspace pins: the keyword sets the session rule, the dispatch
|
||||
# moves an already-open workspace over. Pins from a previously
|
||||
# applied profile linger until reload/rebuild (hyprctl can only
|
||||
@@ -180,29 +242,140 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
esac
|
||||
'';
|
||||
|
||||
# Hotplug auto-switch (opt-in via the menu's Auto-switch row →
|
||||
# settings.displayProfileAuto, read LIVE on every output change so the
|
||||
# toggle is instant, no rebuild). Polls like the keyboard watcher —
|
||||
# exec-once, not a systemd unit (graphical-session-bound units raced
|
||||
# Hyprland's IPC on relogin; see the waybar note below). Only reacts to
|
||||
# CHANGES after session start: the baked config already encodes the
|
||||
# last choice, and login must not fight a deliberate manual pick.
|
||||
# `match` picks deterministically (exact set, else unambiguous largest
|
||||
# subset); applying via the tool persists the concrete profile, so the
|
||||
# next rebuild bakes what auto chose.
|
||||
# Hyprland hotplug watcher: owns the safety-critical dock lifecycle plus
|
||||
# optional profile auto-switching. A low-level logind lid-switch inhibitor
|
||||
# is acquired as soon as an external appears. On final external removal,
|
||||
# the internal panel is enabled and workspaces restored before the watcher
|
||||
# waits for a physically open lid and releases the inhibitor. The normal
|
||||
# undocked lid-close path is therefore unchanged after release.
|
||||
displayProfileWatch = pkgs.writeShellScriptBin "nomarchy-display-profile-watch" ''
|
||||
set -u
|
||||
LOGGER=${pkgs.util-linux}/bin/logger
|
||||
TRANSITION=${displayTransitionTool}/bin/nomarchy-display-transition
|
||||
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
inhibitor_pid=
|
||||
inhibitor_file="$rt/nomarchy-dock-lid-inhibitor.pid"
|
||||
awaiting_lid_open=
|
||||
undock_pending=
|
||||
undock_tries=0
|
||||
log() { "$LOGGER" -t nomarchy-display-watch -- "$*"; }
|
||||
internal_output() {
|
||||
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
|
||||
printf '%s\n' "$NOMARCHY_INTERNAL_OUTPUT"; return
|
||||
fi
|
||||
hyprctl monitors all -j 2>/dev/null \
|
||||
| jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)"))][0] // empty'
|
||||
}
|
||||
external_outputs() {
|
||||
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
|
||||
hyprctl monitors all -j 2>/dev/null \
|
||||
| jq -r --arg m "$NOMARCHY_INTERNAL_OUTPUT" '.[].name | select(. != $m)'
|
||||
return
|
||||
fi
|
||||
hyprctl monitors all -j 2>/dev/null \
|
||||
| jq -r '.[].name | select(test("^(eDP|LVDS|DSI)") | not)'
|
||||
}
|
||||
lid_open() {
|
||||
saw=
|
||||
if [ -n "''${NOMARCHY_LID_STATE_FILE:-}" ]; then
|
||||
files="$NOMARCHY_LID_STATE_FILE"
|
||||
else
|
||||
files=/proc/acpi/button/lid/*/state
|
||||
fi
|
||||
for state in $files; do
|
||||
[ -r "$state" ] || continue
|
||||
saw=1
|
||||
${pkgs.gnugrep}/bin/grep -qi open "$state" && return 0
|
||||
done
|
||||
# Desktops/VMs without a lid are safe to treat as open.
|
||||
[ -z "$saw" ]
|
||||
}
|
||||
owned_inhibitor_pid() {
|
||||
pid="''${1:-}"
|
||||
case "$pid" in *[!0-9]*|"") return 1 ;; esac
|
||||
[ -r "/proc/$pid/cmdline" ] || return 1
|
||||
${pkgs.coreutils}/bin/tr '\0' ' ' < "/proc/$pid/cmdline" \
|
||||
| ${pkgs.gnugrep}/bin/grep -qE \
|
||||
'nomarchy-dock-lid-inhibitor|Safe dock/undock display transition'
|
||||
}
|
||||
stop_inhibitor_group() {
|
||||
pid="''${1:-}"
|
||||
owned_inhibitor_pid "$pid" || return 0
|
||||
# The inhibitor and its sleep command share a private process group,
|
||||
# so both lose the inhibitor fd even after an unclean watcher death.
|
||||
kill -TERM -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
|
||||
}
|
||||
clear_stale_inhibitor() {
|
||||
[ -r "$inhibitor_file" ] || return
|
||||
IFS= read -r old < "$inhibitor_file" || old=
|
||||
if owned_inhibitor_pid "$old"; then
|
||||
stop_inhibitor_group "$old"
|
||||
log "lid-inhibitor=stale-cleaned pid=$old"
|
||||
fi
|
||||
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
|
||||
}
|
||||
acquire_inhibitor() {
|
||||
if [ -n "$inhibitor_pid" ] && kill -0 "$inhibitor_pid" 2>/dev/null; then return; fi
|
||||
${pkgs.util-linux}/bin/setsid ${pkgs.systemd}/bin/systemd-inhibit \
|
||||
--what=handle-lid-switch --mode=block --who=Nomarchy \
|
||||
--why="Safe dock/undock display transition" \
|
||||
${pkgs.bash}/bin/bash -c \
|
||||
'while :; do ${pkgs.coreutils}/bin/sleep 86400; done' \
|
||||
nomarchy-dock-lid-inhibitor &
|
||||
inhibitor_pid=$!
|
||||
printf '%s\n' "$inhibitor_pid" > "$inhibitor_file"
|
||||
tries=0
|
||||
while [ "$tries" -lt 20 ]; do
|
||||
if ! kill -0 "$inhibitor_pid" 2>/dev/null; then break; fi
|
||||
if ${pkgs.systemd}/bin/systemd-inhibit --list --json=short 2>/dev/null \
|
||||
| ${pkgs.jq}/bin/jq -e 'any(.[]; .what == "handle-lid-switch" and .why == "Safe dock/undock display transition" and .mode == "block")' \
|
||||
>/dev/null; then
|
||||
log "lid-inhibitor=acquired pid=$inhibitor_pid"
|
||||
return 0
|
||||
fi
|
||||
tries=$((tries+1)); sleep 0.1
|
||||
done
|
||||
log "lid-inhibitor=acquire-failed"
|
||||
stop_inhibitor_group "$inhibitor_pid"
|
||||
inhibitor_pid=
|
||||
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
|
||||
return 1
|
||||
}
|
||||
release_inhibitor() {
|
||||
[ -n "$inhibitor_pid" ] || return
|
||||
stop_inhibitor_group "$inhibitor_pid"
|
||||
wait "$inhibitor_pid" 2>/dev/null || true
|
||||
log "lid-inhibitor=released"
|
||||
inhibitor_pid=
|
||||
${pkgs.coreutils}/bin/rm -f "$inhibitor_file"
|
||||
}
|
||||
cleanup() { release_inhibitor; }
|
||||
trap cleanup EXIT INT TERM
|
||||
clear_stale_inhibitor
|
||||
|
||||
auto_on() {
|
||||
v=$(${sync} get settings.displayProfileAuto 2>/dev/null) || v=false
|
||||
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
|
||||
|
||||
in_list() { printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -Fxq "$2"; }
|
||||
paint_outputs() {
|
||||
# awww does not inherit the current image when Hyprland adds an output,
|
||||
# and it only paints outputs its daemon has already registered — which
|
||||
# lags the compositor's own monitoradded by an unpredictable margin.
|
||||
# Re-apply a few times over the first few seconds rather than betting
|
||||
# on one delay. Backgrounded so profile matching never waits on paint.
|
||||
for delay in 0.5 1.5 3; do
|
||||
sleep "$delay"
|
||||
${sync} --quiet wallpaper >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
|
||||
check_monitors() {
|
||||
cur=$(outputs)
|
||||
[ "$cur" = "$1" ] && return
|
||||
[ -n "$cur" ] || return
|
||||
auto_on || return
|
||||
command -v nomarchy-display-profile >/dev/null 2>&1 || return
|
||||
|
||||
target=$(nomarchy-display-profile match $cur) || target="base"
|
||||
|
||||
@@ -213,34 +386,232 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
[ "$target" = "$(nomarchy-display-profile active)" ] && return
|
||||
nomarchy-display-profile apply "$target"
|
||||
fi
|
||||
echo "$cur"
|
||||
}
|
||||
|
||||
prev=$(outputs)
|
||||
|
||||
# Listen to Hyprland's IPC socket for instant hotplug reaction
|
||||
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do
|
||||
case "$line" in
|
||||
monitoradded*|monitorremoved*)
|
||||
res=$(check_monitors "$prev")
|
||||
[ -n "$res" ] && prev="$res"
|
||||
;;
|
||||
esac
|
||||
# Entering dock mode is the mirror of the automatic undock below: the
|
||||
# session already comes back to the panel by itself when the last
|
||||
# external leaves, so an arriving external has to take it the other way
|
||||
# or the pair is only half-automatic and the menu has to finish the job.
|
||||
# A display profile is a deliberate, more specific layout, so it wins —
|
||||
# the caller only reaches here when none is in effect.
|
||||
auto_dock() {
|
||||
internal=$(internal_output)
|
||||
target=$(external_outputs | ${pkgs.coreutils}/bin/head -n 1)
|
||||
[ -n "$internal" ] && [ -n "$target" ] || return
|
||||
# `monitors` (unlike `monitors all`) lists only enabled outputs, so the
|
||||
# panel still being there is what "not docked yet" means. Re-docking
|
||||
# an already-docked session would move nothing and disable nothing.
|
||||
hyprctl monitors -j 2>/dev/null \
|
||||
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null || return
|
||||
if "$TRANSITION" dock "$internal" "$target" "$target, preferred, auto, 1"; then
|
||||
log "auto-dock internal=$internal target=$target result=ok"
|
||||
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Display" \
|
||||
"Docked — every laptop workspace moved to $target; laptop panel off." \
|
||||
2>/dev/null || true
|
||||
else
|
||||
log "auto-dock internal=$internal target=$target result=failed"
|
||||
fi
|
||||
}
|
||||
|
||||
# Dock mode has to be re-asserted, not just entered (#142). `reconcile`
|
||||
# below diffs the *connected* set (`monitors all`, which lists disabled
|
||||
# outputs too), so it is blind by construction to a panel being switched
|
||||
# back **on**: the connected set does not change, and every rebuild does
|
||||
# exactly that — Hyprland reloads the rewritten config, the catch-all
|
||||
# `monitor=,preferred,auto,1` re-lights the internal, and a workspace lands
|
||||
# on it. So the invariant is checked on every tick instead of on events:
|
||||
# if the user's recorded intent is dock and the panel is up while an
|
||||
# external is present, put it back.
|
||||
#
|
||||
# `auto_dock` is already idempotent — it returns early unless the internal
|
||||
# is enabled — so this costs one `hyprctl monitors` per tick and does
|
||||
# nothing in the overwhelmingly common case. `settings.display.dockMode`
|
||||
# is the user's recorded intent — written by the menu and by the dock
|
||||
# transition itself (`set_dock_intent`, which `auto_dock` above reaches
|
||||
# via `$TRANSITION dock`) — and that alone is what this is gated on. It
|
||||
# used to also require `auto_on` (`settings.displayProfileAuto`), but that
|
||||
# toggle only controls *named-profile* auto-switching; gating the dock
|
||||
# invariant on it meant enforcement never fired for anyone who had not
|
||||
# separately opted into auto profiles, which is most users. A
|
||||
# `home-manager` activation's `hyprctl reload` re-applies the catch-all
|
||||
# monitor rule regardless, so the panel inside a shut lid came back on
|
||||
# every rebuild while docked (#148). The profile-active check right below
|
||||
# is the real, narrower guard this needs: a profile is a deliberate, more
|
||||
# specific layout and outranks the dock heuristic.
|
||||
enforce_dock_intent() {
|
||||
[ "$(${sync} get settings.display.dockMode 2>/dev/null)" = true ] || return
|
||||
[ "$(nomarchy-display-profile active 2>/dev/null || echo none)" = "none" ] || return
|
||||
internal=$(internal_output)
|
||||
[ -n "$internal" ] || return
|
||||
# Only with an external actually present: intent must never be able to
|
||||
# black out a laptop that has nowhere else to draw (#127's brick).
|
||||
[ -n "$(external_outputs)" ] || return
|
||||
hyprctl monitors -j 2>/dev/null \
|
||||
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null || return
|
||||
log "dock-intent=true panel=$internal state=re-enabled action=re-dock"
|
||||
acquire_inhibitor
|
||||
auto_dock
|
||||
}
|
||||
|
||||
# The set of connected outputs — not the IPC event stream — is the source
|
||||
# of truth. Hyprland's socket can miss or drop events (see the loop
|
||||
# below), and a lost monitoradded used to mean no docking profile, no
|
||||
# wallpaper on the new screen and no audio follow until something else
|
||||
# poked the watcher. `reconcile` diffs the output set against the last
|
||||
# one it acted on, so an event only makes the reaction *instant* — never
|
||||
# necessary. Everything here is idempotent and safe to call on a tick.
|
||||
#
|
||||
# $1 (optional) = an output Hyprland just announced as removed. It may
|
||||
# still be listed in `monitors all` while teardown settles, so exclude it
|
||||
# and restore the internal panel before that lands.
|
||||
reconcile() {
|
||||
hint="''${1:-}"
|
||||
cur=$(outputs)
|
||||
[ -n "$cur" ] || return
|
||||
internal=$(internal_output)
|
||||
ext=$(external_outputs)
|
||||
if [ -n "$hint" ]; then
|
||||
cur=$(printf '%s\n' "$cur" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true)
|
||||
ext=$(printf '%s\n' "$ext" | ${pkgs.gnugrep}/bin/grep -Fxv "$hint" || true)
|
||||
fi
|
||||
|
||||
if [ "$cur" != "$known_outputs" ]; then
|
||||
added_ext=
|
||||
for o in $ext; do
|
||||
in_list "$known_outputs" "$o" || added_ext="$added_ext $o"
|
||||
done
|
||||
gone=
|
||||
for o in $known_outputs; do
|
||||
in_list "$cur" "$o" || gone="$gone $o"
|
||||
done
|
||||
known_outputs=$cur
|
||||
|
||||
if [ -n "$added_ext" ]; then
|
||||
# A new external supersedes any undock still queued for an older
|
||||
# departure: the panel is about to be disabled again on purpose.
|
||||
undock_pending=
|
||||
# …and it supersedes a *completed* one too. Bernardo 2026-07-16:
|
||||
# undocked, panel stayed dark; re-docked and undocked again and it
|
||||
# came back — because in between he opened the lid. `awaiting_lid_open`
|
||||
# is set by every successful undock and cleared only when the external
|
||||
# is gone AND the lid is open, so a clamshell undock latches it; the
|
||||
# next departure then hits the `[ -z "$awaiting_lid_open" ]` guard
|
||||
# below, queues nothing, and the panel never returns. His journal shows
|
||||
# exactly that: no `outputs-changed removed` for the first undock at
|
||||
# all, then a release the moment he lifted the lid, then a clean
|
||||
# undock. The latch means "this departure is dealt with" — a new
|
||||
# external ends that departure, so clear it here or it outlives the
|
||||
# thing it describes.
|
||||
awaiting_lid_open=
|
||||
[ -z "$internal" ] || acquire_inhibitor
|
||||
# An external output that was not there a moment ago is an
|
||||
# unambiguous physical plug whether or not we saw the IPC event,
|
||||
# so it is allowed to override a manual sink choice.
|
||||
command -v nomarchy-dock-audio >/dev/null 2>&1 \
|
||||
&& nomarchy-dock-audio reprobe monitoradded &
|
||||
paint_outputs &
|
||||
log "outputs-changed added=$added_ext audio-reprobe=scheduled"
|
||||
fi
|
||||
# awaiting_lid_open marks an undock already completed for this
|
||||
# departure — the guard is what keeps event + tick from doubling up.
|
||||
# The attempt itself is deliberately NOT made here: it is queued and
|
||||
# driven by the invariant below, so a single lost or failed attempt
|
||||
# can never be what costs the panel.
|
||||
if [ -n "$gone" ] && [ -z "$ext" ] && [ -n "$internal" ] \
|
||||
&& [ -z "$awaiting_lid_open" ]; then
|
||||
undock_pending=1
|
||||
undock_tries=0
|
||||
log "outputs-changed removed=$gone transition=undock-queued"
|
||||
fi
|
||||
check_monitors
|
||||
if [ -n "$added_ext" ] \
|
||||
&& [ "$(nomarchy-display-profile active 2>/dev/null || echo none)" = "none" ]; then
|
||||
auto_dock
|
||||
fi
|
||||
fi
|
||||
|
||||
# Safety invariants, re-checked regardless of whether the set moved.
|
||||
#
|
||||
# A dark panel is the worst outcome this watcher can produce, so the
|
||||
# undock is an invariant driven to completion on the tick — not a
|
||||
# one-shot fired off the removal event, which a dropped IPC event or a
|
||||
# transient hyprctl failure would then turn into a black laptop. (The
|
||||
# transition itself no longer *expects* to fail: what used to fail
|
||||
# every time was an inert keyword, fixed at the source above. This
|
||||
# stays as the backstop it was always meant to be.)
|
||||
if [ -n "$undock_pending" ] && [ -z "$ext" ] && [ -n "$internal" ]; then
|
||||
if "$TRANSITION" undock "$internal"; then
|
||||
undock_pending=
|
||||
awaiting_lid_open=1
|
||||
log "transition=undock result=ok attempts=$((undock_tries+1))"
|
||||
else
|
||||
undock_tries=$((undock_tries+1))
|
||||
# Bounded: a panel that cannot be enabled at all is a different
|
||||
# fault, and retrying forever would pin the lid inhibitor with it.
|
||||
if [ "$undock_tries" -ge 6 ]; then
|
||||
undock_pending=
|
||||
awaiting_lid_open=1
|
||||
log "transition=undock result=gave-up attempts=$undock_tries"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "$internal" ] && [ -n "$ext" ]; then
|
||||
if [ -z "$inhibitor_pid" ] || ! kill -0 "$inhibitor_pid" 2>/dev/null; then
|
||||
acquire_inhibitor || true
|
||||
fi
|
||||
fi
|
||||
if [ -n "$awaiting_lid_open" ] && [ -z "$ext" ] && lid_open; then
|
||||
release_inhibitor
|
||||
awaiting_lid_open=
|
||||
fi
|
||||
# Last, because it re-derives `internal` and the invariants above must
|
||||
# see reconcile's own value: a queued undock outranks a stale dock
|
||||
# intent, and this returns early anyway while no external is present.
|
||||
enforce_dock_intent
|
||||
}
|
||||
|
||||
# Login/relogin while already docked still needs protection. Do not
|
||||
# change the layout here: the baked config/manual profile remains SoT,
|
||||
# so seed known_outputs with what is already connected.
|
||||
internal=$(internal_output)
|
||||
[ -n "$internal" ] && [ -n "$(external_outputs)" ] && acquire_inhibitor
|
||||
known_outputs=$(outputs)
|
||||
|
||||
# Listen to Hyprland's IPC socket for instant hotplug reaction, and
|
||||
# reconnect after a compositor reload/socket hiccup. The connection is
|
||||
# deliberately long-lived: `socat -T 1` used to close it after one second
|
||||
# of desktop idle, and a dock plugged while idle — the normal case —
|
||||
# landed in the reconnect gap and was lost outright. A 1s `read` timeout
|
||||
# supplies the same periodic tick without ever dropping the socket.
|
||||
# Process substitution is deliberate: a pipeline `... | while read` would
|
||||
# run the loop in a subshell and lose inhibitor lifecycle state.
|
||||
socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
|
||||
while :; do
|
||||
exec 3< <(${pkgs.socat}/bin/socat -U - UNIX-CONNECT:"$socket" 2>/dev/null)
|
||||
while :; do
|
||||
hint=
|
||||
if IFS= read -r -t 1 line <&3; then
|
||||
case "$line" in
|
||||
"monitorremoved>>"*) hint=''${line#monitorremoved>>} ;;
|
||||
"monitoradded>>"*) : ;;
|
||||
# socket2 is chatty (focus, workspaces, …); only monitor events
|
||||
# are worth a reconcile, the idle tick covers everything else.
|
||||
*) continue ;;
|
||||
esac
|
||||
else
|
||||
# bash returns >128 for the read timeout (our tick); anything else
|
||||
# is EOF or error, i.e. the socket died and needs a reconnect.
|
||||
rc=$?
|
||||
[ "$rc" -gt 128 ] || break
|
||||
fi
|
||||
reconcile "$hint"
|
||||
done
|
||||
exec 3<&-
|
||||
sleep 0.25
|
||||
done
|
||||
'';
|
||||
|
||||
# Candidate layouts offered by the new-keyboard picker: the session
|
||||
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
|
||||
# session) plus the extra nomarchy.keyboard.layouts pool. The pool is
|
||||
# deliberately NOT merged into input.kb_layout — those candidates are for
|
||||
# *external* keyboards and get applied per-device by the watcher's apply().
|
||||
# Loading them onto the session keyboard is what let a global switch flip
|
||||
# the built-in board to the wrong layout.
|
||||
pickerLayouts = lib.unique (
|
||||
(lib.splitString "," config.nomarchy.keyboard.layout)
|
||||
++ config.nomarchy.keyboard.layouts
|
||||
);
|
||||
kbAutoSwitch = config.nomarchy.keyboard.layouts != [ ];
|
||||
keyboardTool = import ./keyboard-tool.nix { inherit pkgs lib config; };
|
||||
|
||||
# On a keyboard that connects after login (not declared in
|
||||
# keyboard.devices, not already remembered), ask for a layout and persist it
|
||||
@@ -261,53 +632,82 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
# test (hotplug isn't verifiable in CI).
|
||||
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
|
||||
set -u
|
||||
layouts="${lib.concatStringsSep " " pickerLayouts}"
|
||||
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
|
||||
sync=${sync}
|
||||
|
||||
apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
|
||||
tool=${keyboardTool}/bin/nomarchy-keyboard-layout
|
||||
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
|
||||
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
||||
in_list() { printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -Fxq "$2"; }
|
||||
|
||||
# The remembered map (device-name -> layout) from the LIVE in-flake state;
|
||||
# an absent key (sparse state) reads as an empty object.
|
||||
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
|
||||
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
|
||||
|
||||
# Persist a pick INSTANTLY: merge it into the map and write the whole object
|
||||
# back at the dot-free parent path, so a device name containing a dot can't
|
||||
# corrupt the dotted set-path. --no-switch = write only, no rebuild.
|
||||
remember() {
|
||||
map=$(saved_map | jq -c --arg k "$1" --arg v "$2" '. + {($k): $v}') || return
|
||||
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch
|
||||
# Hyprland calls every evdev node that can emit a key a "keyboard": the
|
||||
# lid and power buttons, a monitor's control channel, and each extra HID
|
||||
# collection a real keyboard exposes. One dock with one keyboard on it
|
||||
# therefore used to ask which layout to use four times, for devices that
|
||||
# cannot type. udev already draws this distinction properly — it sets
|
||||
# ID_INPUT_KEYBOARD only for full typing keyboards — so take the answer
|
||||
# from there and translate the names into Hyprland's own spelling:
|
||||
# spaces to '-', then lowercase (its deviceNameToInternalString).
|
||||
typing_keyboards() {
|
||||
for dev in /sys/class/input/event*; do
|
||||
[ -r "$dev/device/name" ] || continue
|
||||
${pkgs.systemd}/bin/udevadm info -q property -p "$dev" 2>/dev/null \
|
||||
| ${pkgs.gnugrep}/bin/grep -q '^ID_INPUT_KEYBOARD=1' || continue
|
||||
IFS= read -r name < "$dev/device/name" || continue
|
||||
printf '%s\n' "$name" \
|
||||
| ${pkgs.coreutils}/bin/tr ' ' '-' | ${pkgs.coreutils}/bin/tr 'A-Z' 'a-z'
|
||||
done | ${pkgs.coreutils}/bin/sort -u
|
||||
}
|
||||
|
||||
# Hyprland disambiguates same-named devices with a -1, -2, … suffix, so a
|
||||
# device's physical keyboard is its name minus that suffix.
|
||||
base_name() { printf '%s\n' "$1" | ${pkgs.gnused}/bin/sed -E 's/-[0-9]+$//'; }
|
||||
|
||||
# Startup: re-apply remembered layouts, never prompt — so the built-in
|
||||
# keyboard just stays on the session default.
|
||||
prev=" "
|
||||
for kb in $(keyboards); do
|
||||
prev="$prev$kb "
|
||||
is_declared "$kb" && continue
|
||||
s=$(saved_for "$kb"); [ -n "$s" ] && apply "$kb" "$s"
|
||||
"$tool" restore "$kb" || true
|
||||
done
|
||||
|
||||
# Watch for keyboards that connect later.
|
||||
while :; do
|
||||
sleep 3
|
||||
cur=" "
|
||||
new=
|
||||
for kb in $(keyboards); do
|
||||
cur="$cur$kb "
|
||||
case "$prev" in *" $kb "*) continue ;; esac
|
||||
is_declared "$kb" && continue
|
||||
s=$(saved_for "$kb")
|
||||
if [ -n "$s" ]; then
|
||||
apply "$kb" "$s"
|
||||
else
|
||||
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb")
|
||||
[ -n "$choice" ] && { remember "$kb" "$choice"; apply "$kb" "$choice"; }
|
||||
fi
|
||||
new="$new $kb"
|
||||
done
|
||||
prev="$cur"
|
||||
# Nothing arrived: stay off udev, this is the every-3s common path.
|
||||
[ -n "$new" ] || continue
|
||||
|
||||
typing=$(typing_keyboards)
|
||||
handled=" "
|
||||
for kb in $new; do
|
||||
b=$(base_name "$kb")
|
||||
in_list "$typing" "$b" || continue
|
||||
case "$handled" in *" $b "*) continue ;; esac
|
||||
handled="$handled$b "
|
||||
# Ask once for the physical keyboard, then answer for every node it
|
||||
# brought with it — they are one keyboard and share one layout.
|
||||
group=
|
||||
for k in $new; do
|
||||
[ "$(base_name "$k")" = "$b" ] && group="$group $k"
|
||||
done
|
||||
s=$("$tool" saved "$b")
|
||||
if [ -n "$s" ]; then
|
||||
for k in $group; do "$tool" restore "$k" || true; done
|
||||
else
|
||||
choice=$("$tool" layouts \
|
||||
| rofi -monitor -1 -dmenu -i -p "Keyboard layout · $b (e.g. us, gb, pt, br)")
|
||||
[ -n "$choice" ] || continue
|
||||
for k in $group; do "$tool" apply "$k" "$choice"; done
|
||||
fi
|
||||
done
|
||||
done
|
||||
'';
|
||||
in
|
||||
@@ -324,6 +724,24 @@ in
|
||||
# (Hyprland then boots into emergency mode with no binds).
|
||||
configType = "hyprlang";
|
||||
|
||||
# Session bring-up (HM renders these into the first exec-once, after
|
||||
# its dbus-update-activation-environment). The default is just
|
||||
# stop/start of hyprland-session.target — not enough after a broken
|
||||
# logout: if the graphical-session.target *stop* transaction was
|
||||
# rejected mid-teardown (seen 2026-07-18: "transaction is destructive"
|
||||
# while easyeffects had a queued start job), the target stays active
|
||||
# across the logout, its services crash-loop against the dead Wayland
|
||||
# socket into start-limit-hit, and the next login's plain `start` is a
|
||||
# no-op — cliphist/swaync/portals stay failed for the whole session
|
||||
# (doctor all red). So: tear the stale targets down, clear
|
||||
# failed/start-limit state, then start fresh. All three are no-ops
|
||||
# after a clean logout or cold boot.
|
||||
systemd.extraCommands = [
|
||||
"systemctl --user stop hyprland-session.target graphical-session.target"
|
||||
"systemctl --user reset-failed"
|
||||
"systemctl --user start hyprland-session.target"
|
||||
];
|
||||
|
||||
settings = {
|
||||
"$mod" = "SUPER";
|
||||
"$terminal" = config.nomarchy.terminal;
|
||||
@@ -347,7 +765,7 @@ in
|
||||
"awww-daemon"
|
||||
# Paint the wallpaper as soon as the session is up (waits for
|
||||
# the daemon internally).
|
||||
"nomarchy-theme-sync wallpaper"
|
||||
"nomarchy-state-sync wallpaper"
|
||||
# Polkit authentication agent — without one, EVERY pkexec/polkit
|
||||
# prompt in the session fails silently (btrfs-assistant-launcher
|
||||
# was the discovery case). hyprpolkitagent is Hyprland's own Qt
|
||||
@@ -361,12 +779,14 @@ in
|
||||
# the clean pkill a theme switch now does — respawns the bar instead
|
||||
# of orphaning the session bar-less.
|
||||
] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar"
|
||||
++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch"
|
||||
++ lib.optional (profiles != { }) "${displayProfileWatch}/bin/nomarchy-display-profile-watch";
|
||||
++ [
|
||||
"${keyboardWatch}/bin/nomarchy-keyboard-watch"
|
||||
"${displayProfileWatch}/bin/nomarchy-display-profile-watch"
|
||||
];
|
||||
|
||||
# ── Theme-driven look ──────────────────────────────────────────
|
||||
# These flow from theme-state.json and stay at NORMAL priority:
|
||||
# change them with `nomarchy-theme-sync set ui.<key>` (the intended
|
||||
# These flow from state.json and stay at NORMAL priority:
|
||||
# change them with `nomarchy-state-sync set ui.<key>` (the intended
|
||||
# path), or lib.mkForce in home.nix to hardcode against the theme.
|
||||
# The non-theme knobs around them are lib.mkDefault — override
|
||||
# those with a plain home.nix assignment. See docs/OVERRIDES.md.
|
||||
@@ -394,7 +814,12 @@ in
|
||||
};
|
||||
shadow = {
|
||||
enabled = t.ui.shadow;
|
||||
range = lib.mkDefault 20;
|
||||
# The mantle-tinted shadow reads as a dark halo on dark themes
|
||||
# but as a warm *glow* on light ones (mantle is light there) —
|
||||
# kept as an identity feature, just tight (a few px) so it never
|
||||
# bleeds into neighbouring windows or the bar (hardware report
|
||||
# 2026-07-18); dark themes keep the soft 20px ambient shadow.
|
||||
range = lib.mkDefault (if t.mode == "light" then 5 else 20);
|
||||
render_power = lib.mkDefault 3;
|
||||
color = rgba c.mantle "aa";
|
||||
};
|
||||
@@ -446,6 +871,10 @@ in
|
||||
];
|
||||
|
||||
misc = {
|
||||
# Kill the water-drop splash + the yellow "autogenerated config"
|
||||
# banner. Migrations that log into Hyprland before the first
|
||||
# home-manager switch still see the banner (no managed conf yet) —
|
||||
# see docs/MIGRATION.md. Once HM owns hyprland.conf these stay off.
|
||||
disable_hyprland_logo = lib.mkDefault true;
|
||||
disable_splash_rendering = lib.mkDefault true;
|
||||
force_default_wallpaper = lib.mkDefault 0;
|
||||
@@ -454,6 +883,35 @@ in
|
||||
# instead of compositor black — on light themes the black flash
|
||||
# reads as a glitch (item 28c, seen in the capture harness).
|
||||
background_color = rgb c.base;
|
||||
|
||||
# #127. Hyprland ships both of these OFF, so nothing in the
|
||||
# compositor turned a blanked screen back on: hypridle's on-resume
|
||||
# was the ONLY caller of `dpms on` anywhere in the session — an idle
|
||||
# daemon as a single point of failure with the display behind it.
|
||||
# Both facts verified on hardware 2026-07-16, as is the cure: with
|
||||
# hypridle deliberately stopped and the screen blanked (i.e. the
|
||||
# brick condition manufactured on purpose), a keypress woke it in 4s
|
||||
# docked / 6s clamshell. The wake now lives in the compositor where
|
||||
# no daemon can lose it; a dead hypridle costs auto-lock, not the seat.
|
||||
#
|
||||
# Deliberately NOT claimed: that this is what caused the original
|
||||
# brick. An earlier version of this comment said so, citing a hypridle
|
||||
# deadlock (hyprwm/hypridle#171) — that was wrong and is retracted:
|
||||
# hypridle had no disconnect at all around the incident. Removing a
|
||||
# proven single point of failure needs no such story. The cause is
|
||||
# still open (BACKLOG #127); the dead Ctrl+Alt+F3 is its lead symptom,
|
||||
# and neither DPMS-off nor a dead hypridle can produce that.
|
||||
key_press_enables_dpms = lib.mkDefault true;
|
||||
mouse_move_enables_dpms = lib.mkDefault true;
|
||||
};
|
||||
|
||||
# First boot of every installed image was showing "Hyprland updated to
|
||||
# 0.55.x!" over the themed desktop (V2 re-boot of the #94 install disk,
|
||||
# 2026-07-15). That dialog is Hyprland's update-news popup, not our
|
||||
# welcome toast — kill it so the first session is ours, not upstream's.
|
||||
ecosystem = {
|
||||
no_update_news = lib.mkDefault true;
|
||||
no_donation_nag = lib.mkDefault true;
|
||||
};
|
||||
|
||||
# Window rules — float + center small config/utility dialogs that
|
||||
@@ -469,12 +927,12 @@ in
|
||||
# Class regexes use `(?i)` and tolerate the XWayland
|
||||
# `.…-wrapped` binary-name form.
|
||||
#
|
||||
# Conservative set (item 41): only dialogs we ship or can name from
|
||||
# package strings / desktop files. SoftGL capture harness could not
|
||||
# materialize polkit/pinentry windows (empty hyprctl clients) — classes
|
||||
# below are from binary/app-id strings (hyprpolkitagent, pinentry-qt).
|
||||
# Remaining: GTK file-chooser portal — class still unknown headlessly;
|
||||
# HARDWARE-QUEUE for live hyprctl clients confirmation.
|
||||
# Conservative float set (item 41 closed): only dialogs we ship or can
|
||||
# name from package strings / desktop files. Classes: mixer,
|
||||
# blueman, system-config-printer, calendar, hyprpolkitagent,
|
||||
# pinentry-qt, and xdg-desktop-portal-gtk (desktop file + libexec
|
||||
# name; .…-wrapped for the Nix wrapper). SoftGL could not materialize
|
||||
# portal windows; polkit float confirmed on Latitude 2026-07-10.
|
||||
windowrule = [
|
||||
# Audio mixer (item 35): right-click the Waybar volume module.
|
||||
"float 1, match:class ^(com\\.saivert\\.pwvucontrol|org\\.pulseaudio\\.pavucontrol|pavucontrol)$"
|
||||
@@ -484,19 +942,42 @@ in
|
||||
"float 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
|
||||
"center 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
|
||||
|
||||
# Calendar popup (item 42): the Waybar clock's on-click runs
|
||||
# nomarchy-calendar → calcurse in a ghostty window tagged with a
|
||||
# distinct --class, so it floats centered instead of tiling.
|
||||
# Calendar popup (item 42): nomarchy-calendar → calcurse in a
|
||||
# kitty window tagged with a distinct --class, so it floats centered.
|
||||
# No `size` rule on purpose: a percentage one is silently ignored here
|
||||
# (#139, measured on hardware — see term-sheet.nix), and px cannot mean
|
||||
# "a fraction of *this* monitor". The launcher sizes the window itself.
|
||||
"float 1, match:class ^(com\\.nomarchy\\.calendar)$"
|
||||
"size 60% 65%, match:class ^(com\\.nomarchy\\.calendar)$"
|
||||
"center 1, match:class ^(com\\.nomarchy\\.calendar)$"
|
||||
|
||||
# Doctor sheet (System › Doctor / Waybar custom/doctor click):
|
||||
# nomarchy-menu doctor → nomarchy-term-sheet, sized the same way.
|
||||
"float 1, match:class ^(com\\.nomarchy\\.doctor)$"
|
||||
"center 1, match:class ^(com\\.nomarchy\\.doctor)$"
|
||||
|
||||
# Upgrade prompt (Waybar custom/updates click → nomarchy-updates
|
||||
# upgrade-window): the same classed sheet, smaller — it is a y/N flow.
|
||||
"float 1, match:class ^(com\\.nomarchy\\.updates)$"
|
||||
"center 1, match:class ^(com\\.nomarchy\\.updates)$"
|
||||
|
||||
# Polkit auth (hyprpolkitagent — app id / binary name in the package)
|
||||
# and pinentry-qt (keys.nix default; org.gnupg.pinentry-qt desktop).
|
||||
# workspace current: agent was started on ws1 at login; without this
|
||||
# the dialog opens on that workspace and the current one looks dead
|
||||
# (hardware: Snapshots menu from ws≠1). stay_focused (0.55 name;
|
||||
# was stayfocused) so it grabs attention when it lands.
|
||||
"float 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
|
||||
"center 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
|
||||
"workspace current, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
|
||||
"stay_focused 1, match:class (?i)^(hyprpolkitagent|\\.hyprpolkitagent-wrapped)$"
|
||||
"float 1, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
|
||||
"center 1, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
|
||||
"workspace current, match:class (?i)^(pinentry(-qt)?|org\\.gnupg\\.pinentry-qt)$"
|
||||
|
||||
# GTK file-chooser portal (xdg-desktop-portal-gtk) — class is the
|
||||
# binary/desktop name; Nix wrap = .xdg-desktop-portal-gtk-wrapped.
|
||||
"float 1, match:class (?i)^(\\.?xdg-desktop-portal-gtk(-wrapped)?)$"
|
||||
"center 1, match:class (?i)^(\\.?xdg-desktop-portal-gtk(-wrapped)?)$"
|
||||
];
|
||||
|
||||
# Rendered from ./keybinds.nix (the cheatsheet reads the same list),
|
||||
@@ -555,11 +1036,11 @@ in
|
||||
home.packages =
|
||||
lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
|
||||
[ pkgs.nwg-displays ]
|
||||
# The new-keyboard watcher on PATH (when enabled) so it's discoverable and
|
||||
# runnable by name for debugging — Hyprland still exec-once's it by store path.
|
||||
++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch
|
||||
# The display-profile switcher (the menu's Profiles row gates on it)
|
||||
# + its hotplug watcher, on PATH for debugging like the keyboard one.
|
||||
++ lib.optionals (config.nomarchy.hyprland.enable && profiles != { })
|
||||
[ displayProfileTool displayProfileWatch ];
|
||||
# Runtime hardware watchers/helpers stay on PATH for manual recovery and
|
||||
# debugging. The display-profile switcher itself remains gated so the
|
||||
# menu does not advertise an empty Profiles submenu.
|
||||
++ lib.optionals config.nomarchy.hyprland.enable
|
||||
([ keyboardTool keyboardWatch displayTransitionTool displayProfileWatch
|
||||
displayDumpTool displayWakeTool ]
|
||||
++ lib.optional (profiles != { }) displayProfileTool);
|
||||
}
|
||||
|
||||
@@ -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,32 @@ let
|
||||
done
|
||||
exit 1
|
||||
'';
|
||||
|
||||
# Absolute store paths for hypridle (thin PATH). Wake reuses the shared
|
||||
# helper AND the full display-transition tool (#150): idle.nix used to
|
||||
# carry a lossy mini copy to dodge a circular import on hyprland.nix, but
|
||||
# both live in leaf files now, so the wake path gets the real undock —
|
||||
# logging, the enable-failure dump, and restore_keyboards after a reload.
|
||||
displayWake =
|
||||
let
|
||||
keyboardTool = import ./keyboard-tool.nix { inherit pkgs lib config; };
|
||||
displayDumpTool =
|
||||
(import ./display-tools.nix { inherit pkgs; }).displayDumpTool;
|
||||
displayTransition = import ./display-transition.nix {
|
||||
inherit pkgs keyboardTool displayDumpTool;
|
||||
sync = lib.getExe config.nomarchy.package;
|
||||
};
|
||||
in
|
||||
(import ./display-tools.nix {
|
||||
inherit pkgs;
|
||||
inherit displayTransition;
|
||||
}).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 +66,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 +90,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 +131,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 +142,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,75 +7,91 @@
|
||||
# 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 -theme launcher"; desc = "Quick launch (apps)"; }
|
||||
{ mods = "$mod"; key = "D"; action = "exec, rofi -show drun -theme launcher"; 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"; }
|
||||
{ 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"; }
|
||||
{ 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 (networkmanager_dmenu)"; }
|
||||
{ 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 = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker (→ clipboard)"; }
|
||||
{ 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 SHIFT"; key = "C"; action = "exec, hyprpicker -a"; desc = "Color picker (→ clipboard)"; }
|
||||
{ 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 — SUPER + arrow keys.
|
||||
{ mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; }
|
||||
{ mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; }
|
||||
{ mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; }
|
||||
{ mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; }
|
||||
{ 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"; }
|
||||
|
||||
# Multi-monitor workspace movement — SUPER + ALT + arrow keys.
|
||||
{ mods = "$mod ALT"; key = "left"; action = "movecurrentworkspacetomonitor, l"; desc = "Move workspace to left monitor"; }
|
||||
{ mods = "$mod ALT"; key = "right"; action = "movecurrentworkspacetomonitor, r"; desc = "Move workspace to right monitor"; }
|
||||
{ mods = "$mod ALT"; key = "up"; action = "movecurrentworkspacetomonitor, u"; desc = "Move workspace to upper monitor"; }
|
||||
{ mods = "$mod ALT"; key = "down"; action = "movecurrentworkspacetomonitor, d"; desc = "Move workspace to lower monitor"; }
|
||||
{ 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"; desc = "Screenshot region → clipboard"; }
|
||||
{ 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"; }
|
||||
{ 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"; }
|
||||
{ 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"; }
|
||||
{ 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
|
||||
@@ -84,14 +100,14 @@
|
||||
# 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"; }
|
||||
{ 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 = "Bar: click"; desc = "Caffeine — hold the screen awake (idle inhibitor)"; }
|
||||
{ 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"; }
|
||||
];
|
||||
}
|
||||
|
||||
81
modules/home/keyboard-tool.nix
Normal file
81
modules/home/keyboard-tool.nix
Normal file
@@ -0,0 +1,81 @@
|
||||
# The per-device keyboard-layout helper (nomarchy-keyboard-layout).
|
||||
# Extracted from hyprland.nix (#150) so it is built ONCE and shared: the
|
||||
# Hyprland module puts it on PATH and drives it from the hotplug watcher,
|
||||
# while the display-transition tool calls it (restore_keyboards) after a
|
||||
# config reload. Pass the module's { pkgs, lib, config }.
|
||||
{ pkgs, lib, config }:
|
||||
let
|
||||
sync = lib.getExe config.nomarchy.package;
|
||||
|
||||
# Candidate layouts offered by the new-keyboard picker: the session
|
||||
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
|
||||
# session) plus the extra nomarchy.keyboard.layouts pool. The pool is
|
||||
# deliberately NOT merged into input.kb_layout — those candidates are for
|
||||
# *external* keyboards and get applied per-device by the watcher's apply().
|
||||
# Loading them onto the session keyboard is what let a global switch flip
|
||||
# the built-in board to the wrong layout.
|
||||
pickerLayouts = lib.unique (
|
||||
(lib.splitString "," config.nomarchy.keyboard.layout)
|
||||
++ config.nomarchy.keyboard.layouts
|
||||
);
|
||||
in
|
||||
# Per-device keyboard helper shared by the hotplug watcher and the manual
|
||||
# System › Keyboard menu. The configured candidates are listed first, then
|
||||
# every XKB layout known to the system, so the feature works on a default
|
||||
# install instead of silently disappearing until home.nix is edited.
|
||||
pkgs.writeShellScriptBin "nomarchy-keyboard-layout" ''
|
||||
set -u
|
||||
sync=${sync}
|
||||
|
||||
layouts() {
|
||||
printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg pickerLayouts}
|
||||
${pkgs.gawk}/bin/awk '
|
||||
/^! layout/ { in_layouts=1; next }
|
||||
/^!/ && in_layouts { exit }
|
||||
in_layouts && NF { print $1 }
|
||||
' ${pkgs.xkeyboard_config}/share/X11/xkb/rules/base.lst
|
||||
}
|
||||
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
|
||||
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
|
||||
apply_runtime() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
|
||||
|
||||
case "''${1:-}" in
|
||||
layouts)
|
||||
layouts | ${pkgs.gawk}/bin/awk 'NF && !seen[$0]++' ;;
|
||||
devices)
|
||||
hyprctl devices -j 2>/dev/null \
|
||||
| jq -r '.keyboards[] | "\(.name)\t\(.active_keymap // \"unknown\")"' ;;
|
||||
saved)
|
||||
[ -n "''${2:-}" ] || exit 64
|
||||
saved_for "$2" ;;
|
||||
restore)
|
||||
[ -n "''${2:-}" ] || exit 64
|
||||
layout=$(saved_for "$2")
|
||||
[ -n "$layout" ] && apply_runtime "$2" "$layout" ;;
|
||||
apply)
|
||||
[ -n "''${2:-}" ] && [ -n "''${3:-}" ] || exit 64
|
||||
layouts | ${pkgs.gnugrep}/bin/grep -Fxq "$3" \
|
||||
|| { notify-send "Keyboard" "Unknown XKB layout: $3"; exit 64; }
|
||||
if apply_runtime "$2" "$3"; then
|
||||
map=$(saved_map | jq -c --arg k "$2" --arg v "$3" '. + {($k): $v}') || exit 1
|
||||
if "$sync" --quiet set settings.keyboard.devices "$map" --no-switch; then
|
||||
notify-send "Keyboard" "$2 → $3"
|
||||
else
|
||||
notify-send "Keyboard" "$2 → $3 for this session, but the choice could not be saved."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
notify-send "Keyboard" "Could not apply $3 to $2."
|
||||
exit 1
|
||||
fi ;;
|
||||
forget)
|
||||
[ -n "''${2:-}" ] || exit 64
|
||||
map=$(saved_map | jq -c --arg k "$2" 'del(.[$k])') || exit 1
|
||||
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch || exit 1
|
||||
apply_runtime "$2" ${lib.escapeShellArg config.nomarchy.keyboard.layout} || true
|
||||
notify-send "Keyboard" "$2 now follows the session layout." ;;
|
||||
*)
|
||||
echo "usage: nomarchy-keyboard-layout [layouts|devices|saved <device>|restore <device>|apply <device> <layout>|forget <device>]" >&2
|
||||
exit 64 ;;
|
||||
esac
|
||||
''
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -44,19 +44,25 @@ lib.mkIf config.nomarchy.mime.enable {
|
||||
"audio/x-m4a" = "io.bassi.Amberol.desktop";
|
||||
"audio/aac" = "io.bassi.Amberol.desktop";
|
||||
|
||||
# The template's active editor; degrades if you drop vscode.
|
||||
"text/plain" = "code.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";
|
||||
|
||||
# The template ships no browser by default (open Decision) — these
|
||||
# are inert until one is installed; firefox is the template's first
|
||||
# suggestion. A different browser registers its own handler and
|
||||
# wins once these entries stay dead.
|
||||
"text/html" = "firefox.desktop";
|
||||
"x-scheme-handler/http" = "firefox.desktop";
|
||||
"x-scheme-handler/https" = "firefox.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";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
# schedule (temperature/sunrise/sunset) is tuned via nomarchy.nightlight.* in
|
||||
# home.nix and baked into the unit's time-based `profile`.
|
||||
#
|
||||
# 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
|
||||
@@ -18,12 +24,15 @@ 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=hyprsunset.service
|
||||
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
|
||||
@@ -36,29 +45,42 @@ let
|
||||
v=$(${sync} get settings.nightlight.on 2>/dev/null) || v=${onDefault}
|
||||
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
installed() { systemctl --user cat "$unit" >/dev/null 2>&1; }
|
||||
start() { systemctl --user start "$unit" 2>/dev/null || true; }
|
||||
# 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 the System menu.
|
||||
systemctl --user is-active --quiet "$unit" \
|
||||
&& printf '{"text":"","tooltip":"Night light on — warm on schedule (click to disable)","class":"on"}\n'
|
||||
# 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 systemctl --user is-active --quiet "$unit"; then
|
||||
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]" >&2; exit 64 ;;
|
||||
*) echo "usage: nomarchy-nightlight [toggle|status|on|off|should-start|is-active]" >&2; exit 64 ;;
|
||||
esac
|
||||
'';
|
||||
in
|
||||
@@ -69,7 +91,7 @@ in
|
||||
# home.nix also works as a declarative opt-in.
|
||||
nomarchy.nightlight.enable = lib.mkDefault s.installed;
|
||||
|
||||
services.hyprsunset = lib.mkIf cfg.enable {
|
||||
services.hyprsunset = lib.mkIf (cfg.enable && !geo) {
|
||||
enable = true;
|
||||
settings.profile = [
|
||||
# Daytime: identity = no colour change.
|
||||
@@ -79,10 +101,19 @@ in
|
||||
];
|
||||
};
|
||||
|
||||
# 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.hyprsunset.Service.ExecCondition =
|
||||
systemd.user.services.${lib.removeSuffix ".service" unit}.Service.ExecCondition =
|
||||
lib.mkIf cfg.enable "${nomarchy-nightlight}/bin/nomarchy-nightlight should-start";
|
||||
|
||||
home.packages = [ nomarchy-nightlight ];
|
||||
|
||||
@@ -88,11 +88,11 @@ in
|
||||
# ── 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.
|
||||
'';
|
||||
};
|
||||
@@ -100,10 +100,18 @@ in
|
||||
# ── Preferences ────────────────────────────────────────────────
|
||||
terminal = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = config.nomarchy.settings.terminal or "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 = config.nomarchy.settings.keyboard.layout or "us";
|
||||
@@ -128,13 +136,13 @@ 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 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
|
||||
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
|
||||
@@ -174,9 +182,9 @@ in
|
||||
|
||||
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 {
|
||||
@@ -192,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;
|
||||
@@ -206,14 +215,31 @@ 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.";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -223,8 +249,8 @@ in
|
||||
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 sys-update /
|
||||
home-update / flatpak update yourself'' // { default = config.nomarchy.settings.updates.enable or false; };
|
||||
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;
|
||||
@@ -346,13 +372,37 @@ in
|
||||
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; };
|
||||
|
||||
@@ -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, ... }:
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ lib.mkIf config.nomarchy.rofi.enable {
|
||||
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; }
|
||||
# -x, both comm names: nixpkgs wraps the binary as `.waybar-wrapped`,
|
||||
# and an unanchored `waybar` also matches (and kills) the
|
||||
# nomarchy-waybar supervisor, whose bash dies on an unhandled RTMIN.
|
||||
poke_bar() { pkill -RTMIN+8 -x 'waybar|\.waybar-wrapped' 2>/dev/null || true; }
|
||||
|
||||
case "''${1:-}" in
|
||||
start)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,7 +71,148 @@ 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) — dialog-scoped
|
||||
ONLY, never a bare `button` / `button label`. This file is
|
||||
loaded by every GTK3 process including Waybar, and a direct
|
||||
`button label` rule beats the color waybar.css puts on
|
||||
`#workspaces button` (a rule matching the label node always
|
||||
outranks color inherited from the button), so the old bare
|
||||
pin painted the workspace digits with whatever palette was
|
||||
live when the bar process started — the 2026-07-19
|
||||
stale-digit bug: switch themes, digits keep the previous
|
||||
gtk.css color until the bar restarts. The real #98 fix is
|
||||
forcing adw-gtk3-dark (below); these pins are only the
|
||||
dialog safety net for a light sheet slipping in. */
|
||||
.dialog-action-area button,
|
||||
.dialog-action-area button label,
|
||||
messagedialog button,
|
||||
messagedialog button label,
|
||||
.message-dialog button,
|
||||
.message-dialog button label {
|
||||
color: #${base16.base05};
|
||||
}
|
||||
.dialog-action-area button:disabled, .dialog-action-area button:disabled label,
|
||||
messagedialog button:disabled, messagedialog button:disabled label,
|
||||
.message-dialog button:disabled, .message-dialog 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;
|
||||
@@ -60,10 +227,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;
|
||||
};
|
||||
@@ -89,13 +258,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 =
|
||||
if t.mode == "light" then "prefer-light" else "prefer-dark";
|
||||
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,7 +29,7 @@ in
|
||||
};
|
||||
|
||||
style = ''
|
||||
/* Palette baked from theme-state.json. Only roles guaranteed to
|
||||
/* 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).
|
||||
@@ -71,12 +71,47 @@ in
|
||||
font-size: ${toString t.fonts.size}pt;
|
||||
}
|
||||
|
||||
.control-center .notification-row:focus,
|
||||
.control-center .notification-row:hover {
|
||||
/* 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;
|
||||
|
||||
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,24 +1,29 @@
|
||||
# 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
|
||||
@@ -64,6 +69,10 @@ let
|
||||
# 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 = {
|
||||
@@ -74,13 +83,16 @@ let
|
||||
# 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-theme-sync enforces before every write. A
|
||||
# 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
|
||||
@@ -125,21 +137,28 @@ let
|
||||
if problems == [ ] then parsed
|
||||
else throw ''
|
||||
|
||||
Nomarchy: your theme-state.json is invalid:
|
||||
Nomarchy: your state.json is invalid:
|
||||
${lib.concatMapStrings (p: " ✖ ${p}\n") problems}
|
||||
Fix the named field(s) in the theme-state.json of your flake
|
||||
checkout (usually ~/.nomarchy/theme-state.json — the store copy at
|
||||
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-theme-sync
|
||||
the same report with per-field fixes: `nomarchy-state-sync
|
||||
validate`. Re-applying any preset resets all appearance fields:
|
||||
`nomarchy-theme-sync apply boreal`.'';
|
||||
`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
|
||||
@@ -149,10 +168,106 @@ 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 = checked // { 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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# 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 theme-sync uses),
|
||||
# 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`.
|
||||
#
|
||||
@@ -24,7 +24,8 @@ let
|
||||
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
|
||||
# Both comm names: nixpkgs wraps the binary as `.waybar-wrapped`.
|
||||
${pkgs.procps}/bin/pkill -SIGUSR2 -x 'waybar|\.waybar-wrapped' 2>/dev/null || true
|
||||
done
|
||||
'';
|
||||
in
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 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 sys-update /
|
||||
# home-update / flatpak update). It counts:
|
||||
# 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.
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
let
|
||||
cfg = config.nomarchy.updates;
|
||||
termSheet = import ./term-sheet.nix { inherit pkgs; };
|
||||
|
||||
nomarchy-updates = pkgs.writeShellScriptBin "nomarchy-updates" ''
|
||||
set -u
|
||||
@@ -70,7 +71,8 @@ let
|
||||
echo 0
|
||||
}
|
||||
|
||||
refresh_bar() { ${pkgs.procps}/bin/pkill -RTMIN+9 -x waybar 2>/dev/null || true; }
|
||||
# Both comm names: nixpkgs wraps the binary as `.waybar-wrapped`.
|
||||
refresh_bar() { ${pkgs.procps}/bin/pkill -RTMIN+9 -x 'waybar|\.waybar-wrapped' 2>/dev/null || true; }
|
||||
|
||||
case "''${1:-status}" in
|
||||
check)
|
||||
@@ -83,7 +85,7 @@ let
|
||||
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 run sys-update."
|
||||
"$msg — click the bar icon, or: nomarchy-pull && nomarchy-rebuild && nomarchy-home"
|
||||
fi
|
||||
refresh_bar ;;
|
||||
status)
|
||||
@@ -91,7 +93,7 @@ let
|
||||
[ "$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) — sys-update"
|
||||
[ "$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)
|
||||
@@ -99,9 +101,13 @@ let
|
||||
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 sys-update >/dev/null 2>&1; then
|
||||
read -rp "Run sys-update (flake update + system rebuild)? [y/N] " a
|
||||
[ "$a" = y ] && sys-update
|
||||
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
|
||||
@@ -109,7 +115,23 @@ let
|
||||
fi
|
||||
"$0" check
|
||||
echo "Done — press enter."; read -r _ || true ;;
|
||||
*) echo "usage: nomarchy-updates [check|status|upgrade]" >&2; exit 64 ;;
|
||||
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
|
||||
|
||||
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
|
||||
@@ -31,9 +36,10 @@ let
|
||||
# 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-theme-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
|
||||
# `pkill -x 'waybar|\.waybar-wrapped'` (nixpkgs wraps the binary, so
|
||||
# the comm is `.waybar-wrapped`) 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).
|
||||
@@ -89,33 +95,34 @@ let
|
||||
[ -n "$next" ] && powerprofilesctl set "$next"
|
||||
'';
|
||||
|
||||
# Opens calcurse (a lightweight TUI calendar, month view by default) in a
|
||||
# floating, centered ghostty window — bound to the Waybar clock's on-click.
|
||||
# A distinct --class gives the window a matchable app-id for the
|
||||
# float+center+size windowrule (hyprland.nix); --gtk-single-instance=false
|
||||
# forces a fresh standalone window (ghostty defaults single-instance on).
|
||||
# calcurse ships uncommented in the downstream template (opt-out), so
|
||||
# self-gate with a helpful notify if it's been removed. ghostty is always
|
||||
# installed (ghostty.nix), so it can be relied on for the --class window.
|
||||
# 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 ghostty --class=com.nomarchy.calendar --gtk-single-instance=false -e calcurse
|
||||
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). Click opens the VPN submenu.
|
||||
# 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" ''
|
||||
active=$(nmcli -t -f TYPE connection show --active 2>/dev/null | grep -Exm1 'vpn|wireguard')
|
||||
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=1
|
||||
[ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=Tailscale
|
||||
fi
|
||||
[ -n "$active" ] || [ -n "$ts" ] || exit 0
|
||||
printf '{"text":"","tooltip":"VPN active (click to manage)","class":"on"}\n'
|
||||
[ -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
|
||||
@@ -156,7 +163,10 @@ let
|
||||
# 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;
|
||||
@@ -170,7 +180,7 @@ let
|
||||
# — BACKLOG #63). Click targets are existing nomarchy-menu entry points.
|
||||
modules-left = [ "custom/nomarchy" "hyprland/workspaces" "hyprland/window" ];
|
||||
modules-center = [ "clock" ];
|
||||
modules-right = [ "custom/recording" "idle_inhibitor" "tray" "custom/vpn" "pulseaudio" "custom/powerprofile" "custom/nightlight" ]
|
||||
modules-right = [ "custom/recording" "idle_inhibitor" "tray" "custom/vpn" "custom/airplane" "pulseaudio" "custom/powerprofile" "custom/nightlight" ]
|
||||
++ lib.optional showLanguage "hyprland/language"
|
||||
++ [ "battery" "custom/doctor" "custom/updates" "custom/notification" "custom/powermenu" ];
|
||||
|
||||
@@ -200,27 +210,37 @@ let
|
||||
};
|
||||
|
||||
clock = {
|
||||
format = "{:%H:%M}";
|
||||
# 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
|
||||
# ghostty). Replaces the old format-alt date toggle; the long date now
|
||||
# rides in the tooltip's first line, so nothing is lost on hover.
|
||||
# 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";
|
||||
# The zone line stays live under auto-timezone (the tz-watch SIGUSR2
|
||||
# reload keeps it showing the currently *detected* zone).
|
||||
tooltip-format = "{:%A %d %B %Y}\n{:%Z (UTC%z)}\n<tt><small>{calendar}</small></tt>";
|
||||
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 = "<span size='${toString (t.fonts.size + 2)}pt'></span> {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 = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span> {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
|
||||
@@ -246,9 +266,17 @@ let
|
||||
|
||||
battery = {
|
||||
states = { warning = 25; critical = 10; };
|
||||
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 = [ "" "" "" "" "" ];
|
||||
# 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";
|
||||
@@ -311,6 +339,17 @@ let
|
||||
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
|
||||
@@ -320,7 +359,7 @@ let
|
||||
return-type = "json";
|
||||
interval = 1800;
|
||||
signal = 9;
|
||||
on-click = "${config.nomarchy.terminal} -e nomarchy-updates upgrade";
|
||||
on-click = "nomarchy-updates upgrade-window";
|
||||
};
|
||||
|
||||
# swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON
|
||||
@@ -355,15 +394,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;
|
||||
@@ -371,14 +427,20 @@ let
|
||||
border-radius: ${toString t.ui.rounding}px;
|
||||
}
|
||||
|
||||
/* Dim states use the palette's @muted role: since item 28b it is
|
||||
floor-guaranteed legible on @base in every theme (muted/base >=
|
||||
2.0, gated by tools/check-theme-contrast.py) — the palettes that
|
||||
once made it vanish (gruvbox muted≈base, item 27) were retuned.
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -406,14 +468,18 @@ let
|
||||
color: @accent;
|
||||
font-family: Nomarchy;
|
||||
font-size: ${toString (t.fonts.size + 4)}pt;
|
||||
padding: 0 10px;
|
||||
/* 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-updates, #custom-vpn, #custom-recording, #idle_inhibitor, #language, #battery, #custom-doctor, #custom-notification, #custom-powermenu {
|
||||
#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; }
|
||||
|
||||
/* Group rhythm (item 28c): a wider breath before each functional
|
||||
@@ -434,6 +500,7 @@ let
|
||||
|
||||
/* 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; }
|
||||
@@ -446,7 +513,7 @@ let
|
||||
(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-nightlight, #custom-doctor, #custom-notification, #custom-powermenu { font-size: ${toString (t.fonts.size + 2)}pt; }
|
||||
#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. */
|
||||
@@ -464,6 +531,15 @@ let
|
||||
#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
|
||||
{
|
||||
@@ -474,7 +550,7 @@ in
|
||||
# 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-theme-sync). No uwsm here to manage 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;
|
||||
|
||||
@@ -485,7 +561,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
|
||||
);
|
||||
|
||||
@@ -496,9 +572,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 — plus
|
||||
# the supervisor hyprland.nix exec-onces.
|
||||
# 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
|
||||
[ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus doctorStatus calendarLauncher ];
|
||||
[ 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,9 +20,26 @@ 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 ./greeter.nix ./file-manager.nix ./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.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 = {
|
||||
# Distro branding. distroName flows into /etc/os-release PRETTY_NAME,
|
||||
@@ -38,33 +55,63 @@ in
|
||||
# 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
|
||||
sys-rebuild rebuild the system, no input update
|
||||
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
|
||||
@@ -116,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 ─────────────────────────────────────────────
|
||||
@@ -142,6 +193,27 @@ in
|
||||
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;
|
||||
@@ -175,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 != [ ];
|
||||
@@ -193,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;
|
||||
@@ -204,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 ─────────────────────────────────────────────────────
|
||||
@@ -282,7 +382,7 @@ in
|
||||
# ── 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
|
||||
@@ -319,118 +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-control-center # TUI control center
|
||||
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 -euo pipefail
|
||||
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"
|
||||
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 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 "sys-update: 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
|
||||
# What did that update actually change? Package-level diff of the
|
||||
# old vs new generation (the informative half of "informative +
|
||||
# rock-stable"); never fails the run.
|
||||
after=$(readlink -f /run/current-system)
|
||||
if [ "$before" = "$after" ]; then
|
||||
echo "sys-update: no changes — the system is identical."
|
||||
else
|
||||
echo "sys-update: what changed:"
|
||||
${pkgs.nvd}/bin/nvd diff "$before" "$after" || true
|
||||
fi
|
||||
'')
|
||||
# The no-update twin (hardware-QA request): rebuild the system
|
||||
# against the CURRENT lock — config changes only, no `nix flake
|
||||
# update` — mirroring how home-update never touches the lock.
|
||||
# Same snapshot-first path when available.
|
||||
(pkgs.writeShellScriptBin "sys-rebuild" ''
|
||||
set -euo pipefail
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "sys-rebuild: run as your normal user (it sudos the rebuild itself)" >&2
|
||||
exit 1
|
||||
fi
|
||||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||
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 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 "sys-rebuild: 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
|
||||
# Same what-changed diff as sys-update (the twins stay twins).
|
||||
after=$(readlink -f /run/current-system)
|
||||
if [ "$before" = "$after" ]; then
|
||||
echo "sys-rebuild: no changes — the system is identical."
|
||||
else
|
||||
echo "sys-rebuild: what changed:"
|
||||
${pkgs.nvd}/bin/nvd diff "$before" "$after" || true
|
||||
fi
|
||||
'')
|
||||
(pkgs.writeShellScriptBin "home-update" ''
|
||||
set -euo pipefail
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "home-update: run as your normal user" >&2
|
||||
exit 1
|
||||
fi
|
||||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||
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 "home-update: switch 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
|
||||
'')
|
||||
# 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
|
||||
@@ -452,12 +449,15 @@ 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) — the primary `nomarchy-menu snapshot`
|
||||
@@ -536,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;
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Greeter — greetd/tuigreet, themed from the same theme-state.json that
|
||||
# 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).
|
||||
@@ -14,16 +14,70 @@
|
||||
# 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 builtins.fromJSON (builtins.readFile cfg.stateFile)
|
||||
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 [ ];
|
||||
@@ -44,6 +98,15 @@ let
|
||||
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));
|
||||
|
||||
@@ -15,13 +15,66 @@
|
||||
|
||||
let
|
||||
cfg = config.nomarchy.hardware;
|
||||
# Fingerprint PAM can follow theme-state.json (menu toggle → next
|
||||
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55).
|
||||
# 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 builtins.fromJSON (builtins.readFile config.nomarchy.system.stateFile)
|
||||
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 = {
|
||||
@@ -103,14 +156,28 @@ in
|
||||
type = lib.types.bool;
|
||||
default = pamFromState;
|
||||
defaultText = lib.literalExpression
|
||||
"(settings.fingerprint.pam from theme-state.json) or false";
|
||||
"(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 theme-state.json `settings.fingerprint.pam` (System › Fingerprint
|
||||
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 ''
|
||||
@@ -192,12 +259,35 @@ in
|
||||
})
|
||||
|
||||
# ── 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;
|
||||
})
|
||||
(lib.mkIf (cfg.fingerprint.enable && cfg.fingerprint.pam) {
|
||||
security.pam.services.login.fprintAuth = true;
|
||||
security.pam.services.sudo.fprintAuth = 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) ──────
|
||||
|
||||
@@ -14,9 +14,28 @@
|
||||
# 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;
|
||||
|
||||
|
||||
@@ -11,27 +11,34 @@
|
||||
|
||||
greeter.autoLogin = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = config.nomarchy.settings.greeter.autoLogin or null;
|
||||
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 Boreal base color.
|
||||
'';
|
||||
@@ -44,7 +51,16 @@
|
||||
option'' // { default = true; };
|
||||
|
||||
audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; };
|
||||
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = config.nomarchy.settings.bluetooth.enable or 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
|
||||
@@ -102,17 +118,49 @@
|
||||
|
||||
batteryChargeLimit = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.ints.between 50 100);
|
||||
default = config.nomarchy.settings.power.batteryChargeLimit or null;
|
||||
# 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 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. Backend-independent
|
||||
(a small systemd unit writes the sysfs knob, re-applied on AC
|
||||
state changes), so it works under PPD too; needs
|
||||
nomarchy.system.power.laptop.
|
||||
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,7 +12,7 @@ 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);
|
||||
# Fallbacks match the distro default theme (Boreal) when stateFile is null.
|
||||
@@ -24,8 +24,20 @@ let
|
||||
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.
|
||||
@@ -38,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";
|
||||
@@ -53,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"; }
|
||||
@@ -62,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" \
|
||||
@@ -73,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,6 +48,38 @@ 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;
|
||||
@@ -40,37 +88,147 @@ in
|
||||
# TLP's own knob only applies under TLP — so a tiny oneshot writes
|
||||
# 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). `[ -w ]` skips paths that
|
||||
# don't exist, 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}%";
|
||||
# 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 = ''
|
||||
# Name-agnostic (BAT0, CMB0, …): same type/scope filter as
|
||||
# nomarchy-battery-notify — BACKLOG #60.
|
||||
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"
|
||||
[ -w "$thresh" ] && echo ${toString cfg.batteryChargeLimit} > "$thresh"
|
||||
[ -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
|
||||
'';
|
||||
};
|
||||
|
||||
# Re-apply the threshold whenever the mains adapter changes state: the
|
||||
# boot oneshot above runs once, but some firmwares clear the limit when
|
||||
# the charger is unplugged, so it must be re-asserted on the event.
|
||||
# Matched by ATTR{type}=="Mains" (vendor-neutral — the kernel name
|
||||
# AC/AC0/ADP1/ACAD varies); --no-block so the RUN+= returns at once
|
||||
# (systemd kills long-running udev workers); restart (not try-restart)
|
||||
# so it re-applies even if the boot run was inactive.
|
||||
services.udev.extraRules = lib.mkIf chargeLimit ''
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${config.systemd.package}/bin/systemctl --no-block restart nomarchy-battery-charge-limit.service"
|
||||
# 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,6 +7,19 @@
|
||||
|
||||
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 = {
|
||||
@@ -74,10 +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`); the menu's
|
||||
System ▸ Printers entry opens the system-config-printer GUI'' // { default = config.nomarchy.settings.printing.enable or false; };
|
||||
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
|
||||
@@ -124,6 +145,14 @@ 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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# /etc/localtime at runtime.
|
||||
#
|
||||
# In-flake state, menu-driven (the keyboard/night-light philosophy): the on/off
|
||||
# flag lives in the same theme-state.json under `settings.autoTimezone`
|
||||
# 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
|
||||
@@ -15,14 +15,15 @@ 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 the file is absent/sparse.
|
||||
# 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 builtins.fromJSON (builtins.readFile cfg.stateFile)
|
||||
then import ../state-read.nix { inherit lib; } cfg.stateFile
|
||||
else { };
|
||||
stateEnabled = (state.settings or { }).autoTimezone or false;
|
||||
|
||||
sync = lib.getExe pkgs.nomarchy-theme-sync;
|
||||
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
|
||||
|
||||
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
|
||||
127
pkgs/nomarchy-airplane/default.nix
Normal file
127
pkgs/nomarchy-airplane/default.nix
Normal file
@@ -0,0 +1,127 @@
|
||||
{ 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
|
||||
]}
|
||||
|
||||
# -x, both comm names: nixpkgs wraps the binary as `.waybar-wrapped`,
|
||||
# and an unanchored `waybar` also matches (and kills) the
|
||||
# nomarchy-waybar supervisor, whose bash dies on an unhandled RTMIN.
|
||||
poke_bar() {
|
||||
pkill -RTMIN+11 -x 'waybar|\.waybar-wrapped' 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
|
||||
''
|
||||
@@ -1,7 +0,0 @@
|
||||
{ writeShellApplication, coreutils, gawk, jq, gum }:
|
||||
|
||||
writeShellApplication {
|
||||
name = "nomarchy-control-center";
|
||||
runtimeInputs = [ coreutils gawk jq gum ];
|
||||
text = builtins.readFile ./nomarchy-control-center.sh;
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Nomarchy Control Center — TUI frontend over nomarchy-theme-sync and tools
|
||||
# Uses charmbracelet/gum for rendering.
|
||||
|
||||
trap 'exit 0' SIGINT
|
||||
|
||||
function get_state() {
|
||||
nomarchy-theme-sync get "$1" 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
function set_state() {
|
||||
nomarchy-theme-sync --quiet set "$1" "$2" --no-switch
|
||||
}
|
||||
|
||||
function first_boot() {
|
||||
gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "Welcome to Nomarchy!"
|
||||
|
||||
echo "Pick a Theme Preset:"
|
||||
themes=$(nomarchy-theme-sync list)
|
||||
chosen_theme=$(printf "%s" "$themes" | gum choose)
|
||||
if [ -n "$chosen_theme" ]; then
|
||||
echo "Applying theme $chosen_theme..."
|
||||
nomarchy-theme-sync apply "$chosen_theme"
|
||||
fi
|
||||
|
||||
if gum confirm "Enable auto-commit for settings?"; then
|
||||
set_state "settings.autoCommit" "true"
|
||||
else
|
||||
set_state "settings.autoCommit" "false"
|
||||
fi
|
||||
|
||||
if command -v nomarchy-autotimezone >/dev/null 2>&1; then
|
||||
if gum confirm "Enable automatic timezone detection?"; then
|
||||
set_state "settings.autoTimezone" "true"
|
||||
else
|
||||
set_state "settings.autoTimezone" "false"
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v fwupdmgr >/dev/null 2>&1; then
|
||||
gum style --foreground 245 \
|
||||
"Tip: SUPER+M → System › Firmware checks LVFS updates (never auto-flashes)."
|
||||
fi
|
||||
|
||||
gum style --foreground 212 "First boot configuration complete!"
|
||||
echo "You can always change these later in the Control Center."
|
||||
read -r -n 1 -s -p "Press any key to exit..."
|
||||
echo
|
||||
}
|
||||
|
||||
function main_menu() {
|
||||
while true; do
|
||||
choice=$(gum choose "Appearance" "System Toggles" "Health (Doctor)" "Rollback" "Exit")
|
||||
case "$choice" in
|
||||
"Appearance") appearance_menu ;;
|
||||
"System Toggles") toggles_menu ;;
|
||||
"Health (Doctor)")
|
||||
if command -v nomarchy-doctor >/dev/null 2>&1; then
|
||||
nomarchy-doctor || true
|
||||
else
|
||||
echo "nomarchy-doctor not found."
|
||||
fi
|
||||
echo
|
||||
read -r -n 1 -s -p "Press any key to return..."
|
||||
echo
|
||||
;;
|
||||
"Rollback")
|
||||
gens=$(home-manager generations 2>/dev/null | head -10)
|
||||
if [ -z "$gens" ]; then
|
||||
echo "No Home Manager generations found."
|
||||
else
|
||||
tmp=$(mktemp)
|
||||
printf "%s\n" "$gens" | awk '{ printf "Desktop gen %s — %s %s%s\n", $5, $1, $2, (NR==1 ? " (current)" : "") }' > "$tmp"
|
||||
sel=$(gum choose < "$tmp" || true)
|
||||
rm -f "$tmp"
|
||||
if [ -n "$sel" ]; then
|
||||
num=${sel#Desktop gen }
|
||||
num=${num%% *}
|
||||
path=$(printf "%s\n" "$gens" | awk -v n="$num" '$5 == n { print $7 }')
|
||||
if [ -x "$path/activate" ]; then
|
||||
echo "Activating Home Manager generation $num..."
|
||||
"$path/activate"
|
||||
echo "Desktop rolled back to generation $num."
|
||||
else
|
||||
echo "Generation $num not found."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
read -r -n 1 -s -p "Press any key to return..."
|
||||
echo
|
||||
;;
|
||||
"Exit"|*) break ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
function appearance_menu() {
|
||||
while true; do
|
||||
choice=$(gum choose "Pick Theme" "Toggle Blur" "Set Gaps" "Back")
|
||||
case "$choice" in
|
||||
"Pick Theme")
|
||||
themes=$(nomarchy-theme-sync list)
|
||||
chosen=$(printf "%s" "$themes" | gum choose)
|
||||
if [ -n "$chosen" ]; then
|
||||
nomarchy-theme-sync apply "$chosen"
|
||||
fi
|
||||
;;
|
||||
"Toggle Blur")
|
||||
blur=$(get_state "ui.blur")
|
||||
if [ "$blur" = "true" ]; then
|
||||
set_state "ui.blur" "false"
|
||||
else
|
||||
set_state "ui.blur" "true"
|
||||
fi
|
||||
echo "Blur setting saved (requires rebuild)."
|
||||
sleep 1
|
||||
;;
|
||||
"Set Gaps")
|
||||
gaps=$(gum input --prompt "Enter gaps in pixels: " --placeholder "$(get_state ui.gapsOut)")
|
||||
if [ -n "$gaps" ]; then
|
||||
set_state "ui.gapsOut" "$gaps"
|
||||
echo "Gaps set to $gaps (requires rebuild)."
|
||||
sleep 1
|
||||
fi
|
||||
;;
|
||||
"Back"|*) break ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
function toggles_menu() {
|
||||
while true; do
|
||||
choice=$(gum choose "Auto-commit" "Auto-timezone" "Night Light" "Updates" "Bluetooth" "Printing" "Terminal" "Keyboard Layout" "Auto-Login" "Back")
|
||||
case "$choice" in
|
||||
"Auto-commit")
|
||||
cur=$(get_state "settings.autoCommit")
|
||||
if [ "$cur" = "true" ]; then
|
||||
set_state "settings.autoCommit" "false"
|
||||
echo "Auto-commit disabled."
|
||||
else
|
||||
set_state "settings.autoCommit" "true"
|
||||
echo "Auto-commit enabled."
|
||||
fi
|
||||
sleep 1
|
||||
;;
|
||||
"Auto-timezone")
|
||||
cur=$(get_state "settings.autoTimezone")
|
||||
if [ "$cur" = "true" ]; then
|
||||
set_state "settings.autoTimezone" "false"
|
||||
echo "Auto-timezone disabled."
|
||||
else
|
||||
set_state "settings.autoTimezone" "true"
|
||||
echo "Auto-timezone enabled."
|
||||
fi
|
||||
sleep 1
|
||||
;;
|
||||
"Night Light")
|
||||
if command -v nomarchy-nightlight >/dev/null 2>&1; then
|
||||
nomarchy-nightlight toggle
|
||||
sleep 1
|
||||
else
|
||||
echo "nomarchy-nightlight not found."
|
||||
sleep 1
|
||||
fi
|
||||
;;
|
||||
"Updates")
|
||||
cur=$(get_state "settings.updates.enable")
|
||||
if [ "$cur" = "true" ]; then
|
||||
set_state "settings.updates.enable" "false"
|
||||
echo "Updates checker disabled (requires rebuild)."
|
||||
else
|
||||
set_state "settings.updates.enable" "true"
|
||||
echo "Updates checker enabled (requires rebuild)."
|
||||
fi
|
||||
sleep 1
|
||||
;;
|
||||
"Bluetooth")
|
||||
cur=$(get_state "settings.bluetooth.enable")
|
||||
if [ "$cur" = "true" ]; then
|
||||
set_state "settings.bluetooth.enable" "false"
|
||||
echo "Bluetooth disabled (requires rebuild)."
|
||||
else
|
||||
set_state "settings.bluetooth.enable" "true"
|
||||
echo "Bluetooth enabled (requires rebuild)."
|
||||
fi
|
||||
sleep 1
|
||||
;;
|
||||
"Printing")
|
||||
cur=$(get_state "settings.printing.enable")
|
||||
if [ "$cur" = "true" ]; then
|
||||
set_state "settings.printing.enable" "false"
|
||||
echo "Printing services disabled (requires rebuild)."
|
||||
else
|
||||
set_state "settings.printing.enable" "true"
|
||||
echo "Printing services enabled (requires rebuild)."
|
||||
fi
|
||||
sleep 1
|
||||
;;
|
||||
"Terminal")
|
||||
term=$(gum choose "ghostty" "kitty" "alacritty" "wezterm" "foot")
|
||||
if [ -n "$term" ]; then
|
||||
set_state "settings.terminal" "\"$term\""
|
||||
echo "Terminal set to $term (requires rebuild)."
|
||||
fi
|
||||
sleep 1
|
||||
;;
|
||||
"Keyboard Layout")
|
||||
layout=$(gum input --prompt "Enter keyboard layout (e.g. us, de, es): " --placeholder "$(get_state settings.keyboard.layout | tr -d '"')")
|
||||
if [ -n "$layout" ]; then
|
||||
set_state "settings.keyboard.layout" "\"$layout\""
|
||||
echo "Keyboard layout set to $layout (requires rebuild)."
|
||||
fi
|
||||
sleep 1
|
||||
;;
|
||||
"Auto-Login")
|
||||
user=$(gum input --prompt "Enter auto-login username (or empty to disable): " --placeholder "$(get_state settings.greeter.autoLogin | tr -d '"')")
|
||||
if [ -n "$user" ]; then
|
||||
set_state "settings.greeter.autoLogin" "\"$user\""
|
||||
echo "Auto-login set to $user (requires rebuild)."
|
||||
else
|
||||
set_state "settings.greeter.autoLogin" "null"
|
||||
echo "Auto-login disabled (requires rebuild)."
|
||||
fi
|
||||
sleep 1
|
||||
;;
|
||||
"Back"|*) break ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--first-boot" ]; then
|
||||
first_boot
|
||||
else
|
||||
main_menu
|
||||
fi
|
||||
@@ -60,28 +60,40 @@ for mp in / /boot /nix; do
|
||||
avail=$(numfmt --to=iec $((availkb * 1024)))
|
||||
if [ "$use" -ge 90 ]; then
|
||||
bad "$mp is ${use}% full (${avail} free)" \
|
||||
"sudo nix-collect-garbage --delete-older-than 14d (then sys-rebuild to prune old boot entries)"
|
||||
"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 ───────────────────────────────────────────
|
||||
if [ -f "$flake/theme-state.json" ]; then
|
||||
if jq empty "$flake/theme-state.json" 2>/dev/null; then
|
||||
ok "theme-state.json parses"
|
||||
# #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 "theme-state.json is not valid JSON (rebuilds will fail)" \
|
||||
"nomarchy-theme-sync validate (names the spot; fix it, or re-apply a theme)"
|
||||
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 theme-state.json >/dev/null 2>&1; then
|
||||
ok "theme-state.json is git-tracked"
|
||||
if git -C "$flake" ls-files --error-unmatch "$state_base" >/dev/null 2>&1; then
|
||||
ok "$state_base is git-tracked"
|
||||
else
|
||||
bad "theme-state.json is NOT git-tracked (flake evaluation can't see it)" \
|
||||
"git -C $flake add theme-state.json"
|
||||
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 "theme-state.json (no flake checkout at $flake)"
|
||||
skip "state.json (no flake checkout at $flake)"
|
||||
fi
|
||||
|
||||
if [ -d "$flake/.git" ]; then
|
||||
@@ -98,6 +110,19 @@ if [ -d "$flake/.git" ]; then
|
||||
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).
|
||||
@@ -115,6 +140,21 @@ 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
|
||||
@@ -127,6 +167,33 @@ 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
|
||||
@@ -233,6 +300,8 @@ 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
|
||||
@@ -246,13 +315,132 @@ for bat in /sys/class/power_supply/*/; do
|
||||
esac
|
||||
if [ "$lim" -lt 100 ]; then
|
||||
bat_limited=1
|
||||
ok "$(basename "$bat") charge limit active at ${lim}%"
|
||||
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
|
||||
|
||||
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"
|
||||
''
|
||||
@@ -15,8 +15,9 @@
|
||||
, 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)
|
||||
@@ -53,7 +54,7 @@ stdenvNoCC.mkDerivation {
|
||||
mkdir -p "$share/template"
|
||||
# Full downstream template is the SoT; install script copies + patches.
|
||||
cp ${templateDir}/flake.nix ${templateDir}/system.nix \
|
||||
${templateDir}/home.nix ${templateDir}/theme-state.json \
|
||||
${templateDir}/home.nix ${templateDir}/state.json \
|
||||
"$share/template/"
|
||||
install -Dm644 patch-template.py "$share/patch-template.py"
|
||||
|
||||
@@ -65,6 +66,7 @@ stdenvNoCC.mkDerivation {
|
||||
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)} \
|
||||
|
||||
@@ -26,6 +26,121 @@
|
||||
|
||||
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/,
|
||||
@@ -109,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)")
|
||||
@@ -164,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"
|
||||
@@ -214,17 +364,51 @@ 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)
|
||||
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=""
|
||||
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=""
|
||||
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" ]] \
|
||||
|| timedatectl list-timezones 2>/dev/null | grep -qx "$TIMEZONE" \
|
||||
@@ -232,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"
|
||||
@@ -287,10 +477,11 @@ 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) ")— $SOURCE_NET"
|
||||
@@ -394,7 +585,7 @@ rm -rf /mnt/etc/nixos
|
||||
cp "$SHARE/template/flake.nix" \
|
||||
"$SHARE/template/system.nix" \
|
||||
"$SHARE/template/home.nix" \
|
||||
"$SHARE/template/theme-state.json" \
|
||||
"$SHARE/template/state.json" \
|
||||
"$FLAKE_DIR/"
|
||||
|
||||
# Detected hardware → flags for the patcher (safe defaults active).
|
||||
@@ -460,7 +651,8 @@ python3 "$SHARE/patch-template.py" "$FLAKE_DIR" <<PYJSON
|
||||
"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
|
||||
"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")))')
|
||||
}
|
||||
PYJSON
|
||||
|
||||
@@ -487,7 +679,7 @@ if (( compose_ok != 0 )); then
|
||||
(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
|
||||
@@ -496,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.
|
||||
@@ -514,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.
|
||||
@@ -554,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.
|
||||
@@ -603,10 +802,28 @@ 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
|
||||
# NOMARCHY_TEST_FORCE_HM_FAIL=1 — unattended harness only (#54 V2): take
|
||||
# the failure arm so the durable recovery hint is exercised without a
|
||||
@@ -629,8 +846,8 @@ else
|
||||
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: the account doesn't exist in the live environment yet
|
||||
# (same reasoning as the $FLAKE_DIR chown above).
|
||||
# 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
|
||||
@@ -640,7 +857,7 @@ Desktop pre-activation failed during install — see
|
||||
|
||||
(delete this file once done)
|
||||
HINT
|
||||
chown 1000:100 "$hint_file"
|
||||
chown "$USER_UID:$USER_GID" "$hint_file"
|
||||
fi
|
||||
rm -f /mnt/root/nomarchy-hm-activate.sh
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ def nix_str(s: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
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}")
|
||||
@@ -51,6 +57,17 @@ def patch_flake(text: str, v: dict) -> str:
|
||||
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)
|
||||
@@ -75,7 +92,7 @@ def patch_flake(text: str, v: dict) -> str:
|
||||
|
||||
def patch_home(text: str, v: dict) -> str:
|
||||
layout = nix_str(v["keyboardLayout"])
|
||||
variant = nix_str(v.get("keyboardVariant") or "")
|
||||
variant = nix_str(keyboard_variant(v))
|
||||
text = replace_once(
|
||||
text,
|
||||
' nomarchy.keyboard.layout = "us";',
|
||||
@@ -98,12 +115,10 @@ def build_installer_region(v: dict) -> str:
|
||||
" # active; heavier opt-ins stay in the commented catalog below.",
|
||||
]
|
||||
|
||||
if v.get("autoLogin"):
|
||||
user = nix_str(v["username"])
|
||||
lines += [
|
||||
" # LUKS passphrase already gates this machine — skip the greeter password.",
|
||||
f' nomarchy.system.greeter.autoLogin = "{user}";',
|
||||
]
|
||||
# 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 += [
|
||||
@@ -213,7 +228,7 @@ def patch_system(text: str, v: dict) -> str:
|
||||
text = replace_once(
|
||||
text,
|
||||
' services.xserver.xkb.variant = "";',
|
||||
f' services.xserver.xkb.variant = "{nix_str(v.get("keyboardVariant") or "")}";',
|
||||
f' services.xserver.xkb.variant = "{nix_str(keyboard_variant(v))}";',
|
||||
"xkb variant",
|
||||
)
|
||||
|
||||
@@ -244,6 +259,23 @@ def patch_system(text: str, v: dict) -> str:
|
||||
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>")
|
||||
@@ -254,6 +286,7 @@ def main() -> None:
|
||||
"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
|
||||
|
||||
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.2";
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""nomarchy-theme-sync — state writer for Nomarchy's declarative theming.
|
||||
"""nomarchy-state-sync — state writer for Nomarchy's declarative theming.
|
||||
|
||||
Single source of truth: $NOMARCHY_PATH/theme-state.json (inside the flake,
|
||||
Single source of truth: $NOMARCHY_PATH/state.json (inside the flake,
|
||||
read purely by Home Manager via the nomarchy.stateFile option).
|
||||
Legacy name `theme-state.json` is still read (and migrated on write).
|
||||
The old binary name `nomarchy-theme-sync` is a symlink to this tool.
|
||||
|
||||
Theme changes are applied by Home Manager: this tool writes the new state
|
||||
and runs `home-manager switch` (override with $NOMARCHY_REBUILD, or skip
|
||||
with --no-switch). All app theming — Hyprland, Waybar, Ghostty, btop,
|
||||
with --no-switch). All app theming — Hyprland, Waybar, Kitty, btop,
|
||||
Stylix — is baked into the generation; nothing is patched at runtime.
|
||||
|
||||
The one runtime exception is the wallpaper: swww is imperative by nature,
|
||||
@@ -18,6 +20,7 @@ Commands:
|
||||
apply <name|file.json> merge a preset into the state + rebuild
|
||||
set <dotted.path> <value> tweak one key (e.g. `set ui.gapsOut 16`) + rebuild
|
||||
get [dotted.path] print the current state (or one key)
|
||||
auto [--which] apply the day/night theme for the current time
|
||||
validate check the state file against the schema (read-only)
|
||||
wallpaper apply the current wallpaper via swww
|
||||
bg [next|auto] cycle the theme's wallpapers (instant, no rebuild)
|
||||
@@ -38,7 +41,24 @@ from pathlib import Path
|
||||
# ─── Paths ────────────────────────────────────────────────────────────────
|
||||
|
||||
FLAKE_DIR = Path(os.environ.get("NOMARCHY_PATH", Path.home() / ".nomarchy")).expanduser()
|
||||
STATE_FILE = FLAKE_DIR / "theme-state.json"
|
||||
# Canonical name after #107. Legacy theme-state.json (and a brief theme.json
|
||||
# misnomer some notes used) are still accepted for existing checkouts.
|
||||
STATE_NAME = "state.json"
|
||||
LEGACY_STATE_NAMES = ("theme-state.json", "theme.json")
|
||||
|
||||
|
||||
def resolve_state_file(flake_dir: Path = FLAKE_DIR) -> Path:
|
||||
preferred = flake_dir / STATE_NAME
|
||||
if preferred.exists():
|
||||
return preferred
|
||||
for name in LEGACY_STATE_NAMES:
|
||||
legacy = flake_dir / name
|
||||
if legacy.exists():
|
||||
return legacy
|
||||
return preferred
|
||||
|
||||
|
||||
STATE_FILE = resolve_state_file()
|
||||
|
||||
# Preset search path: the user's flake first (their custom themes win),
|
||||
# then the presets baked into this package by the Nix build.
|
||||
@@ -50,21 +70,24 @@ WALLPAPER_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
|
||||
QUIET = False
|
||||
|
||||
# argv[0] may still be the compatibility name nomarchy-theme-sync.
|
||||
_TOOL = Path(sys.argv[0]).name or "nomarchy-state-sync"
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
if not QUIET:
|
||||
print(f"nomarchy-theme-sync: {msg}")
|
||||
print(f"{_TOOL}: {msg}")
|
||||
|
||||
|
||||
def die(msg: str) -> "None":
|
||||
print(f"nomarchy-theme-sync: error: {msg}", file=sys.stderr)
|
||||
print(f"{_TOOL}: error: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# All Nomarchy theme notifications share this synchronous tag, so the
|
||||
# notification daemon (swaync) REPLACES the previous one in place instead
|
||||
# of stacking — the "rebuilding…" toast becomes the "applied ✓" toast.
|
||||
NOTIFY_SYNC_TAG = "nomarchy-theme-sync"
|
||||
NOTIFY_SYNC_TAG = "nomarchy-state-sync"
|
||||
|
||||
|
||||
def notify(body: str, summary: str = "Nomarchy", *,
|
||||
@@ -86,13 +109,14 @@ def notify(body: str, summary: str = "Nomarchy", *,
|
||||
|
||||
# ─── State management ─────────────────────────────────────────────────────
|
||||
|
||||
def load_state(path: Path = STATE_FILE) -> dict:
|
||||
def load_state(path: Path | None = None) -> dict:
|
||||
path = path or resolve_state_file()
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except FileNotFoundError:
|
||||
die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)")
|
||||
except json.JSONDecodeError as e:
|
||||
die(f"theme-state.json has a JSON syntax error at line {e.lineno}, "
|
||||
die(f"{path.name} has a JSON syntax error at line {e.lineno}, "
|
||||
f"column {e.colno}: {e.msg}\n"
|
||||
f" file: {path}\n"
|
||||
f" fix: correct the syntax by hand (a trailing comma is the "
|
||||
@@ -102,32 +126,48 @@ def load_state(path: Path = STATE_FILE) -> dict:
|
||||
def write_state(state: dict) -> None:
|
||||
"""Atomic write: render to a temp file in the same dir, then rename.
|
||||
Validates first — an invalid state never reaches disk (the old file
|
||||
stays untouched), so a bad `set` can't brick the next rebuild."""
|
||||
stays untouched), so a bad `set` can't brick the next rebuild.
|
||||
Always writes the canonical state.json; removes a legacy-named sibling
|
||||
so a checkout never has two competing sources of truth."""
|
||||
errors, warnings = validate_state(state)
|
||||
for w in warnings:
|
||||
log(f"warning: {w}")
|
||||
if errors:
|
||||
die("refusing to write an invalid state (nothing changed):\n ✖ "
|
||||
+ "\n ✖ ".join(errors))
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".theme-state.", suffix=".json")
|
||||
out = FLAKE_DIR / STATE_NAME
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=out.parent, prefix=".state.", suffix=".json")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
f.write("\n")
|
||||
os.replace(tmp, STATE_FILE)
|
||||
os.replace(tmp, out)
|
||||
except BaseException:
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
log(f"state written to {STATE_FILE}")
|
||||
for name in LEGACY_STATE_NAMES:
|
||||
legacy = FLAKE_DIR / name
|
||||
if legacy.exists() and legacy.resolve() != out.resolve():
|
||||
try:
|
||||
legacy.unlink()
|
||||
log(f"migrated: removed legacy {name} (now {STATE_NAME})")
|
||||
except OSError as e:
|
||||
log(f"warning: could not remove legacy {name}: {e}")
|
||||
log(f"state written to {out}")
|
||||
|
||||
# Flakes only see git-tracked files. theme-state.json ships tracked,
|
||||
# Flakes only see git-tracked files. state.json ships tracked,
|
||||
# but make sure a fresh checkout / reset can't silently hide it.
|
||||
if (FLAKE_DIR / ".git").exists() and shutil.which("git"):
|
||||
subprocess.run(
|
||||
["git", "-C", str(FLAKE_DIR), "add", "--intent-to-add", "theme-state.json"],
|
||||
["git", "-C", str(FLAKE_DIR), "add", "--intent-to-add", STATE_NAME],
|
||||
capture_output=True,
|
||||
)
|
||||
for name in LEGACY_STATE_NAMES:
|
||||
subprocess.run(
|
||||
["git", "-C", str(FLAKE_DIR), "rm", "-f", "--ignore-unmatch", name],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def auto_commit_enabled(state: dict) -> bool:
|
||||
@@ -135,27 +175,41 @@ def auto_commit_enabled(state: dict) -> bool:
|
||||
|
||||
|
||||
def auto_commit(message: str) -> None:
|
||||
"""Opt-in (settings.autoCommit): commit theme-state.json — and nothing
|
||||
"""Opt-in (settings.autoCommit): commit state.json — and nothing
|
||||
else — after a mutation, so settings history is `git log`. The pathspec
|
||||
keeps unrelated dirty files out of the commit; a missing git identity
|
||||
falls back to a Nomarchy one so a fresh machine never errors. Callers
|
||||
fire this when the flag is on before OR after the write, so the
|
||||
disable-toggle itself lands in history instead of staying dirty.
|
||||
`bg` is deliberately excluded (runtime wallpaper churn); the wallpaper
|
||||
path rides along with the next apply/set commit."""
|
||||
path rides along with the next apply/set commit. The rest of a dirty
|
||||
tree (hand edits, lock bumps) is swept by nomarchy-lifecycle's
|
||||
nomarchy-autocommit right before a pull/rebuild/home switch — same
|
||||
flag, honest message — so it never rides a settings-named commit."""
|
||||
if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None:
|
||||
return
|
||||
git = ["git", "-C", str(FLAKE_DIR)]
|
||||
# Pathspec: preferred name plus any legacy file git still knows — in the
|
||||
# index or in HEAD (a migration's staged `git rm` needs the pathspec to
|
||||
# ride along). Names known to neither MUST be dropped: unlike `git diff`,
|
||||
# `git commit` aborts on a pathspec that matches nothing, which silently
|
||||
# killed every auto-commit once the #107 rename removed the legacy files.
|
||||
def known(name: str) -> bool:
|
||||
return subprocess.run(git + ["ls-files", "--error-unmatch", "--", name],
|
||||
capture_output=True).returncode == 0 \
|
||||
or subprocess.run(git + ["cat-file", "-e", f"HEAD:{name}"],
|
||||
capture_output=True).returncode == 0
|
||||
paths = [STATE_NAME] + [n for n in LEGACY_STATE_NAMES if known(n)]
|
||||
# No-op when the file already matches HEAD (a `set` to the same value).
|
||||
# On a repo with no commits yet this diff errors — then just commit.
|
||||
if subprocess.run(git + ["diff", "--quiet", "HEAD", "--", "theme-state.json"],
|
||||
if subprocess.run(git + ["diff", "--quiet", "HEAD", "--"] + paths,
|
||||
capture_output=True).returncode == 0:
|
||||
return
|
||||
if not subprocess.run(git + ["config", "user.email"],
|
||||
capture_output=True, text=True).stdout.strip():
|
||||
git += ["-c", "user.name=Nomarchy", "-c", "user.email=nomarchy@localhost"]
|
||||
result = subprocess.run(
|
||||
git + ["commit", "--quiet", "-m", message, "--", "theme-state.json"],
|
||||
git + ["commit", "--quiet", "-m", message, "--"] + paths,
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
log(f"auto-committed: {message}")
|
||||
@@ -208,7 +262,7 @@ def validate_state(state) -> tuple:
|
||||
|
||||
if not isinstance(state, dict):
|
||||
err("top level", "must be a JSON object",
|
||||
"re-apply a preset: nomarchy-theme-sync apply boreal")
|
||||
"re-apply a preset: nomarchy-state-sync apply boreal")
|
||||
return errors, warnings
|
||||
|
||||
for k in state:
|
||||
@@ -311,7 +365,7 @@ def cmd_validate(_args) -> None:
|
||||
for w in warnings:
|
||||
print(f" ● {w}")
|
||||
if errors:
|
||||
print(f"nomarchy-theme-sync: {STATE_FILE} has "
|
||||
print(f"nomarchy-state-sync: {STATE_FILE} has "
|
||||
f"{len(errors)} problem(s):", file=sys.stderr)
|
||||
for e in errors:
|
||||
print(f" ✖ {e}", file=sys.stderr)
|
||||
@@ -336,7 +390,7 @@ def check_fonts(state: dict) -> None:
|
||||
families = [f.strip().lower() for f in result.stdout.split(",")]
|
||||
if result.returncode != 0 or name.strip().lower() not in families:
|
||||
fallback = result.stdout.strip() or "unknown"
|
||||
print(f"nomarchy-theme-sync: warning: fonts.{key} '{name}' is not "
|
||||
print(f"nomarchy-state-sync: warning: fonts.{key} '{name}' is not "
|
||||
f"installed — fontconfig will substitute '{fallback}'",
|
||||
file=sys.stderr)
|
||||
notify(f"Font '{name}' is not installed — using '{fallback}'")
|
||||
@@ -366,9 +420,23 @@ def run_switch() -> None:
|
||||
"Rebuild FAILED — scroll up for last lines; run nomarchy-doctor",
|
||||
urgency="critical",
|
||||
)
|
||||
# Live ISO pins only the *default* theme's HM generation. Applying
|
||||
# another preset offline tries to build the world from source (#113).
|
||||
offline_hint = ""
|
||||
try:
|
||||
import socket
|
||||
socket.create_connection(("1.1.1.1", 53), timeout=1.5).close()
|
||||
except OSError:
|
||||
offline_hint = (
|
||||
" Offline? Only the live ISO's already-pinned theme is "
|
||||
"guaranteed without a network — other themes need builds "
|
||||
"or a binary cache. Connect and retry, or apply the default "
|
||||
"theme (boreal)."
|
||||
)
|
||||
die(
|
||||
"rebuild failed (state already written; fix and re-run). "
|
||||
"Diagnose: nomarchy-doctor. Recovery: docs/RECOVERY.md"
|
||||
"Diagnose: nomarchy-doctor. Recovery: docs/RECOVERY.md."
|
||||
+ offline_hint
|
||||
)
|
||||
notify("Changes applied ✓")
|
||||
# Waybar runs from Hyprland's exec-once (not a systemd unit HM would
|
||||
@@ -379,14 +447,23 @@ def run_switch() -> None:
|
||||
# reload_style_on_change while the symlinks flip), so prefer the
|
||||
# restart whenever the supervisor is there to catch it; fall back to
|
||||
# SIGUSR2 for unsupervised/custom bars. No-ops if nothing is running.
|
||||
# The pattern matches both comm names: nixpkgs wraps the binary, so
|
||||
# the process is `.waybar-wrapped` — a bare `pkill -x waybar` matched
|
||||
# nothing, theme switches never touched the bar, and it kept the
|
||||
# gtk.css of whatever theme it was started under (the 2026-07-19
|
||||
# stale-digit-color bug). Keep -x: unanchored `waybar` also matches
|
||||
# the supervisor's own `nomarchy-waybar` comm and would kill it.
|
||||
if shutil.which("pkill"):
|
||||
supervised = subprocess.run(
|
||||
["pgrep", "-f", "nomarchy-waybar"], capture_output=True
|
||||
).returncode == 0
|
||||
if supervised:
|
||||
subprocess.run(["pkill", "-x", "waybar"], check=False)
|
||||
subprocess.run(["pkill", "-x", r"waybar|\.waybar-wrapped"], check=False)
|
||||
else:
|
||||
subprocess.run(["pkill", "--signal", "SIGUSR2", "-x", "waybar"], check=False)
|
||||
subprocess.run(
|
||||
["pkill", "--signal", "SIGUSR2", "-x", r"waybar|\.waybar-wrapped"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
# ─── Theme assets (wallpapers) ────────────────────────────────────────────
|
||||
@@ -466,11 +543,13 @@ def cmd_list(_args) -> None:
|
||||
print("\n".join(names))
|
||||
|
||||
|
||||
def cmd_apply(args) -> None:
|
||||
candidate = Path(args.theme).expanduser()
|
||||
preset_path = candidate if candidate.is_file() else find_preset(args.theme)
|
||||
def apply_named(theme: str, no_switch: bool = False) -> None:
|
||||
"""Merge a preset (name or JSON file) into the state and rebuild. Shared
|
||||
by `apply` and `auto` so the day/night switch is the *same* one engine."""
|
||||
candidate = Path(theme).expanduser()
|
||||
preset_path = candidate if candidate.is_file() else find_preset(theme)
|
||||
if preset_path is None:
|
||||
die(f"unknown theme '{args.theme}' (try `nomarchy-theme-sync list`)")
|
||||
die(f"unknown theme '{theme}' (try `nomarchy-state-sync list`)")
|
||||
|
||||
preset = json.loads(preset_path.read_text())
|
||||
# Merge over current state. Presets carry a full appearance block —
|
||||
@@ -483,14 +562,66 @@ def cmd_apply(args) -> None:
|
||||
state = deep_merge(old, preset)
|
||||
write_state(state)
|
||||
if auto_commit_enabled(old) or auto_commit_enabled(state):
|
||||
auto_commit(f"nomarchy: apply theme {state.get('name', args.theme)}")
|
||||
log(f"theme: {state.get('name', args.theme)}")
|
||||
auto_commit(f"nomarchy: apply theme {state.get('name', theme)}")
|
||||
log(f"theme: {state.get('name', theme)}")
|
||||
check_fonts(state)
|
||||
if not args.no_switch:
|
||||
if not no_switch:
|
||||
run_switch()
|
||||
apply_wallpaper(state)
|
||||
|
||||
|
||||
def cmd_apply(args) -> None:
|
||||
apply_named(args.theme, no_switch=args.no_switch)
|
||||
|
||||
|
||||
def _hhmm_to_min(s: str, default: str) -> int:
|
||||
"""'HH:MM' → minutes since midnight; falls back to `default` on garbage."""
|
||||
for candidate in (s, default):
|
||||
try:
|
||||
h, m = str(candidate).split(":")
|
||||
return int(h) * 60 + int(m)
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_auto(args) -> None:
|
||||
"""Apply the day or night theme for the current local time, per
|
||||
settings.autoTheme = {enable, day, night, sunrise, sunset}. Slice 1 of
|
||||
the auto time-of-day pair (BACKLOG #79): the timer (slice 2) and menu
|
||||
(slice 3) drive this; it can also be run by hand or from a user cron."""
|
||||
state = load_state()
|
||||
at = (state.get("settings") or {}).get("autoTheme") or {}
|
||||
if not at.get("enable"):
|
||||
if not args.which:
|
||||
log("auto-theme: off (enable via settings.autoTheme.enable)")
|
||||
return
|
||||
day, night = at.get("day"), at.get("night")
|
||||
if not day or not night:
|
||||
die("auto-theme needs settings.autoTheme.day and .night (theme slugs)")
|
||||
|
||||
sunrise = _hhmm_to_min(at.get("sunrise", "07:00"), "07:00")
|
||||
sunset = _hhmm_to_min(at.get("sunset", "20:00"), "20:00")
|
||||
now = time.localtime()
|
||||
mins = now.tm_hour * 60 + now.tm_min
|
||||
# Daytime is the span between sunrise and sunset. Also handle a
|
||||
# wrap-around pair (sunrise after sunset → the day window crosses
|
||||
# midnight), so an inverted schedule still switches both ways.
|
||||
if sunrise <= sunset:
|
||||
daytime = sunrise <= mins < sunset
|
||||
else:
|
||||
daytime = mins >= sunrise or mins < sunset
|
||||
target = day if daytime else night
|
||||
|
||||
if args.which:
|
||||
print(target)
|
||||
return
|
||||
if state.get("slug") == target and not args.force:
|
||||
log(f"auto-theme: already on {target} ({'day' if daytime else 'night'})")
|
||||
return
|
||||
apply_named(target, no_switch=args.no_switch)
|
||||
|
||||
|
||||
def cmd_set(args) -> None:
|
||||
state = load_state()
|
||||
try:
|
||||
@@ -531,16 +662,19 @@ def cmd_get(args) -> None:
|
||||
node = node[key]
|
||||
except (KeyError, TypeError):
|
||||
die(f"no such key: {args.path}")
|
||||
# Booleans print JSON-style (true/false, not Python's True/False) so
|
||||
# shell consumers can compare against the same literal they `set`.
|
||||
# Booleans and null print JSON-style (true/false/null, not Python's
|
||||
# True/False/None) so shell consumers can compare against the same
|
||||
# literal they `set`. null matters for genuinely-nullable keys like
|
||||
# settings.greeter.autoLogin, where "None" would silently miss every
|
||||
# `case ... null)` a caller writes.
|
||||
print(json.dumps(node, indent=2)
|
||||
if isinstance(node, (dict, list, bool)) else node)
|
||||
if node is None or isinstance(node, (dict, list, bool)) else node)
|
||||
else:
|
||||
print(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
def cmd_wallpaper(_args) -> None:
|
||||
"""Apply the current wallpaper (session start, post-switch hook)."""
|
||||
"""Apply the current wallpaper (session start, switch, output hotplug)."""
|
||||
apply_wallpaper(load_state(), wait=True)
|
||||
|
||||
|
||||
@@ -567,7 +701,7 @@ def main() -> None:
|
||||
global QUIET
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="nomarchy-theme-sync",
|
||||
prog="nomarchy-state-sync",
|
||||
description="Nomarchy theming — state writer + Home Manager rebuild dispatcher.",
|
||||
)
|
||||
parser.add_argument("--quiet", action="store_true", help="suppress progress output")
|
||||
@@ -590,7 +724,13 @@ def main() -> None:
|
||||
p.add_argument("path", nargs="?")
|
||||
p.set_defaults(func=cmd_get)
|
||||
|
||||
sub.add_parser("validate", help="check theme-state.json against the schema (read-only)").set_defaults(func=cmd_validate)
|
||||
p = sub.add_parser("auto", help="apply the day/night theme for the current time (settings.autoTheme)")
|
||||
p.add_argument("--which", action="store_true", help="print the theme that WOULD apply, don't switch")
|
||||
p.add_argument("--force", action="store_true", help="apply even if already on the target theme")
|
||||
p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild")
|
||||
p.set_defaults(func=cmd_auto)
|
||||
|
||||
sub.add_parser("validate", help="check state.json against the schema (read-only)").set_defaults(func=cmd_validate)
|
||||
|
||||
sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper)
|
||||
|
||||
65
pkgs/nomarchy-suspend/default.nix
Normal file
65
pkgs/nomarchy-suspend/default.nix
Normal file
@@ -0,0 +1,65 @@
|
||||
{ lib
|
||||
, writeShellScriptBin
|
||||
, coreutils
|
||||
, gnugrep
|
||||
, jq
|
||||
, systemd
|
||||
}:
|
||||
|
||||
# Smart suspend (#115): on battery, with hibernate available and the
|
||||
# in-flake toggle on, enter suspend-then-hibernate (1h → hibernate via
|
||||
# systemd.sleep HibernateDelaySec). Otherwise plain suspend.
|
||||
#
|
||||
# Reads the *live* state.json (menu may have flipped before rebuild);
|
||||
# logind lid policy still needs a system rebuild to match (power.nix).
|
||||
writeShellScriptBin "nomarchy-suspend" ''
|
||||
set -euo pipefail
|
||||
PATH=${lib.makeBinPath [ coreutils gnugrep jq systemd ]}:$PATH
|
||||
|
||||
on_ac() {
|
||||
local f
|
||||
for f in /sys/class/power_supply/*/online; do
|
||||
[ -r "$f" ] && [ "$(cat "$f")" = "1" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
can_hibernate() {
|
||||
# Prefer logind's live answer (swap + resume wiring).
|
||||
local v
|
||||
v=$(busctl get-property org.freedesktop.login1 \
|
||||
/org/freedesktop/login1 org.freedesktop.login1.Manager \
|
||||
CanHibernate 2>/dev/null || true)
|
||||
case "$v" in
|
||||
"s \"yes\"") return 0 ;;
|
||||
esac
|
||||
# Offline / early-boot fallback: resume= on the kernel cmdline.
|
||||
grep -q '[[:space:]]resume=' /proc/cmdline 2>/dev/null \
|
||||
|| grep -q '^resume=' /proc/cmdline 2>/dev/null
|
||||
}
|
||||
|
||||
want_s2h() {
|
||||
# Default on (absent key / no state file) — bag-carry drains less without
|
||||
# a Preferences trip; menu writes false to opt out.
|
||||
local st v="" found=0
|
||||
for st in \
|
||||
"''${NOMARCHY_PATH:-$HOME/.nomarchy}/state.json" \
|
||||
/home/*/.nomarchy/state.json
|
||||
do
|
||||
[ -r "$st" ] || continue
|
||||
found=1
|
||||
v=$(jq -r '.settings.power.suspendThenHibernate // true' "$st" 2>/dev/null || true)
|
||||
break
|
||||
done
|
||||
[ "$found" -eq 0 ] && return 0
|
||||
case "$v" in
|
||||
false|False|0) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if want_s2h && ! on_ac && can_hibernate; then
|
||||
exec systemctl suspend-then-hibernate
|
||||
fi
|
||||
exec systemctl suspend
|
||||
''
|
||||
@@ -1,49 +0,0 @@
|
||||
{ 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.
|
||||
, themesDir ? null
|
||||
}:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "nomarchy-theme-sync";
|
||||
version = "0.4.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
buildInputs = [ python3 ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 nomarchy-theme-sync.py $out/bin/nomarchy-theme-sync
|
||||
patchShebangs $out/bin/nomarchy-theme-sync
|
||||
|
||||
${lib.optionalString (themesDir != null) ''
|
||||
mkdir -p $out/share/nomarchy
|
||||
cp -r ${themesDir} $out/share/nomarchy/themes
|
||||
''}
|
||||
|
||||
# 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-theme-sync \
|
||||
--prefix PATH : ${lib.makeBinPath [ awww libnotify git ]} \
|
||||
${lib.optionalString (themesDir != null)
|
||||
"--set NOMARCHY_DEFAULT_THEMES $out/share/nomarchy/themes"}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Nomarchy theming: JSON state writer + Home Manager rebuild dispatcher";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "nomarchy-theme-sync";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
9
pkgs/nomarchy-what-changed/default.nix
Normal file
9
pkgs/nomarchy-what-changed/default.nix
Normal file
@@ -0,0 +1,9 @@
|
||||
# Plain-language generation diff (BACKLOG #82). Wraps nvd; PATH-resolved
|
||||
# notify is left to callers. NOMARCHY_NVD lets checks inject a fixture.
|
||||
{ writeShellApplication, coreutils, nvd, gnugrep, gnused }:
|
||||
|
||||
writeShellApplication {
|
||||
name = "nomarchy-what-changed";
|
||||
runtimeInputs = [ coreutils nvd gnugrep gnused ];
|
||||
text = builtins.readFile ./nomarchy-what-changed.sh;
|
||||
}
|
||||
178
pkgs/nomarchy-what-changed/nomarchy-what-changed.sh
Normal file
178
pkgs/nomarchy-what-changed/nomarchy-what-changed.sh
Normal file
@@ -0,0 +1,178 @@
|
||||
# nomarchy-what-changed — plain-language "what changed last rebuild".
|
||||
# Wraps nvd into a short summary (for toasts / menu) and optional full
|
||||
# report. In-checkout state is not involved; this only reads Nix profiles.
|
||||
#
|
||||
# usage:
|
||||
# nomarchy-what-changed # system + home (last two gens each)
|
||||
# nomarchy-what-changed system|home # one layer
|
||||
# nomarchy-what-changed --summary … # one line per layer (toast body)
|
||||
# nomarchy-what-changed --diff PATH1 PATH2
|
||||
# nomarchy-what-changed --summary --diff PATH1 PATH2
|
||||
#
|
||||
# NOMARCHY_NVD overrides the nvd binary (tests inject a fixture).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
nvd_bin="${NOMARCHY_NVD:-nvd}"
|
||||
summary_only=0
|
||||
mode="" # empty | system | home | diff
|
||||
diff_a="" diff_b=""
|
||||
|
||||
usage() {
|
||||
echo "usage: nomarchy-what-changed [--summary] [system|home]" >&2
|
||||
echo " nomarchy-what-changed [--summary] --diff <before> <after>" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--summary) summary_only=1; shift ;;
|
||||
--diff)
|
||||
mode="diff"
|
||||
shift
|
||||
[ $# -ge 2 ] || usage
|
||||
diff_a=$1; diff_b=$2; shift 2
|
||||
;;
|
||||
system|home) mode="$1"; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# One-line plain-language summary of an nvd --color never report.
|
||||
# Prefer package row counts ([A.]/[R.]/[C.]); fall back to closure path delta.
|
||||
summarize_report() {
|
||||
local label=$1 report=$2
|
||||
local added removed changed
|
||||
if printf '%s\n' "$report" | grep -q 'No version or selection state changes'; then
|
||||
printf '%s: no package changes\n' "$label"
|
||||
return
|
||||
fi
|
||||
# Count package rows (nvd tags: A added, R removed, C version/selection change).
|
||||
added=$(printf '%s\n' "$report" | grep -c '\[A\.' || true)
|
||||
removed=$(printf '%s\n' "$report" | grep -c '\[R\.' || true)
|
||||
changed=$(printf '%s\n' "$report" | grep -c '\[C\.' || true)
|
||||
# Strip trailing newline noise from grep -c on empty (already || true → 0).
|
||||
added=${added:-0}; removed=${removed:-0}; changed=${changed:-0}
|
||||
|
||||
if [ "$added" -eq 0 ] && [ "$removed" -eq 0 ] && [ "$changed" -eq 0 ]; then
|
||||
# Closure-only deltas (sources, etc. without package names).
|
||||
local paths_a paths_r
|
||||
paths_a=$(printf '%s\n' "$report" \
|
||||
| sed -n 's/.*(\([0-9]*\) paths added,.*/\1/p' | head -1)
|
||||
paths_r=$(printf '%s\n' "$report" \
|
||||
| sed -n 's/.*paths added, \([0-9]*\) paths removed.*/\1/p' | head -1)
|
||||
paths_a=${paths_a:-0}; paths_r=${paths_r:-0}
|
||||
if [ "$paths_a" -eq 0 ] && [ "$paths_r" -eq 0 ]; then
|
||||
printf '%s: no package changes\n' "$label"
|
||||
else
|
||||
printf '%s: %s paths added, %s removed\n' "$label" "$paths_a" "$paths_r"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
local parts=()
|
||||
[ "$added" -gt 0 ] && parts+=("$added added")
|
||||
[ "$removed" -gt 0 ] && parts+=("$removed removed")
|
||||
[ "$changed" -gt 0 ] && parts+=("$changed updated")
|
||||
local joined
|
||||
joined=$(printf '%s, ' "${parts[@]}")
|
||||
joined=${joined%, }
|
||||
printf '%s: %s\n' "$label" "$joined"
|
||||
}
|
||||
|
||||
run_diff() {
|
||||
local before=$1 after=$2 label=$3
|
||||
if [ ! -e "$before" ] || [ ! -e "$after" ]; then
|
||||
printf '%s: no previous generation to compare\n' "$label"
|
||||
return 0
|
||||
fi
|
||||
if [ "$(readlink -f "$before" 2>/dev/null || echo "$before")" \
|
||||
= "$(readlink -f "$after" 2>/dev/null || echo "$after")" ]; then
|
||||
printf '%s: no package changes\n' "$label"
|
||||
return 0
|
||||
fi
|
||||
local report
|
||||
report=$("$nvd_bin" --color never diff "$before" "$after" 2>/dev/null) || {
|
||||
printf '%s: could not diff (is nvd installed?)\n' "$label"
|
||||
return 0
|
||||
}
|
||||
if [ "$summary_only" -eq 1 ]; then
|
||||
summarize_report "$label" "$report"
|
||||
else
|
||||
printf '── %s ──\n' "$label"
|
||||
printf '%s\n' "$report"
|
||||
echo
|
||||
summarize_report "$label" "$report"
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve last two links for a profile stem (…/system or …/home-manager).
|
||||
last_two() {
|
||||
local stem=$1
|
||||
# shellcheck disable=SC2012
|
||||
ls -d "${stem}"-[0-9]*-link 2>/dev/null | sort -V | tail -n 2
|
||||
}
|
||||
|
||||
system_pair() {
|
||||
last_two /nix/var/nix/profiles/system
|
||||
}
|
||||
|
||||
home_pair() {
|
||||
local d
|
||||
d="${XDG_STATE_HOME:-$HOME/.local/state}/nix/profiles"
|
||||
if ls -d "$d"/home-manager-[0-9]*-link >/dev/null 2>&1; then
|
||||
last_two "$d/home-manager"
|
||||
return
|
||||
fi
|
||||
d="/nix/var/nix/profiles/per-user/${USER:-$(id -un)}"
|
||||
if ls -d "$d"/home-manager-[0-9]*-link >/dev/null 2>&1; then
|
||||
last_two "$d/home-manager"
|
||||
return
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
pair_to_before_after() {
|
||||
# stdin: 0–2 paths; sets before/after (before may be empty).
|
||||
local -a links=()
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && links+=("$line")
|
||||
done
|
||||
before=""; after=""
|
||||
if [ "${#links[@]}" -ge 2 ]; then
|
||||
before=$(readlink -f "${links[0]}")
|
||||
after=$(readlink -f "${links[1]}")
|
||||
elif [ "${#links[@]}" -eq 1 ]; then
|
||||
after=$(readlink -f "${links[0]}")
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$mode" = diff ]; then
|
||||
run_diff "$diff_a" "$diff_b" "Diff"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$mode" ] || [ "$mode" = system ]; then
|
||||
before=""; after=""
|
||||
pair_to_before_after < <(system_pair)
|
||||
if [ -n "$after" ] && [ -n "$before" ]; then
|
||||
run_diff "$before" "$after" "System"
|
||||
elif [ -n "$after" ]; then
|
||||
printf 'System: only one generation on disk — rebuild once more to compare\n'
|
||||
else
|
||||
printf 'System: no NixOS profile found\n'
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$mode" ] || [ "$mode" = home ]; then
|
||||
before=""; after=""
|
||||
pair_to_before_after < <(home_pair)
|
||||
if [ -n "$after" ] && [ -n "$before" ]; then
|
||||
run_diff "$before" "$after" "Desktop"
|
||||
elif [ -n "$after" ]; then
|
||||
printf 'Desktop: only one generation on disk — switch once more to compare\n'
|
||||
else
|
||||
printf 'Desktop: no Home Manager profile found\n'
|
||||
fi
|
||||
fi
|
||||
50
pkgs/pam-fprint-grosshack/default.nix
Normal file
50
pkgs/pam-fprint-grosshack/default.nix
Normal file
@@ -0,0 +1,50 @@
|
||||
# pam_fprintd_grosshack.so — fprintd's PAM module forked to accept the
|
||||
# password OR a fingerprint at the same prompt (upstream calls itself a
|
||||
# "gross hack"; it is also the field-standard answer to this PAM gap —
|
||||
# stock PAM cannot express parallel factors, linux-pam#301).
|
||||
#
|
||||
# Source reviewed before packaging (2026-07-12, v0.3.0 = fprintd 1.94.2
|
||||
# base + ~60-line delta): every failure path — no reader, no enrolled
|
||||
# prints, fprintd absent, timeout, password typed — returns
|
||||
# PAM_AUTHINFO_UNAVAIL, i.e. the module FAILS and PAM falls through to
|
||||
# the password rule; a typed password is never validated here, only
|
||||
# ferried via PAM_AUTHTOK to pam_unix (try_first_pass). Accepted
|
||||
# quirks: installs a process-wide SIGUSR1 handler in the calling
|
||||
# process, pthread_cancels the prompt thread on fingerprint win, and
|
||||
# does not zeroize the prompt buffer. Consumed by
|
||||
# modules/nixos/hardware.nix (nomarchy.hardware.fingerprint.parallel).
|
||||
{ lib, stdenv, fetchFromGitLab, meson, ninja, pkg-config, gettext, python3
|
||||
, glib, libfprint, polkit, pam, systemd, dbus, libpam-wrapper }:
|
||||
|
||||
stdenv.mkDerivation rec {
|
||||
pname = "pam-fprint-grosshack";
|
||||
version = "0.3.0";
|
||||
|
||||
src = fetchFromGitLab {
|
||||
owner = "mishakmak";
|
||||
repo = "pam-fprint-grosshack";
|
||||
rev = "v${version}";
|
||||
hash = "sha256-obczZbf/oH4xGaVvp3y3ZyDdYhZnxlCWvL0irgEYIi0=";
|
||||
};
|
||||
|
||||
# Only the PAM module is built (the fork disables every other fprintd
|
||||
# subdir), but the top-level meson still resolves the full fprintd
|
||||
# dependency set — hence libfprint/polkit in buildInputs.
|
||||
# pam_wrapper is only exercised by the (disabled) test suite, but the
|
||||
# top-level meson marks it required whenever the pam option is on.
|
||||
nativeBuildInputs = [ meson ninja pkg-config gettext python3 ];
|
||||
buildInputs = [ glib libfprint polkit pam systemd dbus libpam-wrapper ];
|
||||
|
||||
mesonFlags = [
|
||||
"-Dpam_modules_dir=${placeholder "out"}/lib/security"
|
||||
"-Dsystemd=false"
|
||||
"-Dman=false"
|
||||
];
|
||||
|
||||
meta = {
|
||||
description = "PAM module accepting password or fingerprint in parallel (fprintd fork)";
|
||||
homepage = "https://gitlab.com/mishakmak/pam-fprint-grosshack";
|
||||
license = lib.licenses.gpl2Plus;
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
@@ -15,19 +15,19 @@ keyboard, detected hardware, password hash, …). Keep commented opt-ins and
|
||||
services — the login user is created from `username` automatically) and
|
||||
`home.nix` (your packages).
|
||||
3. `git init && git add -A` — flakes only see tracked files, including
|
||||
`theme-state.json`.
|
||||
`state.json`.
|
||||
4. System: `sudo nixos-rebuild switch --flake .#default`
|
||||
5. Desktop: `nix run home-manager -- switch --flake .#me`
|
||||
(afterwards just `home-manager switch --flake .#me` — it's installed)
|
||||
6. Clone/symlink this directory to `~/.nomarchy` (or export
|
||||
`NOMARCHY_PATH`) so `nomarchy-theme-sync` knows where the state lives.
|
||||
`NOMARCHY_PATH`) so `nomarchy-state-sync` knows where the state lives.
|
||||
|
||||
Day-to-day:
|
||||
|
||||
```sh
|
||||
nomarchy-theme-sync list # 24 shipped presets
|
||||
nomarchy-theme-sync apply gruvbox # writes state + home-manager switch
|
||||
nomarchy-theme-sync bg next # cycle wallpapers (instant, no rebuild)
|
||||
nomarchy-state-sync list # 24 shipped presets
|
||||
nomarchy-state-sync apply gruvbox # writes state + home-manager switch
|
||||
nomarchy-state-sync bg next # cycle wallpapers (instant, no rebuild)
|
||||
```
|
||||
|
||||
The system layer only needs `nixos-rebuild` when you change `system.nix`.
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
# The only input. nixpkgs, home-manager etc. come pinned through it —
|
||||
# tested together upstream. This file is written once (by you or the
|
||||
# installer) and never hand-edited afterwards; your machine lives in
|
||||
# system.nix and home.nix. v1 is the release branch.
|
||||
# Installer copies this template and patches username + hardwareProfile.
|
||||
inputs.nomarchy.url = "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1";
|
||||
# system.nix and home.nix.
|
||||
# Development tip is `main`. Release consumers pin `?ref=v1` once that
|
||||
# pointer is current (installer overwrites this URL from the ISO build).
|
||||
# Installer copies this template and patches username + hardwareProfile
|
||||
# + the nomarchy input URL.
|
||||
inputs.nomarchy.url = "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=main";
|
||||
|
||||
outputs = { nomarchy, ... }:
|
||||
nomarchy.lib.mkFlake {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user