Compare commits
188 Commits
53f75f6e1b
...
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 |
@@ -1,11 +1,12 @@
|
|||||||
# Nomarchy scheduled lock bump — the automated half of "rock-stable
|
# 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
|
# 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
|
# deliberate hand-edited v2, see agent/GOALS.md), gate it, and land it
|
||||||
# on main only on green. `v1` promotion stays human, always.
|
# on main only on green. `v1` promotion stays human, always.
|
||||||
#
|
#
|
||||||
# Fast lane: workflow_dispatch. For a security fix upstream, run this
|
# 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
|
# Gate scope: `nix flake check --no-build` (eval tier) followed by a V1 build
|
||||||
# (home-manager activation package + nixos toplevel) to catch compilation errors.
|
# (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;
|
# Failure mode: a red gate fails this run visibly and pushes nothing;
|
||||||
# next schedule retries. A push race (someone landed on main mid-run)
|
# 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
|
# Container recipe (nixbld users, sandbox=false, pinned Nix, plain-shell
|
||||||
# install) inherited from check.yml — the gotchas are documented there.
|
# install) inherited from check.yml — the gotchas are documented there.
|
||||||
@@ -27,7 +28,7 @@ name: Lock bump
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '17 5 * * 1' # Mondays 05:17 UTC
|
- cron: '17 5 * * *' # daily 05:17 UTC
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -36,6 +37,8 @@ jobs:
|
|||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
NIX_CONFIG: |
|
NIX_CONFIG: |
|
||||||
|
max-jobs = 1
|
||||||
|
cores = 2
|
||||||
experimental-features = nix-command flakes
|
experimental-features = nix-command flakes
|
||||||
sandbox = false
|
sandbox = false
|
||||||
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||||
@@ -68,9 +71,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
id: update
|
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'
|
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)
|
- name: Gate — V1 build (toplevel + home-manager)
|
||||||
if: steps.update.outputs.changed == '1'
|
if: steps.update.outputs.changed == '1'
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
NIX_CONFIG: |
|
NIX_CONFIG: |
|
||||||
|
max-jobs = 1
|
||||||
|
cores = 1
|
||||||
experimental-features = nix-command flakes
|
experimental-features = nix-command flakes
|
||||||
sandbox = false
|
sandbox = false
|
||||||
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
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
|
curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon
|
||||||
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: nix flake check (eval only)
|
- name: Evaluate every output (eval tier, memory-bounded)
|
||||||
# Full module-system evaluation of every output — both nixos
|
# Same coverage as `nix flake check --no-build` — both nixos
|
||||||
# configs, the home config, the checks.* derivations (instantiated,
|
# configs, the home config, the checks.* derivations (instantiated,
|
||||||
# not run) and the downstream template through lib.mkFlake.
|
# not run) and the downstream template through lib.mkFlake — but
|
||||||
run: nix flake check --no-build
|
# 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)
|
- name: Python syntax (all tracked scripts)
|
||||||
# Every tracked *.py — theme-sync, the installer composers
|
# Every tracked *.py — theme-sync, the installer composers
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,5 +5,9 @@ result-*
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
.DS_Store
|
.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 Code machine-local settings (permissions etc.)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.claude/settings.local.json.tmp.*
|
||||||
|
|||||||
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.
|
||||||
140
README.md
140
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 │
|
│ 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)
|
1. merges the preset into the JSON (atomic write)
|
||||||
2. runs `home-manager switch` (no sudo, no system rebuild)
|
2. runs `home-manager switch` (no sudo, no system rebuild)
|
||||||
│
|
│
|
||||||
@@ -19,7 +19,7 @@ partial.
|
|||||||
┌──────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
│ Home Manager bakes EVERYTHING into one read-only generation: │
|
│ Home Manager bakes EVERYTHING into one read-only generation: │
|
||||||
│ Hyprland (colors/gaps/borders) Waybar (palette or whole-swap) │
|
│ 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 │
|
│ 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)
|
├── flake.nix # inputs + the downstream API (exports below)
|
||||||
├── lib.nix # nomarchy.lib.mkFlake — one-call downstream wrapper
|
├── lib.nix # nomarchy.lib.mkFlake — one-call downstream wrapper
|
||||||
├── theme-state.json # ★ THE single source of truth (git-tracked!)
|
├── state.json # ★ THE single source of truth (git-tracked!)
|
||||||
├── themes/ # 24 presets: <slug>.json + optional <slug>/ assets
|
├── themes/ # 28 presets: <slug>.json + optional <slug>/ assets
|
||||||
│ ├── nord.json # palette (required, works alone)
|
│ ├── nord.json # palette (required, works alone)
|
||||||
│ └── nord/ # assets (optional, fixed filenames)
|
│ └── 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)
|
│ ├── btop.theme # hand-made config drop (else generated)
|
||||||
│ └── waybar.css # whole-swap: replaces the generated bar style
|
│ └── waybar.css # whole-swap: replaces the generated bar style
|
||||||
├── modules/
|
├── 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
|
│ ├── stylix.nix # GTK/Qt/cursors/fonts from the same JSON
|
||||||
│ ├── hyprland.nix # all JSON-driven
|
│ ├── hyprland.nix # all JSON-driven
|
||||||
│ ├── waybar.nix
|
│ ├── waybar.nix
|
||||||
│ ├── ghostty.nix
|
│ ├── kitty.nix
|
||||||
│ ├── btop.nix
|
│ ├── btop.nix
|
||||||
│ ├── rofi.nix # launcher + nomarchy-menu (calc, emoji, clip…)
|
│ ├── rofi.nix # launcher + nomarchy-menu (calc, emoji, clip…)
|
||||||
│ ├── keybinds.nix # single source: Hyprland binds + SUPER+? sheet
|
│ ├── 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)
|
│ ├── default/ # reference machine (thin: boot, user, hostname)
|
||||||
│ └── live.nix # bootable live ISO (try the distro, no install)
|
│ └── live.nix # bootable live ISO (try the distro, no install)
|
||||||
├── pkgs/
|
├── 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-install/ # live-ISO installer (gum + disko + mkFlake)
|
||||||
│ ├── nomarchy-doctor/ # read-only health sheet, one command per failure
|
│ ├── 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
|
│ └── nomarchy-battery-notify/ # low-battery toast watcher backing battery-notify.nix
|
||||||
├── templates/downstream/ # machine flake SoT (`flake init` + installer copy/patch)
|
├── templates/downstream/ # machine flake SoT (`flake init` + installer copy/patch)
|
||||||
├── docs/ # human docs — map: docs/README.md
|
├── docs/ # human docs — map: docs/README.md
|
||||||
@@ -102,11 +101,15 @@ probably shouldn't exist.
|
|||||||
|
|
||||||
## 2. Try it first (live ISO)
|
## 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:
|
Boot the full desktop from a USB stick or VM without installing anything:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage
|
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
|
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
|
`home-manager switch` it triggers) works **offline**, exactly like on an
|
||||||
installed system. Verification checklist: [docs/TESTING.md](docs/TESTING.md).
|
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
|
Like what you see? **`nomarchy-install`** (in a terminal) walks you through
|
||||||
installing to disk: pick a disk, LUKS2 full-disk encryption **by default**
|
installing to disk: pick a disk, LUKS2 full-disk encryption **by default**
|
||||||
(in exchange the desktop logs in passwordless — the passphrase already
|
(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)**.
|
**[docs/MIGRATION.md](docs/MIGRATION.md)**.
|
||||||
|
|
||||||
You own two files day-to-day: `system.nix` and `home.nix` (plus
|
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:
|
later by the installer — and never hand-edited; it's a single call:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
@@ -187,12 +197,17 @@ nomarchy-pull # nix flake update — refresh inputs (nomarchy, nixpkgs, …
|
|||||||
nomarchy-rebuild # system rebuild against the CURRENT lock (sudo inside;
|
nomarchy-rebuild # system rebuild against the CURRENT lock (sudo inside;
|
||||||
# BTRFS snapshot first when snapper is on; nvd diff after)
|
# BTRFS snapshot first when snapper is on; nvd diff after)
|
||||||
nomarchy-home # home-manager switch — desktop only, no sudo, no lock bump
|
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)
|
||||||
```
|
```
|
||||||
|
|
||||||
`~/.nomarchy` is **your machine flake** (`system.nix` / `home.nix`). Newer
|
`~/.nomarchy` is **your machine flake** (`system.nix` / `home.nix`). Newer
|
||||||
Nomarchy code arrives when `flake.lock` updates the `nomarchy` input
|
Nomarchy code arrives when `flake.lock` updates the `nomarchy` input
|
||||||
(`nomarchy-pull`), not by `git pull` of that directory (many installs have
|
(`nomarchy-pull`), not by `git pull` of that directory (many installs have
|
||||||
no remote there — only local commits from auto-commit).
|
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 |
|
| When | Run |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -213,7 +228,7 @@ Legacy aliases still work: `sys-update` → pull+rebuild, `sys-rebuild` →
|
|||||||
`nomarchy-rebuild`, `home-update` → `nomarchy-home`.
|
`nomarchy-rebuild`, `home-update` → `nomarchy-home`.
|
||||||
|
|
||||||
Override anything via the `nomarchy.*` surface or plain NixOS/HM options:
|
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`
|
behaviour (input/misc/monitor/chrome) is `mkDefault` so a plain `home.nix`
|
||||||
assignment wins, and bind/exec-once lists concatenate. Full guide with
|
assignment wins, and bind/exec-once lists concatenate. Full guide with
|
||||||
examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
|
examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
|
||||||
@@ -227,12 +242,13 @@ two tables below are split along exactly that line.
|
|||||||
|
|
||||||
| Option | Default | Purpose |
|
| Option | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `nomarchy.stateFile` | — (required) | Path to your theme-state.json |
|
| `nomarchy.stateFile` | — (required) | Path to your state.json |
|
||||||
| `nomarchy.terminal` | `"ghostty"` | Terminal for keybinds and `$TERMINAL` |
|
| `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.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.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.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.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.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.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.hyprland.enable` | `true` | Nomarchy's Hyprland config |
|
||||||
@@ -240,11 +256,13 @@ two tables below are split along exactly that line.
|
|||||||
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
|
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
|
||||||
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
|
| `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.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.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
|
||||||
| `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute |
|
| `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.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.btop.enable` | `true` | btop with per-theme colors |
|
||||||
| `nomarchy.stylix.enable` | `true` | GTK/Qt/cursor theming |
|
| `nomarchy.stylix.enable` | `true` | GTK/Qt/cursor theming |
|
||||||
| `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo |
|
| `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo |
|
||||||
@@ -253,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.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.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.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.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.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`
|
**Always-on, no toggle by design:** `services.cliphist`, `services.udiskie`
|
||||||
(automount + safe-removal toasts) and `services.easyeffects` (mic noise
|
(automount + safe-removal toasts) and `services.easyeffects` (mic noise
|
||||||
@@ -271,25 +298,27 @@ option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
|
|||||||
| Option | Default | Purpose |
|
| Option | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `nomarchy.system.plymouth.enable` | `true` | Branded boot splash, background from the theme JSON (recolors on system rebuilds) |
|
| `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.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) |
|
||||||
| `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet |
|
| `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.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 |
|
| `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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.latestKernel` | `false` | Opt-in: ship `linuxPackages_latest` instead of the default kernel — for very new hardware whose drivers landed recently |
|
||||||
| `nomarchy.hardware.camera.hideIrSensor` | `false` | Hide a dual-sensor webcam's IR node from PipeWire's **v4l2** path so apps only ever see the colour camera (the "second, dark Integrated Camera"); installer-set on a paired RGB+IR webcam. `/dev/video*` stays open, so Howdy-style face unlock still works; `.irMatch` overrides the IR-name regex. Does **not** hide IR from libcamera / portal / Flatpak pickers — see [HARDWARE.md §7](docs/HARDWARE.md) |
|
| `nomarchy.hardware.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) |
|
||||||
@@ -347,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
|
cannot read arbitrary `$HOME` paths without `--impure` (the old prototype
|
||||||
required it — never again). Nomarchy's convention: **the state file lives
|
required it — never again). Nomarchy's convention: **the state file lives
|
||||||
inside the consuming flake** and is wired via
|
inside the consuming flake** and is wired via
|
||||||
`nomarchy.stateFile = ./theme-state.json;`. Reading it is pure — it's flake
|
`nomarchy.stateFile = ./state.json;`. Reading it is pure — it's flake
|
||||||
source. It must be git-tracked (`nomarchy-theme-sync` runs
|
source. It must be git-tracked (`nomarchy-state-sync` runs
|
||||||
`git add --intent-to-add` after every write as a safety net).
|
`git add --intent-to-add` after every write as a safety net).
|
||||||
|
|
||||||
### One change = one generation
|
### 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
|
`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
|
btop, and — via Stylix, mapped onto base16 roles — GTK, Qt, cursors and
|
||||||
fonts. No runtime patching means no partial states, and `home-manager
|
fonts. No runtime patching means no partial states, and `home-manager
|
||||||
generations` is also your theme history. Waybar even restyles in place: it
|
generations` is also your theme history. Waybar even restyles in place: it
|
||||||
re-reads `style.css` when the symlink flips.
|
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
|
The **wallpaper** is the one runtime piece (awww — nixpkgs' swww — is
|
||||||
imperative; nothing in Nix consumes the path): applied at session start and
|
imperative; nothing in Nix consumes the path): applied at session start,
|
||||||
after every switch via a tiny activation hook, cycled instantly with
|
after every switch via a tiny activation hook, and again when a monitor is
|
||||||
`bg next`.
|
hotplugged; cycled instantly with `bg next`.
|
||||||
|
|
||||||
### Config the menu writes (not just themes)
|
### Config the menu writes (not just themes)
|
||||||
|
|
||||||
@@ -384,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.
|
instant on/off). Expect more `nomarchy.*` toggles to migrate to this pattern.
|
||||||
|
|
||||||
**Auto-commit (opt-in):** System › Auto-commit makes every `apply`/`set`
|
**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
|
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
|
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
|
Nix consumes the flag), the off-write is itself committed so history stays
|
||||||
@@ -398,26 +434,29 @@ per theme — a single place to look, unlike the old distro's split:
|
|||||||
|
|
||||||
| Asset | Mechanism |
|
| 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) |
|
| `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.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) |
|
| `waybar.jsonc` | whole-swap for the bar *layout* (must be plain JSON) |
|
||||||
| `rofi.rasi` | **whole-swap**: replaces the generated launcher/menu theme entirely |
|
| `rofi.rasi` | **whole-swap**: replaces the generated launcher/menu theme entirely |
|
||||||
|
|
||||||
Four themes ship a `waybar.css` identity (summer-day, summer-night,
|
Eight themes ship a `waybar.css` identity: the three Nomarchy-exclusive
|
||||||
executive-slate, boreal). Custom user themes can live in
|
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
|
`$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
|
## 5. Day-to-day
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nomarchy-theme-sync list # 24 presets (nord, gruvbox, rose-pine, …)
|
nomarchy-state-sync list # 28 presets (nord, gruvbox, rose-pine, …)
|
||||||
nomarchy-theme-sync apply kanagawa # whole desktop, one generation (~a switch)
|
nomarchy-state-sync apply kanagawa # whole desktop, one generation (~a switch)
|
||||||
nomarchy-theme-sync set ui.gapsOut 16 # tweak one knob (also a switch)
|
nomarchy-state-sync set ui.gapsOut 16 # tweak one knob (also a switch)
|
||||||
nomarchy-theme-sync bg next # cycle wallpapers — instant, no rebuild
|
nomarchy-state-sync bg next # cycle wallpapers — instant, no rebuild
|
||||||
nomarchy-theme-sync bg auto # back to the theme's default wallpaper
|
nomarchy-state-sync bg auto # back to the theme's default wallpaper
|
||||||
nomarchy-theme-sync get colors.accent
|
nomarchy-state-sync get colors.accent
|
||||||
nomarchy-pull # flake input update (no rebuild)
|
nomarchy-pull # flake input update (no rebuild)
|
||||||
nomarchy-rebuild # rebuild the system, current lock
|
nomarchy-rebuild # rebuild the system, current lock
|
||||||
nomarchy-home # rebuild just the desktop layer
|
nomarchy-home # rebuild just the desktop layer
|
||||||
@@ -453,7 +492,7 @@ gp gpl gf # push · pull · fetch --all --prune
|
|||||||
# nix (lifecycle: nomarchy-pull / nomarchy-rebuild / nomarchy-home — full names)
|
# nix (lifecycle: nomarchy-pull / nomarchy-rebuild / nomarchy-home — full names)
|
||||||
ns nr # nix shell · nix run (e.g. ns nixpkgs#ripgrep)
|
ns nr # nix shell · nix run (e.g. ns nixpkgs#ripgrep)
|
||||||
nfu nfc # nix flake update · check (prefer nomarchy-pull for day-to-day)
|
nfu nfc # nix flake update · check (prefer nomarchy-pull for day-to-day)
|
||||||
nsearch ngc # nix search nixpkgs · nix-collect-garbage -d
|
nsearch ngc # nix search · nix-collect-garbage -d (prefer: sudo nomarchy-gen-prune)
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
path # print $PATH, one entry per line
|
path # print $PATH, one entry per line
|
||||||
@@ -464,7 +503,7 @@ reload # exec zsh (reload the shell)
|
|||||||
|
|
||||||
- **New theme:** drop a JSON into `themes/` (schema = any existing preset),
|
- **New theme:** drop a JSON into `themes/` (schema = any existing preset),
|
||||||
plus an optional `themes/<slug>/` assets directory.
|
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.
|
the Nix modules. One place — there is no second renderer to keep in sync.
|
||||||
- **Importing more old-distro palettes:**
|
- **Importing more old-distro palettes:**
|
||||||
`tools/import-palettes.py <palettes-dir> themes/` (roles are first-class:
|
`tools/import-palettes.py <palettes-dir> themes/` (roles are first-class:
|
||||||
@@ -483,6 +522,17 @@ reload # exec zsh (reload the shell)
|
|||||||
line to slim the machine, uncomment an extra (a browser, email, full TeX
|
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
|
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.
|
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
|
## Roadmap & known issues
|
||||||
|
|
||||||
@@ -495,3 +545,5 @@ reload # exec zsh (reload the shell)
|
|||||||
| **[agent/LOOP.md](agent/LOOP.md)** | Autonomous iteration protocol |
|
| **[agent/LOOP.md](agent/LOOP.md)** | Autonomous iteration protocol |
|
||||||
|
|
||||||
Kept out of the README body so this stays a focused entry point.
|
Kept out of the README body so this stays a focused entry point.
|
||||||
|
|
||||||
|
License: [MIT](LICENSE).
|
||||||
|
|||||||
279
agent/BACKLOG.md
279
agent/BACKLOG.md
@@ -24,51 +24,59 @@ in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in
|
|||||||
|
|
||||||
## NOW
|
## 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
|
## NEXT
|
||||||
|
|
||||||
### 76. Hibernation default — all agent work shipped `[blocked:hw]`
|
### 151. `[human]` v1 launch plan — devise with Bernardo (includes the GitHub move)
|
||||||
Hibernation + zram on by default (product intent, Bernardo 2026-07-10).
|
|
||||||
**All V0/V1/V2 slices done** (2026-07-10); only the V3 check remains — on a
|
|
||||||
machine **actually running Nomarchy** (the dev box runs Bernardo's own NixOS,
|
|
||||||
not Nomarchy, so it can't close it). Steps in HARDWARE-QUEUE. Design record in
|
|
||||||
**ROADMAP § Hibernation + zram by default**. Shipped:
|
|
||||||
- zram default (`modules/nixos/oom.nix`, zstd/50%/prio 100) — `checks.zram-swap`.
|
|
||||||
- Installer/template already default new installs to a RAM-sized encrypted
|
|
||||||
`@swap` swapfile + `resume_offset` wiring; `swapSize=0` = no-swap opt-out.
|
|
||||||
- `docs/MIGRATION.md` enable-hibernation runbook (existing machines).
|
|
||||||
- `rofi.nix` Power → Hibernate: kept unconditional, **notifies on failure**.
|
|
||||||
- `checks.hibernate` VM test (V2 PASS) — hibernate→resume→crash round-trip
|
|
||||||
(swap partition); zram (prio 100) doesn't swallow the image, disk carries it.
|
|
||||||
- `checks.hibernate-swapfile` VM test (V2 PASS) — the installer's btrfs `@swap`
|
|
||||||
**swapfile** setup: NOCOW `mkswapfile`, file-type swapon, a valid
|
|
||||||
`map-swapfile -r` offset the kernel accepts (`/sys/power/resume_offset`),
|
|
||||||
zram above it. (Bounded proof of the swapfile+offset the partition test
|
|
||||||
can't reach; a full runtime-swapfile hibernate isn't headless-reachable —
|
|
||||||
systemd `CanHibernate=no` for undeclared swap.)
|
|
||||||
|
|
||||||
**V3 remaining (narrowed):** the literal encrypted-swapfile power-cycle —
|
Bernardo, 2026-07-17: before tagging v1, sit down and devise the launch plan.
|
||||||
`Hibernate → power off → unlock LUKS → resume session` on the LUKS+@swap
|
Known ingredients, so that session starts concrete:
|
||||||
layout — plus the no-swap notify in a real session. QEMU can't feed a
|
- **The repo moves to GitHub.** Everything baking the Gitea URL follows: the
|
||||||
runtime-computed `resume_offset` into the boot cmdline, and the dev box runs
|
downstream template's `inputs.nomarchy.url`, the `nomarchy-wallpapers`
|
||||||
exactly this layout (`/proc/cmdline` verified) but isn't Nomarchy. Steps in
|
input URL (baked 2026-07-17 — sweep it in the same move), README/docs
|
||||||
HARDWARE-QUEUE. Close #76 on PASS.
|
links, and CI (eval CI runs on Gitea act_runner — decide GitHub Actions
|
||||||
|
vs keeping Gitea CI on a mirror).
|
||||||
|
- The move is the natural moment for the **history-rewrite decision**, and
|
||||||
|
it is now measured (2026-07-17): the 107 MiB pack is 94.2 MB wallpaper
|
||||||
|
blobs + 9.9 MB preview generations; all 848 commits of *text* pack to
|
||||||
|
~15–20 MiB. So the history is not long-heavy, it is image-heavy.
|
||||||
|
Recommendation on the table: **filter, don't reset** —
|
||||||
|
`git filter-repo` stripping only `themes/*/backgrounds/` keeps every
|
||||||
|
commit's diffs/blame/`log -S` (load-bearing: it diagnosed #147) at a
|
||||||
|
~15–20 MiB clone. Cost: all SHAs change, and ROADMAP/journal cite them —
|
||||||
|
mitigate by freezing the Gitea repo as a read-only archive (old SHAs
|
||||||
|
resolve there; stash filter-repo's commit-map in it) + one ROADMAP note.
|
||||||
|
Argue against a full mirror (ships 94 MB of deleted images to every
|
||||||
|
clone forever) and against fresh-start (burns the archaeology to save
|
||||||
|
~15 MiB).
|
||||||
|
- The existing v1 bar (VISION § v1.0): HARDWARE-QUEUE burn-down, install P0
|
||||||
|
re-verify before the `v1` fast-forward, visual ritual. LICENSE shipped
|
||||||
|
(MIT, 2026-07-17).
|
||||||
|
- Adjacent calls that may fold in: cachix/public binary cache (#120's
|
||||||
|
prerequisite), the netinstall decision itself, release notes/announce.
|
||||||
|
|
||||||
|
`v1` stays human-only throughout — agents prepare evidence and checklists,
|
||||||
|
never the tag or branch.
|
||||||
|
|
||||||
## LATER
|
## LATER
|
||||||
|
|
||||||
- **Wallpapers artifact split** (ROADMAP § Faster switches — decided,
|
- **Pre-built theme variants** (ROADMAP § Faster switches follow-on): if
|
||||||
deferred): pinned `Nomarchy-wallpapers` input so a state write stops
|
`home-manager switch` itself is still the theme-switch bottleneck now
|
||||||
re-copying 86 MB. Follow-on: pre-built theme variants if switches are
|
that the wallpapers split shipped (2026-07-17), pre-build each theme's
|
||||||
still slow after.
|
generation so a switch just activates a cached one. Measure first.
|
||||||
- **Installer round 2** (ROADMAP § Installer): multi-disk BTRFS RAID,
|
- **Installer round 2** (ROADMAP § Installer): multi-disk BTRFS RAID,
|
||||||
impermanence, BIOS/legacy boot.
|
impermanence, BIOS/legacy boot.
|
||||||
- **Boot-from-snapshot**: a systemd-boot equivalent of grub-btrfs.
|
- **Boot-from-snapshot**: a systemd-boot equivalent of grub-btrfs.
|
||||||
- **MIPI/IPU software-ISP camera** support (no-UVC machines).
|
- **MIPI/IPU software-ISP camera** support (no-UVC machines).
|
||||||
- **NixOS release bump → v2** `[human]`: deliberate, hand-edited, never
|
- **NixOS release bump → v2** `[human]`: deliberate, hand-edited, never
|
||||||
automated; the previous attempt was discarded (2026-06-22) over a
|
automated; the previous attempt was discarded (2026-06-22) over a
|
||||||
Hyprland OOM blocker — see MEMORY.md before retrying (NOW#3 should
|
Hyprland OOM blocker — see MEMORY.md before retrying.
|
||||||
also soften that blocker class).
|
|
||||||
|
|
||||||
## FUTURE (decided deferred — not the agent queue head)
|
## FUTURE (decided deferred — not the agent queue head)
|
||||||
|
|
||||||
@@ -103,22 +111,217 @@ high-ROI, etc.) live in the journal + ROADMAP — not here.*
|
|||||||
|
|
||||||
### Product / day-2
|
### Product / day-2
|
||||||
|
|
||||||
|
### 153. Desktop widgets (clock / system stats) as an opt-in per-theme surface
|
||||||
|
|
||||||
|
Pitch (filed while shipping the three exclusive theme pairs, 2026-07-18):
|
||||||
|
themed desktop widgets — a clock, calendar, or system-stats panel living on
|
||||||
|
the wallpaper, conky-style — would deepen the identity themes (imagine
|
||||||
|
kiln's copper stats panel or executive-ivory's letterhead clock). No such
|
||||||
|
surface exists today, so it is a NEW theming surface: per CONVENTIONS it
|
||||||
|
must consume the palette from the state JSON (no side pipeline) and ship
|
||||||
|
as an opt-in `nomarchy.*` toggle with a commented template example. Engine
|
||||||
|
choice is the real decision — conky is X11-era; a Wayland layer-shell
|
||||||
|
widget system (eww or a small QML/GTK layer-shell tool) fits the stack
|
||||||
|
better. Cost: moderate (engine spike + palette bridge + 2–3 widget
|
||||||
|
layouts; start with the exclusive pairs only, not all 28 themes). Value:
|
||||||
|
v1+ polish, not a v1 blocker.
|
||||||
|
|
||||||
|
### 146. `[watch]` hypridle hangs instead of exiting when its compositor vanishes
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Rewritten 2026-07-16, hours after filing: the premise was disproven and almost
|
||||||
|
nothing survives.** As filed it said hypridle "dies silently ~2×/week — 19 times
|
||||||
|
in 11 days", with Jul 14 as the clean example ("`CRITICAL` at 13:08:17, then
|
||||||
|
eleven hours of silence"). Every number there was an artifact of the
|
||||||
|
measurement. **18 of the 19** `Disconnected from pollfd id 1` lines are
|
||||||
|
hypridle's noisy goodbye while systemd *deliberately stops it* — each bracketed
|
||||||
|
by `Stopped hypridle` / `Started hypridle` with a healthy replacement seconds
|
||||||
|
later. The "eleven hours of silence" came from grepping **one PID**: systemd
|
||||||
|
logged `Stopped hypridle` 13:08:17, `Started hypridle` 13:09:00, and PID 1678
|
||||||
|
ran fine all evening where the filter could not see it. Do not re-derive the old
|
||||||
|
rate; it counts normal shutdown noise.
|
||||||
|
|
||||||
|
**What is real, and it is small.** Exactly one genuine zombie has been observed
|
||||||
|
(2026-07-16 17:25:39), verified by process inspection rather than logs — `ps`
|
||||||
|
(alive 9h36m), `ss` (48 KB unread on its D-Bus socket, i.e. not reading),
|
||||||
|
`NRestarts=0` while `systemctl --user is-active` said `active (running)`. That
|
||||||
|
is upstream hyprwm/hypridle#171 ("Possible deadlock in pollThr when compositor
|
||||||
|
exits?", open since 2025-09, in neither 0.1.7 nor main) and it is a true bug:
|
||||||
|
hypridle hangs rather than exits, so `Restart=always` never fires.
|
||||||
|
|
||||||
|
**A second real one joined it with #127's close (2026-07-17):** an
|
||||||
|
inhibit-lock underflow (`BUG THIS: inhibit locks < 0: -1`, hypridle's own
|
||||||
|
words, Jul 15 10:36:29) left the fired DPMS listener's `on-resume` permanently
|
||||||
|
dead on 0.1.7 — the incident's actual mechanism (ROADMAP § "The idle brick's
|
||||||
|
CAUSE found"). Reported upstream with the full trace 2026-07-17:
|
||||||
|
https://github.com/hyprwm/hypridle/issues/208
|
||||||
|
(#74/#104/#128 are the same class, closed before 0.1.7 evidently without
|
||||||
|
killing it). Its impact path here is closed by the compositor-side wake fix
|
||||||
|
(a2151f4); same watch posture — take the upstream fix when a release carries
|
||||||
|
it.
|
||||||
|
|
||||||
|
**But its impact path is closed, which is why this is `[watch]` and not work.**
|
||||||
|
The zombie only *persists* if systemd never stops the unit — which needed the
|
||||||
|
user manager to survive logout, i.e. the stray pre-migration linger removed in
|
||||||
|
#147. Without linger, logout tears the manager down and takes any zombie with
|
||||||
|
it; the Jul 14 trace above shows the healthy path (`Stopped` → `Started`) doing
|
||||||
|
exactly that. A Hyprland death ends the logind session anyway (2026-07-16:
|
||||||
|
`Session 2 logged out` → greeter), so there is no known route left to a
|
||||||
|
surviving zombie.
|
||||||
|
|
||||||
|
**Do before building anything:** re-measure. Count only disconnects with **no**
|
||||||
|
`Stopped/Started hypridle` around them and **no** logind session event, and
|
||||||
|
confirm by `ps`/`ss` that the process is really wedged — not by grepping a PID.
|
||||||
|
If that count is zero over a few weeks post-linger, **close this unbuilt**. If it
|
||||||
|
is not, the upstream issue has sat ten months on vaxerski's "that will cause a
|
||||||
|
segfault no?" and a reproducer would be worth more than a local guard.
|
||||||
|
|
||||||
|
### 149. `[watch]` Waybar's exec-once workaround may be defending against nothing post-linger
|
||||||
|
|
||||||
|
Spun out of #147 (shipped 2026-07-17) so its loose thread survives the entry:
|
||||||
|
`modules/home/hyprland.nix` runs Waybar from `exec-once` rather than a systemd
|
||||||
|
unit because the unit "raced Hyprland's IPC on a **warm** relogin ... never
|
||||||
|
retried, so the bar vanished" — and a warm relogin is exactly what linger
|
||||||
|
created. With the stray linger removed (2026-07-16) that workaround may be
|
||||||
|
defending against nothing. Do not rip it out on this note alone: it works, and
|
||||||
|
the claim needs a real relogin without linger to test (V3 — the same relogin
|
||||||
|
that verifies #148's watcher fix would answer it).
|
||||||
|
|
||||||
|
**2026-07-18 evidence: the warm relogin is real post-linger.** On the dev
|
||||||
|
box the user manager survived a Hyprland logout (the tty1 greetd session
|
||||||
|
keeps it alive — no linger involved) and the teardown broke:
|
||||||
|
`graphical-session.target`'s stop transaction was rejected as destructive
|
||||||
|
(easyeffects had a queued start job), the target stayed active,
|
||||||
|
cliphist/swaync/portals/swayosd crash-looped against the dead Wayland
|
||||||
|
socket into `start-limit-hit`, and the relogin's plain `start` was a no-op
|
||||||
|
— doctor red for the whole session. Session bring-up now stops stale
|
||||||
|
targets + `reset-failed` before starting (`hyprland.nix`
|
||||||
|
systemd.extraCommands); relogin V3 check queued in HARDWARE-QUEUE. The
|
||||||
|
exec-once posture this entry watches is therefore still earning its keep.
|
||||||
|
|
||||||
|
### 120. A netinstall ISO, next to the fat offline one
|
||||||
|
|
||||||
|
**Deferred to PROPOSED 2026-07-16 (Bernardo): not now.** Nothing below is
|
||||||
|
stale — the measurements stand and the decision it needs is unchanged. It sits
|
||||||
|
here because it is the only genuinely large item left, and because the gotcha
|
||||||
|
at the bottom (no binary cache for our own derivations) probably makes a
|
||||||
|
cachix the real first step, not the ISO.
|
||||||
|
|
||||||
|
Bernardo 2026-07-14, after seeing the measured size: **keep the current ISO
|
||||||
|
exactly as it is** — the guaranteed offline install is the feature it buys —
|
||||||
|
and ship a **much lighter netinstall variant alongside it**. Two products, one
|
||||||
|
distro: "works on a plane" and "8 GiB is absurd to download" are both true, and
|
||||||
|
a second target settles them without compromising either.
|
||||||
|
|
||||||
|
**Measured facts (2026-07-14), so this starts from numbers, not vibes.**
|
||||||
|
*(These stand as measured: #121 would have cut ~0.67 GiB of duplicate chromium
|
||||||
|
from them, but it was **reverted** — decided against, ROADMAP § one chromium,
|
||||||
|
not two. If a netinstall ships, revisit it: the duplicate is worth ~195 MiB of
|
||||||
|
**download**, which is this item's whole currency, even though it is worth
|
||||||
|
almost nothing on disk or on the ISO.)*
|
||||||
|
|
||||||
|
> **Read this before using the numbers below.** They are **closure arithmetic**,
|
||||||
|
> and #121 proved the hard way that closure size is neither disk size nor image
|
||||||
|
> size: removing a 687 MiB path shrank the ISO by **8 KiB**, because
|
||||||
|
> **mksquashfs dedupes duplicate files** and **`auto-optimise-store` hardlinks**
|
||||||
|
> them on disk. So a change that looks like it sheds gigabytes of closure can
|
||||||
|
> shed nothing off the actual image. **Measure the artifact — build the ISO and
|
||||||
|
> `stat` it.** The corollary cuts the other way and is the good news for this
|
||||||
|
> item: what dedupe cannot help is the **wire**, so a netinstall's download is
|
||||||
|
> the one figure closure/NAR size predicts honestly (`nix path-info --store
|
||||||
|
> https://cache.nixos.org --json` gives the real `downloadSize`).
|
||||||
|
- Current ISO **8.078 GiB** compressed; **18.03 GiB** of store uncompressed
|
||||||
|
(`zstd -19`, 2.23:1 — compression is already near-max, not the lever).
|
||||||
|
- The offline pin (`system.extraDependencies`, 60 roots: a representative
|
||||||
|
installed system + the template HM closure + all flake inputs) is **4.02 GiB
|
||||||
|
uncompressed of that — only ~22%**. Dropping it entirely still leaves a
|
||||||
|
**~13.3 GiB** desktop → roughly **6 GiB** compressed at the same ratio.
|
||||||
|
**So "no pin" alone is NOT the lighter ISO** — this is the trap to avoid.
|
||||||
|
- The desktop's own top weights: libreoffice 1457 MiB, initrd 1369,
|
||||||
|
linux-firmware 770, chromium 1391 (two builds — #121, left in), llvm-lib 540,
|
||||||
|
bibata-cursors 322, mesa 264, mbrola-voices 259, nerd-fonts ~420 combined.
|
||||||
|
Note what that list implies: no single lever gets a desktop ISO under ~4 GB —
|
||||||
|
which is the case for (b) below.
|
||||||
|
|
||||||
|
**So the real decision is what a netinstall ISO IS**, and it should be settled
|
||||||
|
first (`[human]`): (a) the full try-before-install desktop minus the pin
|
||||||
|
(~6.3 GiB — barely lighter, probably not worth a second target); (b) a **TUI
|
||||||
|
installer only, no desktop** (~1 GiB, the actual "netinstall" in the Debian
|
||||||
|
sense) which drops "try before install" from that medium — the fat ISO still
|
||||||
|
offers it; (c) a middle desktop (no libreoffice/chromium — but note #103 just
|
||||||
|
put those there deliberately, and a *demo* desktop that can't browse is the
|
||||||
|
bug #103 fixed).
|
||||||
|
|
||||||
|
**The gotcha that decides feasibility:** without the pin, a netinstall target
|
||||||
|
fetches from `cache.nixos.org` for stock nixpkgs paths — but **Nomarchy's own
|
||||||
|
derivations are in no binary cache**, so they would build *from source on the
|
||||||
|
user's machine* during install. That is the same failure `tools/vm/gap-analysis.py`
|
||||||
|
exists to diagnose (and #113 is a live instance of). So this item probably
|
||||||
|
depends on a public binary cache (cachix) for the flake's own outputs, or it
|
||||||
|
trades an 8 GiB download for a 40-minute install. Establish that before
|
||||||
|
building the target.
|
||||||
|
|
||||||
|
Pass = a second, documented ISO target that is *substantially* smaller (state
|
||||||
|
the measured number, both ISOs built from one tree), installs successfully with
|
||||||
|
a network in a QEMU run, says clearly at boot that it needs one, and leaves the
|
||||||
|
offline ISO's behaviour untouched (`checks.*` for the offline path stay green).
|
||||||
- **NVIDIA first-class options** — **deferred past v1** (Bernardo
|
- **NVIDIA first-class options** — **deferred past v1** (Bernardo
|
||||||
2026-07-10). Keep #59 commented install guidance; no
|
2026-07-10). Keep #59 commented install guidance; no
|
||||||
`nomarchy.hardware.nvidia.*` until a hybrid maintainer + queue.
|
`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
|
### v1.0 pointer
|
||||||
|
|
||||||
See **VISION**. Open PROPOSED: NVIDIA wrappers deferred past v1; IR
|
See **VISION**. Open PROPOSED: NVIDIA deferred past v1; IR portal (b)/(c)
|
||||||
portal (b)/(c) need T14s hardware (HARDWARE-QUEUE § T14s). Standing
|
need T14s (HARDWARE-QUEUE § T14s). Standing calls: browser = Chromium;
|
||||||
calls: browser default = Chromium; power backend = PPD.
|
power = PPD. (Post-install hardware hints shipped 2026-07-17.)
|
||||||
|
|
||||||
|
|
||||||
## Decisions `[human]`
|
## Decisions `[human]`
|
||||||
|
|
||||||
Open calls only Bernardo can make; agents add options/evidence but never
|
Open calls only Bernardo can make; agents add options/evidence but never
|
||||||
decide. **Resolved** entries stay for history; agents treat them as closed.
|
decide. **Resolved** entries stay for history; agents treat them as closed.
|
||||||
|
|
||||||
|
### Resolved (2026-07-17)
|
||||||
|
|
||||||
|
- **#143 rofi refilter highlight** — the 07-16 "wait for upstream" ruling
|
||||||
|
was priced against carrying a patch; the maintainer's reply on
|
||||||
|
rofi#2317 pointed at the `inputchange {}` config block instead. Adopted
|
||||||
|
same day as pure config (`kb-row-first` on query edits, generated
|
||||||
|
config.rasi) — no patch, no source build, watch closed
|
||||||
|
(ROADMAP § Rofi highlight).
|
||||||
|
- **#120 netinstall** — stays deferred in PROPOSED (re-affirmed). When it
|
||||||
|
is promoted, a public binary cache (cachix) is the likely first step.
|
||||||
|
- **#134 unstable packages** — build it: Nomarchy carries the
|
||||||
|
`nixpkgs-unstable` input, `unstable.*` via the overlay, home-scope only.
|
||||||
|
Shipped the same day (ROADMAP § `unstable.<pkg>` in the downstream).
|
||||||
|
- **#114 greeter layouts** — document only: a VT has one keymap by design,
|
||||||
|
so tuigreet cannot honour per-device layouts. Shipped as the README
|
||||||
|
"Greeter keyboard layout" note + a RECOVERY.md pointer; entry deleted.
|
||||||
|
|
||||||
### Resolved (2026-07-10)
|
### Resolved (2026-07-10)
|
||||||
|
|
||||||
- **Docs site vs Markdown-in-repo** — **markdown in-repo for now**
|
- **Docs site vs Markdown-in-repo** — **markdown in-repo for now**
|
||||||
@@ -139,8 +342,8 @@ decide. **Resolved** entries stay for history; agents treat them as closed.
|
|||||||
|
|
||||||
- **Hibernation** — **want by default** (product intent). Needs a
|
- **Hibernation** — **want by default** (product intent). Needs a
|
||||||
disk-backed swap (file or partition) sized for resume; not zram alone.
|
disk-backed swap (file or partition) sized for resume; not zram alone.
|
||||||
Implementation is a future/NEXT item when designed (installer +
|
**Shipped as #76**; V3 power-cycle PASSED on TuringMachine 2026-07-12
|
||||||
resume device + encrypted-root story).
|
(ROADMAP § Hibernation + zram by default).
|
||||||
|
|
||||||
### Resolved (2026-07-10, #76 design)
|
### Resolved (2026-07-10, #76 design)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ those, it probably shouldn't exist.
|
|||||||
|
|
||||||
## Feature design
|
## Feature design
|
||||||
- **In-flake state:** any user-settable config gets a menu writer that
|
- **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
|
`~/.local/state`, no side files. Instant-effect where possible
|
||||||
(`--no-switch` + flip the service; the service reads the *live* working
|
(`--no-switch` + flip the service; the service reads the *live* working
|
||||||
tree at start — the night-light `ExecCondition` pattern). Rebuild-baked
|
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
|
named `writeShellScriptBin`s on PATH (so static configs can exec them
|
||||||
by bare name), and are added to **both** the generated `waybar.nix`
|
by bare name), and are added to **both** the generated `waybar.nix`
|
||||||
config **and** every `waybar.jsonc` whole-swap — summer-day, summer-night,
|
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
|
- **Theming:** every new visual surface consumes the palette from the
|
||||||
state JSON. There is no second renderer to keep in sync — add the key
|
state JSON. There is no second renderer to keep in sync — add the key
|
||||||
to the JSON, consume it in the module.
|
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
|
- Commit style: `feat|fix|test|docs|chore(scope): summary`, body with
|
||||||
what/why + verification tier + what remains. Bookkeeping (`agent/`
|
what/why + verification tier + what remains. Bookkeeping (`agent/`
|
||||||
updates) rides in the same commit as the change.
|
updates) rides in the same commit as the change.
|
||||||
- `flake.lock` moves only when the task *is* a lock bump, and only within
|
- `flake.lock` moves only when the task *is* a lock bump. The release
|
||||||
the pinned release branches.
|
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`.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ confidence, default identity, release bar) live in
|
|||||||
on-hardware QA.
|
on-hardware QA.
|
||||||
2. **Reproducible, with zero hidden state.** The downstream flake checkout
|
2. **Reproducible, with zero hidden state.** The downstream flake checkout
|
||||||
*is* the machine. All user-settable config is menu-writable into the
|
*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
|
`~/.local/state`, never `~/.config` side files. Re-cloning your flake
|
||||||
reproduces the machine, settings and all.
|
reproduces the machine, settings and all.
|
||||||
3. **Effortless to configure.** The user never has to learn Nix. Every
|
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
|
ergonomic writer for the flake. Where a toggle can take effect without a
|
||||||
rebuild, it must (`--no-switch` + flip the running service).
|
rebuild, it must (`--no-switch` + flip the running service).
|
||||||
4. **Beautiful.** One JSON themes the entire desktop coherently — Hyprland,
|
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
|
surface follows the palette. Informative, self-gating Waybar modules
|
||||||
(they hide when irrelevant). No unthemed corner survives contact with
|
(they hide when irrelevant). No unthemed corner survives contact with
|
||||||
the theme switcher.
|
the theme switcher.
|
||||||
|
|||||||
@@ -9,13 +9,281 @@ git history is the archive. Machines: the **AMD dev box** (Ryzen AI
|
|||||||
laptop: AMD + fingerprint + NPU), the **Latitude 5310/5410** (Intel QA),
|
laptop: AMD + fingerprint + NPU), the **Latitude 5310/5410** (Intel QA),
|
||||||
the **T14s** (webcam case).
|
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)
|
## Any machine (dev box is fine)
|
||||||
|
|
||||||
|
- [ ] **Theme pairs V3 — the four new themes on real GL** — after pulling
|
||||||
|
the pair commits: `nomarchy-state-sync apply <slug>` for each of
|
||||||
|
boreal-dawn, executive-ivory, kiln, kiln-clay. Pass = all of:
|
||||||
|
(1) the bar renders true — boreal-dawn's frosted pills actually blur
|
||||||
|
what's behind them (softGL VM can't prove blur), kiln's plank shows
|
||||||
|
its copper edge, no font-fallback boxes (GeistMono / JetBrainsMono /
|
||||||
|
CaskaydiaCove); (2) wallpaper paints at native res, no banding on the
|
||||||
|
kiln ember gradient; (3) Kitty + btop under each theme — btop colors
|
||||||
|
match (the VM never started the GL terminal; btop.theme is
|
||||||
|
file-asserted only); (4) boreal-dawn app launcher (SUPER+Space) lays
|
||||||
|
out its icon grid; (5) an nm-applet/tray menu is readable under all
|
||||||
|
four (the `*`-reset counter-rules). If a VM-rendered preview looks
|
||||||
|
off in the theme-picker grid next to the hardware-captured ones,
|
||||||
|
recapture that preview on hardware into themes/<slug>/preview.png.
|
||||||
|
- [ ] **Light-theme contrast refits (2026-07-18 report)** — under any
|
||||||
|
light theme: (1) with two workspaces open, the inactive number is
|
||||||
|
readable on the bar (subtext, not the washed-out grey); (2) hover a
|
||||||
|
notification popup — background stays the theme's light card with a
|
||||||
|
faint tint, text readable (the swaync default's dark
|
||||||
|
.notification-default-action hover is now overridden); close
|
||||||
|
button matches the palette. VM-verified with a hover probe under
|
||||||
|
summer-day; this is the on-hardware confirmation of the report.
|
||||||
|
- [ ] **Auto-theme pair flip on hardware** — set
|
||||||
|
`settings.autoTheme.{day,night}` to one of the new pairs (e.g.
|
||||||
|
kiln-clay / kiln), set sunset (or sunrise) a few minutes ahead via
|
||||||
|
Look & Feel › Auto theme (the time edit itself rebuilds — since the
|
||||||
|
exact-time revision the timer fires AT the configured minute, no
|
||||||
|
15-min poll), let the timer flip once each way. Pass = (1)
|
||||||
|
`systemctl --user list-timers nomarchy-auto-theme` shows the two
|
||||||
|
configured times as the trigger; (2) the switch lands within ~a
|
||||||
|
minute of the configured time; (3) the whole desktop switches in
|
||||||
|
one generation, and nothing leaks from the previous theme — fonts,
|
||||||
|
rounding, terminal opacity all reset (the per-theme appearance
|
||||||
|
block), wallpaper follows; (4) with autoCommit on, `git -C
|
||||||
|
~/.nomarchy log` shows an `apply theme` commit and state.json is
|
||||||
|
not left dirty (the 0.5.1 pathspec fix).
|
||||||
|
- [ ] **#148 dock-intent enforcement without auto-profiles (dev box, docked)** —
|
||||||
|
after pulling the #148 commit: **log out and back in first** — the
|
||||||
|
watcher is `exec-once`, so `nomarchy-home` alone leaves the old one
|
||||||
|
running. Then, docked clamshell (lid closed, panel off, dockMode set
|
||||||
|
from the Display menu or a prior auto-dock), run `nomarchy-home` or a
|
||||||
|
bare `hyprctl reload`. **Pass:** eDP-1 re-lights for at most ~1–2 s and
|
||||||
|
goes back off by itself; `journalctl -t nomarchy-display-watch
|
||||||
|
--since -5min` shows `dock-intent=true … action=re-dock`; a "Docked —"
|
||||||
|
toast appears. Same relogin answers #149's question (does the Waybar
|
||||||
|
unit still race Hyprland IPC on a cold relogin without linger?).
|
||||||
|
**Fail:** the panel stays lit inside the shut lid (the #148 symptom).
|
||||||
|
- [ ] **#115 suspend-then-hibernate (laptop with hibernate/resume wired)** —
|
||||||
|
after system rebuild: Preferences shows **Suspend then hibernate (on)**
|
||||||
|
(hidden if `CanHibernate=no`). Confirm:
|
||||||
|
`grep -E 'HibernateDelaySec|HibernateOnACPower' /etc/systemd/sleep.conf`
|
||||||
|
→ `1h` / false; `grep HandleLidSwitch /etc/systemd/logind.conf` →
|
||||||
|
undocked s2h, ExternalPower=suspend, Docked=ignore.
|
||||||
|
**Quick path:** on battery, `nomarchy-suspend` then
|
||||||
|
`systemctl list-jobs` / journal should show suspend-then-hibernate
|
||||||
|
(not plain suspend). On AC, same command → plain suspend.
|
||||||
|
**Full path (optional, long):** battery, lid close or idle past 15m,
|
||||||
|
leave ~1h+, open → single LUKS unlock (encrypted) or lock screen
|
||||||
|
(unencrypted), session intact. Toggle **off** + rebuild → plain
|
||||||
|
suspend only. **Pass:** battery s2h + 1h hibernate resume works;
|
||||||
|
AC never plans hibernate; no row without hibernate support.
|
||||||
|
- [ ] **#104 airplane mode radios (any laptop with Wi-Fi + BT)** —
|
||||||
|
after `nomarchy-home` + waybar restart: System › **Airplane mode (off)**
|
||||||
|
(or SUPER+CTRL+R). **Pass on:** Wi-Fi and Bluetooth both drop (nm-applet /
|
||||||
|
blueman confirm); Waybar shows a plane glyph; click glyph or menu again
|
||||||
|
restores **only** the radios that were on before. If Wi-Fi was already
|
||||||
|
off, it must stay off after disengage. Glyph must **not** appear when
|
||||||
|
airplane is off.
|
||||||
|
- [ ] **#96 battery-limit row on threshold-less firmware (Acer M5-481T)** —
|
||||||
|
on the Acer (live or installed session with the commit carrying this
|
||||||
|
entry): open System (`SUPER+CTRL+I`). **Pass:** a "Battery limit" row
|
||||||
|
IS listed (battery present); picking it shows the notification
|
||||||
|
explaining the firmware exposes no charge-stop control (not a raw
|
||||||
|
error, and the row never silently disappears). On the T14s the row
|
||||||
|
must still open the working preset picker.
|
||||||
|
- [ ] **#101 charge-limit service survives USB-C power-event bursts**
|
||||||
|
(round 2) — round 1 FAILED on TuringMachine 2026-07-13 18:16: a
|
||||||
|
*spaced* storm (each run finished before the next AC event) landed 5
|
||||||
|
successful starts in 10s → `start-limit-hit`, unit marked failed,
|
||||||
|
doctor badge — although every run succeeded. Fixed by exempting the
|
||||||
|
unit from start rate limiting (`StartLimitIntervalSec=0`) + a
|
||||||
|
spaced-storm guard in `checks.battery-charge-limit` (the commit
|
||||||
|
carrying this entry). **Re-check:** after pulling/rebuilding this
|
||||||
|
commit, `sudo systemctl reset-failed nomarchy-battery-charge-limit
|
||||||
|
.service`, then plug/unplug the powered USB-C dock several times in
|
||||||
|
quick succession. **Pass:** `systemctl is-failed
|
||||||
|
nomarchy-battery-charge-limit.service` stays false, `Result=success`,
|
||||||
|
BAT0's `charge_control_end_threshold` still matches the limit, and the
|
||||||
|
journal has no new `start-limit-hit`/`Failed with result` lines.
|
||||||
|
- [ ] **Docking recovery round 8 (undock panel restore, 2026-07-14)** —
|
||||||
|
round 7 **FAILED** on the AMD dev box (Bernardo, 5–6 consecutive
|
||||||
|
unplugs, none recovered). Its whole diagnosis was wrong, and the way it
|
||||||
|
was wrong is the lesson: round 6 read `result=enable-timeout` ×2 +
|
||||||
|
`result=ok` ×1 as an intermittent race and retried the keyword. But the
|
||||||
|
keyword is **inert, not raced** — with ZERO enabled outputs (panel
|
||||||
|
disabled by the dock, external gone) Hyprland 0.55.4 accepts
|
||||||
|
`keyword monitor` (prints `ok`, exits 0) and never flushes it until a
|
||||||
|
DRM event arrives. Retrying an inert command 25×/poll for 6 polls just
|
||||||
|
bought 30s of black screen. Every `result=ok` in that journal was
|
||||||
|
**Bernardo plugging the cable back in** — his hotplug flushed the queued
|
||||||
|
rule and the next poll took the credit. Probed live 2026-07-14: keyword
|
||||||
|
inert across 4s and 5s in two runs, `dispatch forcerendererreload` also
|
||||||
|
inert, and only `hyprctl reload` worked — 99ms and 289ms, cable out.
|
||||||
|
The transition now escalates to `reload` when the keyword proves inert,
|
||||||
|
re-asserts the rule, and restores per-device keyboard layouts (a reload
|
||||||
|
drops runtime `device[…]:kb_layout` keywords).
|
||||||
|
**Already proven on hardware** (2026-07-14, two real unplugs invoking
|
||||||
|
the fixed `nomarchy-display-transition undock eDP-1` directly with the
|
||||||
|
watcher paused): panel on and workspaces 1–3 home in 1.8s, journal
|
||||||
|
`keyword=inert escalate=reload` → `enable=via-reload` → `result=ok`; and
|
||||||
|
the keyboard-restore path with a Logitech K400 whose receiver is in the
|
||||||
|
laptop — it kept its remembered `us` across the undock, while a control
|
||||||
|
`hyprctl reload` with no restore dropped it to the session's `gb`
|
||||||
|
(so `restore_keyboards` is load-bearing, not insurance).
|
||||||
|
**What is NOT proven, and is this round:** the *watcher-driven* path
|
||||||
|
(the running watcher calls the transition by baked store path, so it
|
||||||
|
only picks the fix up at relogin — the round-5 trap), and repetition.
|
||||||
|
**Re-check:** relogin (mandatory — see below), dock, then unplug the
|
||||||
|
cable **at least five times**, plus once with the lid shut.
|
||||||
|
**Pass:** the panel comes back every time with workspaces intact, and
|
||||||
|
`journalctl --user
|
||||||
|
-t nomarchy-display-transition -t nomarchy-display-watch --since
|
||||||
|
'-10 min'` shows `result=ok` for each undock with **no**
|
||||||
|
`result=enable-failed`. `keyword=inert escalate=reload` on every undock
|
||||||
|
is EXPECTED — that is the fix firing, not a fault. A `result=ok`
|
||||||
|
**without** a preceding `keyword=inert` line means the keyword flushed
|
||||||
|
on its own and the reload was never needed: interesting, worth
|
||||||
|
reporting, still a pass.
|
||||||
|
- [ ] **Headphone jack-follow (last open count from rounds 4–6,
|
||||||
|
2026-07-14)** — rounds 4–6 opened five counts on the AMD dev box; four
|
||||||
|
are now **confirmed fixed on hardware** by round 6 (Bernardo): wallpaper
|
||||||
|
on the external, automatic dock mode, audio reaching the monitor, and
|
||||||
|
the external keyboard prompting **exactly once** (was four times). The
|
||||||
|
undock panel is round 7 above. This is the one count **never tested**:
|
||||||
|
plugging headphones had stopped moving audio to them, because a pinned
|
||||||
|
`default.configured.audio.sink` outranks the priority rules, which kills
|
||||||
|
jack-follow on UCM cards where the headphones are their own sink (fixed
|
||||||
|
in ce480f3's lineage, unverified). **Re-check:** docked and undocked,
|
||||||
|
plug and unplug headphones — audio must move to them and back each time,
|
||||||
|
and `journalctl --user -t nomarchy-dock-audio` must show `trigger=jack
|
||||||
|
selected=…` on each plug. Worth folding into the round 7 session: the
|
||||||
|
dock is already in hand and the two don't interfere.
|
||||||
|
|
||||||
|
**Historical detail for the four settled counts** (kept only until
|
||||||
|
round 7 closes; the fixes themselves are in the log):
|
||||||
|
— round 4 was run on the AMD dev box and **failed on four counts**
|
||||||
|
(Bernardo): no wallpaper on the external monitor, no automatic dock
|
||||||
|
mode (System › Display had to be used by hand), audio staying on the
|
||||||
|
laptop speakers while docked, and one dock + one external keyboard
|
||||||
|
asking for a layout **four times**. Separately, plugging headphones had
|
||||||
|
stopped moving audio to them. Causes and fixes in the commit carrying
|
||||||
|
this entry: the display watcher's `socat -T 1` closed Hyprland's IPC
|
||||||
|
socket after a second of idle, so a dock plugged into an idle desktop
|
||||||
|
lost `monitoradded` in the reconnect gap (wallpaper + dock mode + audio
|
||||||
|
all hang off that one event); Hyprland lists every key-capable evdev
|
||||||
|
node as a "keyboard"; and a pinned `default.configured.audio.sink`
|
||||||
|
outranks the priority rules, which kills jack-follow on UCM cards where
|
||||||
|
the headphones are their own sink. **Re-run round 4 below in full**,
|
||||||
|
plus: (a) leave the desktop untouched for ~30s, then plug the dock —
|
||||||
|
wallpaper, dock mode, and audio must all follow with no manual step;
|
||||||
|
(b) the external keyboard must prompt **exactly once**, and never for
|
||||||
|
the monitor or the lid/power buttons; (c) docked and undocked, plug and
|
||||||
|
unplug headphones — audio must move to them and back each time.
|
||||||
|
`journalctl --user -t nomarchy-display-watch` should show
|
||||||
|
`outputs-changed added=…`, and `-t nomarchy-dock-audio`
|
||||||
|
`trigger=jack selected=…` on a headphone plug.
|
||||||
|
|
||||||
|
**Round 5 was itself run and failed (2026-07-14); this entry now covers
|
||||||
|
round 6.** Two of the four reports were never actually testing the fix:
|
||||||
|
both watchers are Hyprland `exec-once`, so `nomarchy-home` swaps the
|
||||||
|
config but leaves the *old* processes running — only `nomarchy-dock-audio`
|
||||||
|
(a user service) had picked up new code. **Relogin is mandatory before
|
||||||
|
testing either watcher**; confirm with `tr '\0' '\n' < /proc/$(pgrep -f
|
||||||
|
bin/nomarchy-display-profile-watch)/cmdline | sed -n 2p` and grep the
|
||||||
|
script for `outputs-changed`. The other two were real and are fixed in
|
||||||
|
the commit carrying this entry: automatic dock mode did not exist at all
|
||||||
|
(the watcher auto-*undocked* but only ever matched display profiles on
|
||||||
|
plug, and this box has none saved with `displayProfileAuto` unset, so
|
||||||
|
nothing happened — `Dock mode` was interactive-only), and the BenQ's
|
||||||
|
audio was unreachable because WirePlumber had `alsa_card.pci-0000_c3_00.1`
|
||||||
|
pinned to `pro-audio`, whose raw `pro-output-N` sinks have no ports and
|
||||||
|
no routing. The BenQ's own USB card is input-only (all profiles
|
||||||
|
`sinks: 0`), so DisplayPort is the only path to its speakers.
|
||||||
|
**Also check:** no display profile is saved, yet plugging the BenQ must
|
||||||
|
now dock by itself; and `journalctl --user -t nomarchy-dock-audio` shows
|
||||||
|
`repaired-card=alsa_card.pci-0000_c3_00.1 profile=HiFi` on the first
|
||||||
|
plug, after which audio reaches the monitor's speakers. A saved+matching
|
||||||
|
display profile must still win over auto-dock.
|
||||||
|
- [ ] **Docking recovery round 4 (#100, closed-lid BenQ sequence)** — after
|
||||||
|
updating to the commit carrying this entry, run `nomarchy-home` and
|
||||||
|
relogin. With the lid open, put visible windows on at least workspaces
|
||||||
|
1, 2, and 3 and start audio; plug the BenQ GW2790QT. **Pass on plug:**
|
||||||
|
wallpaper appears, audio toasts and moves to a BenQ/GPU HDMI sink, and
|
||||||
|
`journalctl --user -t nomarchy-dock-audio -n 10` shows
|
||||||
|
`trigger=monitoradded` plus `selected=…` (not only a card event). Open
|
||||||
|
System › Display › **Dock mode**. **Pass on dock:** every workspace and
|
||||||
|
window is on the BenQ, focus follows, eDP is disabled in `hyprctl
|
||||||
|
monitors all -j`, and `systemd-inhibit --list` shows Nomarchy holding
|
||||||
|
`handle-lid-switch` for “Safe dock/undock display transition”; there is
|
||||||
|
no dangling laptop workspace and no application exits. Close the lid;
|
||||||
|
audio/session must stay awake. While still docked, manually choose the
|
||||||
|
speakers and change volume/mute: it must stay on speakers. Unplug the
|
||||||
|
BenQ **with the lid still closed**, wait 10 seconds, then open it.
|
||||||
|
**Pass on undock:** no suspend/crash/hard reset, eDP is already active,
|
||||||
|
workspaces 1/2/3 and their windows are intact on it, and the inhibitor
|
||||||
|
disappears only after lid-open. `journalctl -b --since '-5 min'` must
|
||||||
|
contain no cable-removal `systemd-logind: Suspending...`. Finally close
|
||||||
|
the undocked lid normally: the laptop must suspend, proving policy was
|
||||||
|
restored. Wake it, replug the BenQ: that fresh plug must move audio back
|
||||||
|
to HDMI (manual speakers do not survive a physical replug). If any step
|
||||||
|
fails, capture `journalctl --user -t nomarchy-display-watch -t
|
||||||
|
nomarchy-display-transition -t nomarchy-dock-audio --since '-10 min'`,
|
||||||
|
`systemd-inhibit --list`, `hyprctl monitors all -j`, `hyprctl workspaces
|
||||||
|
-j`, and `pactl --format=json list sinks` before changing state again.
|
||||||
|
- [ ] **Parallel fingerprint-or-password on the real reader** (AMD dev
|
||||||
|
box, 2026-07-12) — **sudo path PASSED 2026-07-14** (Bernardo, dev box,
|
||||||
|
gen 422, `fingerprint.pam = true`, parallel default): `sudo -k true`
|
||||||
|
showed ONE prompt, the typed password worked, touching the sensor
|
||||||
|
instead worked, and wrong-finger ×3 fell back to password.
|
||||||
|
**Still open:** hyprlock and the greeter must accept both factors and
|
||||||
|
not wedge on a leftover prompt after a fingerprint win (known cosmetic
|
||||||
|
quirk of the hack — pthread_cancel'd prompt); `fingerprint.parallel =
|
||||||
|
false` must restore the old sequential behavior.
|
||||||
|
**Do NOT "check password still works with `systemctl stop fprintd`"** —
|
||||||
|
that check was in this item and it is a trap that cannot fail. fprintd
|
||||||
|
is D-Bus activated, so PAM asks D-Bus and D-Bus starts it straight back
|
||||||
|
up: Bernardo ran it and *the fingerprint still authenticated*, which is
|
||||||
|
the tell that the daemon never stayed down. It proves nothing about a
|
||||||
|
dead reader. The property it was reaching for is structural anyway —
|
||||||
|
password is `auth sufficient` at order 11700, independent of the fprintd
|
||||||
|
rule at 11400, so no fprintd failure can gate it (verify by eval:
|
||||||
|
`nix eval --raw <flake>#nixosConfigurations.<host>.config.security.pam
|
||||||
|
.services.sudo.text`), and checks.hardware-toggles asserts that shape in
|
||||||
|
the VM with no reader at all. If someone genuinely wants it on hardware
|
||||||
|
it needs `systemctl mask` + stop, and masking a working machine's
|
||||||
|
authentication daemon to re-prove an evaluated invariant is a bad trade.
|
||||||
- [ ] **#55 fingerprint enroll on real reader** — with
|
- [ ] **#55 fingerprint enroll on real reader** — with
|
||||||
`nomarchy.hardware.fingerprint.enable` and a physical reader: System ›
|
`nomarchy.hardware.fingerprint.enable` and a physical reader: System ›
|
||||||
Fingerprint › Enroll a finger; List shows it; Verify succeeds; optional
|
Fingerprint › Enroll a finger; List shows it; Verify succeeds.
|
||||||
Use for login (on) → `nomarchy-rebuild` → login/sudo accept fingerprint.
|
Enroll/List/Verify verified on hardware 2026-07-14 (T14s); the
|
||||||
Menu surface is V1/V2 without a reader (self-gate + dry paths).
|
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
|
- [ ] **#60 non-BAT* battery name (if available)** — on a machine whose
|
||||||
system battery is **not** named `BAT*` (e.g. `CMB0`): confirm
|
system battery is **not** named `BAT*` (e.g. `CMB0`): confirm
|
||||||
charge-limit oneshot writes the threshold, System › Battery limit
|
charge-limit oneshot writes the threshold, System › Battery limit
|
||||||
@@ -24,13 +292,13 @@ the **T14s** (webcam case).
|
|||||||
a no-op (charge-limit re-apply passed on `BAT0` — Latitude
|
a no-op (charge-limit re-apply passed on `BAT0` — Latitude
|
||||||
2026-07-10).
|
2026-07-10).
|
||||||
- [ ] **btop theme fidelity (#52 residual)** — softGL theme-shot cannot
|
- [ ] **btop theme fidelity (#52 residual)** — softGL theme-shot cannot
|
||||||
open Ghostty, so the hand `btop.theme` assets were only guest-file
|
open a real terminal UI, so the hand `btop.theme` assets were only
|
||||||
asserted (main_bg / inactive_fg keys) + desktop/bar rendered for
|
guest-file asserted (main_bg / inactive_fg keys) + desktop/bar
|
||||||
rose-pine, everforest, summer-night, vantablack, catppuccin. On a
|
rendered for rose-pine, everforest, summer-night, vantablack,
|
||||||
real session: `nomarchy-theme-sync apply <slug>` then `btop` and
|
catppuccin. On a real session: `nomarchy-state-sync apply <slug>`
|
||||||
confirm backgrounds/text match the theme (esp. rose-pine Dawn light
|
then `btop` and confirm backgrounds/text match the theme (esp.
|
||||||
bg `#faf4ed`, vantablack near-black `#0d0d0d`, catppuccin Mocha
|
rose-pine Dawn light bg `#faf4ed`, vantablack near-black `#0d0d0d`,
|
||||||
`#1E1E2E`). One pass cycling those five is enough.
|
catppuccin Mocha `#1E1E2E`). One pass cycling those five is enough.
|
||||||
- [ ] **SSH_AUTH_SOCK for GUI clients** — after relogin, launch a GUI git
|
- [ ] **SSH_AUTH_SOCK for GUI clients** — after relogin, launch a GUI git
|
||||||
client (or `rofi`-launched terminal-less app) and confirm it reaches
|
client (or `rofi`-launched terminal-less app) and confirm it reaches
|
||||||
gpg-agent's SSH socket without an interactive shell parent.
|
gpg-agent's SSH socket without an interactive shell parent.
|
||||||
@@ -71,10 +339,16 @@ the **T14s** (webcam case).
|
|||||||
(SUPER+? / root menu › Keybindings) now ends in ↩ Back and
|
(SUPER+? / root menu › Keybindings) now ends in ↩ Back and
|
||||||
returns to the root picker; spot a couple of submenus (Display,
|
returns to the root picker; spot a couple of submenus (Display,
|
||||||
VPN › Tailscale) still Back correctly after the audit pass.
|
VPN › Tailscale) still Back correctly after the audit pass.
|
||||||
- [ ] **Keyboard hotplug picker (re-verify after in-flake graduation)** —
|
- [ ] **Per-device keyboard menu + hotplug restore (re-verify after in-flake
|
||||||
plug an external keyboard post-login, pick a layout in rofi, confirm
|
graduation)** — update to the current `main`, run `nomarchy-home`, and
|
||||||
it applies per-device only, persists in `settings.keyboard.devices`,
|
relogin with an external keyboard already connected. Open System ›
|
||||||
and graduates into a `device{}` block on the next rebuild.
|
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
|
- [ ] **Snapshots restore + rollback exercise** (residual — the
|
||||||
GUI/polkit half passed on hardware 2026-07-04, a47aa3a): in
|
GUI/polkit half passed on hardware 2026-07-04, a47aa3a): in
|
||||||
`sudo nomarchy-snapshots`, restore a single file (`undochange`)
|
`sudo nomarchy-snapshots`, restore a single file (`undochange`)
|
||||||
@@ -201,28 +475,11 @@ the **T14s** (webcam case).
|
|||||||
screenshot loaded. Check that the UI draws on the current theme palette
|
screenshot loaded. Check that the UI draws on the current theme palette
|
||||||
(tools colored properly) and hitting save places the screenshot in
|
(tools colored properly) and hitting save places the screenshot in
|
||||||
`~/Pictures/Screenshots/` while hitting copy places it in the clipboard.
|
`~/Pictures/Screenshots/` while hitting copy places it in the clipboard.
|
||||||
- [ ] **#76 hibernate→resume on a real Nomarchy install** — the V2 VM test
|
- [ ] **#76 no-swap Hibernate notify** (#76 itself is closed — the
|
||||||
proves the resume mechanism + zram coexistence, but QEMU can't feed a
|
hibernate→resume power-cycle **PASSED on TuringMachine 2026-07-12**:
|
||||||
boot-time `resume_offset` for a btrfs *swapfile* nor drive the LUKS
|
Bernardo ran the full hibernate → power off → single LUKS unlock →
|
||||||
initrd unlock, so this triple needs hardware. **Requires a machine
|
session-restored cycle "flawlessly"; this notify check is the one
|
||||||
actually running Nomarchy** (installer default swap = RAM), NOT the dev
|
remaining leftover) — on a **Nomarchy** machine installed
|
||||||
box — that box runs Bernardo's own NixOS (TuringMachine, 25.11); its
|
|
||||||
layout resumes fine (verified 2026-07-10) but that only corroborates
|
|
||||||
the *layout*, not Nomarchy's oom.nix/patch-template/rofi wiring.
|
|
||||||
Candidate: a fresh Nomarchy install on a Latitude, or the dev box
|
|
||||||
*after* migrating to Nomarchy (docs/MIGRATION.md).
|
|
||||||
1. Confirm the wiring on the Nomarchy box: `swapon --show` lists
|
|
||||||
`/swap/swapfile` (Type file) **and** `/dev/zram0` at priority 100;
|
|
||||||
`cat /proc/cmdline` shows `resume_offset=…` + `resume=` the btrfs UUID.
|
|
||||||
2. Open apps, note an unsaved in-RAM state. `nomarchy-menu → Power →
|
|
||||||
Hibernate` (or `systemctl hibernate`). Machine writes image, **powers
|
|
||||||
off**.
|
|
||||||
3. Power on: unlock LUKS **once** in the themed prompt; land back in the
|
|
||||||
**same session** — state restored, not a fresh login.
|
|
||||||
4. **Pass** = session restored after full power-off + single unlock.
|
|
||||||
Fail (fresh boot / stuck initrd) → NOW bug in BACKLOG (capture
|
|
||||||
`journalctl -b -1 | grep -i -E 'hibernat|resume|swap'`).
|
|
||||||
- [ ] **#76 no-swap Hibernate notify** — on a **Nomarchy** machine installed
|
|
||||||
with `swap = 0` (or temporarily `sudo swapoff -a` on one): `nomarchy-menu
|
with `swap = 0` (or temporarily `sudo swapoff -a` on one): `nomarchy-menu
|
||||||
→ Power → Hibernate` must surface a desktop notification ("Couldn't
|
→ Power → Hibernate` must surface a desktop notification ("Couldn't
|
||||||
hibernate — likely no swap is configured. See docs/MIGRATION.md →
|
hibernate — likely no swap is configured. See docs/MIGRATION.md →
|
||||||
@@ -238,12 +495,118 @@ the **T14s** (webcam case).
|
|||||||
survives relogin (the ExecCondition gate on the swapped unit —
|
survives relogin (the ExecCondition gate on the swapped unit —
|
||||||
the eval check proves the wiring, not the runtime gate). **Pass** =
|
the eval check proves the wiring, not the runtime gate). **Pass** =
|
||||||
warm shift at the location's night + toggle/persistence intact.
|
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
|
## 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
|
- [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP
|
||||||
active and PPD switching governors; opt-ins: ROCm (`rocminfo`, a GPU
|
active and PPD switching governors; opt-ins: ROCm (`rocminfo`, a GPU
|
||||||
PyTorch/Ollama smoke) and the XDNA NPU driver loading.
|
PyTorch/Ollama smoke) and the XDNA NPU driver loading.
|
||||||
- [ ] **Fingerprint** — `fprintd-enroll` + (opt-in PAM) login/sudo.
|
- [ ] **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,
|
- [ ] **System ▸ Firmware menu on real LVFS hardware** (item #43,
|
||||||
`nomarchy-menu firmware`) — on a machine whose firmware/SSD/dock is
|
`nomarchy-menu firmware`) — on a machine whose firmware/SSD/dock is
|
||||||
on LVFS: open Menu ▸ System ▸ **Firmware**; confirm the terminal runs
|
on LVFS: open Menu ▸ System ▸ **Firmware**; confirm the terminal runs
|
||||||
@@ -253,6 +616,30 @@ the **T14s** (webcam case).
|
|||||||
proves the menu row + flow renders; a real capsule write is
|
proves the menu row + flow renders; a real capsule write is
|
||||||
hardware-only.)
|
hardware-only.)
|
||||||
|
|
||||||
|
## Acer Aspire M5-481T only (1366×768 — the narrow-panel case)
|
||||||
|
- [ ] **#139 sheets on a small panel** — Waybar clock → calendar, System ▸
|
||||||
|
Doctor, and the Waybar updates click. Pass = each opens floating and
|
||||||
|
centred at roughly 60%×65% / 55%×70% / 45%×50% of *that* screen (they are
|
||||||
|
computed from the focused monitor now, so this is checking the fallback
|
||||||
|
path and the font, not the arithmetic), fully on-screen, none clipped by
|
||||||
|
the bar. Dev box (2560×1440) already measured exact.
|
||||||
|
- [ ] **#131 menu width on the real narrow panel** — open Menu ▸ Recovery.
|
||||||
|
Pass = every label complete (no ellipsis) and the picker visibly *not*
|
||||||
|
hogging the screen (≤65%, the cap). The geometry was already reproduced
|
||||||
|
pixel-exactly on the dev box (888px + JetBrainsMono 14 = what 65% of 1366
|
||||||
|
produces) and passed, so what is genuinely unproven here is only the
|
||||||
|
Acer's own fontconfig/DPI resolving `ch` the same way — try it under a
|
||||||
|
**JetBrainsMono 14 theme** (summer-day/night, kanagawa), the widest case.
|
||||||
|
|
||||||
|
- [ ] **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
|
## Latitude 5310 / 5410 only
|
||||||
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —
|
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —
|
||||||
general smoke before the next `main → v1` promotion (broader than
|
general smoke before the next `main → v1` promotion (broader than
|
||||||
|
|||||||
@@ -6,6 +6,347 @@ JOURNAL.md grows past ~25 entries, move the tail here.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-10 — #78 Omarchy migrant doc (docs/OMARCHY.md)
|
||||||
|
- **Task:** BACKLOG #78 (promoted from PROPOSED) — Omarchy→Nomarchy
|
||||||
|
one-pager (`VISION § F`).
|
||||||
|
- **Did:** New `docs/OMARCHY.md`: leads with the config-model shift
|
||||||
|
(imperative dotfiles → declarative flake + menu, rollback via
|
||||||
|
generations), then install (ISO/`nomarchy-install` or MIGRATION.md),
|
||||||
|
a familiar-bindings table, theming (`SUPER+T`/`nomarchy-theme-sync`),
|
||||||
|
packages (home.packages / `nix shell`, no AUR), update+rollback
|
||||||
|
(`nomarchy-pull`/`-rebuild`/`-home`, boot-menu generations,
|
||||||
|
`nomarchy-doctor`). Added to `docs/README.md` map + VISION § F ✓.
|
||||||
|
- **Verified:** V0 (docs). Grounded Nomarchy side in code: pulled real
|
||||||
|
binds from `keybinds.nix`; confirmed all 7 cited commands, the `nsearch`
|
||||||
|
alias, and the named slugs (gruvbox/rose-pine/nord) exist. Kept
|
||||||
|
Omarchy-side claims to known facts (Arch, dotfiles, pacman/AUR).
|
||||||
|
- **Pending:** none.
|
||||||
|
- **Next suggestion:** NEXT head #76 `[blocked:hw]`; last open PROPOSED is
|
||||||
|
auto time-of-day theme pair (`§D`, `[big]` — needs splitting).
|
||||||
|
|
||||||
|
## 2026-07-10 — #77 doctor hibernate/sleep section (V2 PASS)
|
||||||
|
- **Task:** BACKLOG #77 (promoted from PROPOSED by Bernardo) — read-only
|
||||||
|
hibernate section in nomarchy-doctor (`VISION § C`, #76 follow-on).
|
||||||
|
- **Did:** Added a hibernate/sleep row group to `nomarchy-doctor.sh`: resume
|
||||||
|
device on cmdline (+ `resume_offset` for a swapfile), disk swap ≥ RAM,
|
||||||
|
zram active, previous-boot suspend/hibernate error. Self-gates (`skip` on
|
||||||
|
swap=0); only ok/warn/skip → never fails the sheet. Added `hibernate` to
|
||||||
|
the `checks.doctor` probe list.
|
||||||
|
- **Verified:** **V2 PASS** — `checks.doctor` VM test green (fresh build,
|
||||||
|
exit 0; asserts the hibernate row renders + healthy run still 0). **V1** —
|
||||||
|
ran on this box's real LUKS+@swap layout: all three `ok` rows render
|
||||||
|
(resume set, swap 34G ≥ RAM, zram 16G) and the sheet reaches its verdict.
|
||||||
|
**V0** bash -n + flake check.
|
||||||
|
- **Gotcha (fixed + MEMORY):** the doctor runs `set -euo pipefail`; my first
|
||||||
|
cut aborted before the verdict because a no-match `grep` inside `$(…)` and
|
||||||
|
a `cond && action` both trip set -e. Fixed with `|| true` / `|| echo 0` and
|
||||||
|
an `if`. First VM run caught it (exit 1, no verdict) — the test earned its keep.
|
||||||
|
- **Pending:** none — fully agent-verifiable (read-only checks, no V3).
|
||||||
|
- **Next suggestion:** NEXT head is #76 `[blocked:hw]`; remaining PROPOSED
|
||||||
|
(Omarchy doc §F, auto time-of-day theme §D) await triage.
|
||||||
|
|
||||||
|
## 2026-07-10 — QA sweep (clean) + VISION-gap pass → 3 PROPOSED
|
||||||
|
- **Task:** Bernardo — QA/drift sweep, then a VISION-gap pass with proposals.
|
||||||
|
- **Did (sweep):** Fanned out two subagents (Bernardo OK'd spawning for
|
||||||
|
efficiency): nomarchy-runner ran the deterministic checks + `bash -n`/
|
||||||
|
`py_compile` (all PASS; the 4 "needs-args" scripts pass via their flake
|
||||||
|
derivations); nomarchy-scout hunted fuzzy drift (dead code, stale refs,
|
||||||
|
TODOs, changed-area contradictions, count/version) — **all clean**.
|
||||||
|
Spot-checked scout's count claim myself: 24 theme JSONs / 24 previews /
|
||||||
|
README "24 presets" — consistent. Eval sweep green. **No fixes needed.**
|
||||||
|
- **Did (VISION):** Verified candidate gaps against the tree before
|
||||||
|
proposing (killed two false gaps — nomarchy-rebuild/home already tail logs
|
||||||
|
+ point at doctor on failure = §A shipped; `nvd diff` = §B covered; System
|
||||||
|
› Firmware menu already exists). Filed 3 **PROPOSED** (verified open):
|
||||||
|
doctor hibernate/sleep section (§C, top Day-2 pick, #76 follow-on),
|
||||||
|
Omarchy migrant one-pager (§F), auto time-of-day theme pair (§D, `[big]`).
|
||||||
|
- **Verified:** V0 (agent-notes/docs only). Sweep evidence above.
|
||||||
|
- **Pending:** none. Proposals await Bernardo's triage into a tier.
|
||||||
|
- **Next suggestion:** if a PROPOSED is promoted, the doctor hibernate
|
||||||
|
section is the cleanest next build; else NEXT #76 stays `[blocked:hw]`.
|
||||||
|
|
||||||
|
## 2026-07-10 — Icon overrides: user-facing docs (OVERRIDES.md)
|
||||||
|
- **Task:** Bernardo asked whether the session's features need user docs.
|
||||||
|
- **Did:** Hibernation already covered (README swapfile line, MIGRATION.md
|
||||||
|
runbook, discoverable Power-menu row) — no gap. Icons had one: the only
|
||||||
|
doc was the template comment. Added an "Icon pack" subsection to
|
||||||
|
`docs/OVERRIDES.md` § Appearance — `nomarchy-theme-sync set icons "<name>"`,
|
||||||
|
the known-packs table, the ~1 GiB/opt-in rationale, and that the choice is
|
||||||
|
sticky across `apply` (presets carry no icon field; verified `deep_merge`).
|
||||||
|
- **Verified:** command path confirmed (`cmd_set` writes any dotted key;
|
||||||
|
`icons` is a schema key). **Built all 4 packs and listed real theme dirs**
|
||||||
|
to fix the examples — caught Qogir using capital `Qogir-Dark`/`Light`
|
||||||
|
(lowercase would silently fall back to hicolor). Docs-only (V0).
|
||||||
|
- **Pending:** none.
|
||||||
|
- **Next suggestion:** NEXT #76 `[blocked:hw]`; LATER/QA-sweep next.
|
||||||
|
|
||||||
|
## 2026-07-10 — Icon themes: opt-in cross-pack overrides (no default bloat)
|
||||||
|
- **Task:** LATER "per-theme icon overrides / more icon packs". Recon found
|
||||||
|
papirus is already ~1 GiB and ships only Papirus/Dark/Light (no free
|
||||||
|
variety); real variety needs extra packs at ~hundreds of MB each. Bernardo
|
||||||
|
chose the **opt-in, no-default-bloat** shape (over shipping packs).
|
||||||
|
- **Did:** `theme.nix` resolves `nomarchy.theme.iconThemePackage` from a
|
||||||
|
small `iconPacks` registry (papirus·tela·qogir·reversal·numix, by name
|
||||||
|
prefix). Default themes name `Papirus-*` → resolves to the *plain* papirus
|
||||||
|
derivation (no symlinkJoin, identical closure); only a theme naming another
|
||||||
|
pack's set symlinkJoins that one pack in. `stylix.nix` uses it
|
||||||
|
(mkDefault). Opt-in note in `templates/downstream/home.nix`; ROADMAP §
|
||||||
|
Icon themes updated (incl. the deliberate no-default-packs decision).
|
||||||
|
- **Verified:** **V1** — HM activationPackage builds (exit 0). Eval proofs:
|
||||||
|
default `iconThemePackage` = plain `papirus-icon-theme-*` (zero delta);
|
||||||
|
with `icons="Tela-dark"` → `nomarchy-icon-themes` symlinkJoin (papirus+tela)
|
||||||
|
and name flows, **without building tela**. All 5 pack attrs exist in the
|
||||||
|
pinned nixpkgs. `template-sot` + `option-docs` + `flake check` green.
|
||||||
|
- **Pending:** none. No default visual change (identical papirus), so no
|
||||||
|
screenshot needed; the opt-in render is the downstream user's to see.
|
||||||
|
- **Next suggestion:** NEXT still #76 `[blocked:hw]`; LATER/QA-sweep next.
|
||||||
|
|
||||||
|
## 2026-07-10 — #76 slice 3: hibernate VM test (V2 PASS) → #76 agent-complete
|
||||||
|
- **Task:** BACKLOG #76 slice 3 — the mandatory V2 hibernate test + V3 queue.
|
||||||
|
- **Did:** Added `checks.hibernate` (flake.nix), modeled on nixpkgs'
|
||||||
|
hibernate.nix but wired to Nomarchy's #76 risk: imports oom.nix (zram
|
||||||
|
default-on), disk swap at lower priority + explicit `boot.resumeDevice`,
|
||||||
|
systemd-initrd. Test drives hibernate→power-off→resume→crash: ramfs marker
|
||||||
|
survives resume, a crash-boot loses it (real resume, not a survivor), and
|
||||||
|
asserts zram@100 + disk-swap<100 so the image lands on disk not volatile
|
||||||
|
zram. Queued the V3 laptop steps (real LUKS+@swap+offset resume + no-swap
|
||||||
|
notify) in HARDWARE-QUEUE. Collapsed #76 to a `[blocked:hw]` stub; design
|
||||||
|
record → ROADMAP § "Hibernation + zram by default".
|
||||||
|
- **Verified:** **V2 PASS** — `nix build .#checks.x86_64-linux.hibernate`
|
||||||
|
built the driver fresh and exited 0 (all assertions held). `nix flake
|
||||||
|
check --no-build` green.
|
||||||
|
- **Pending:** only V3 (dev box / laptop) — #76's last check; close on PASS.
|
||||||
|
- **Next suggestion:** #76 is now blocked:hw; NEXT head falls to LATER/
|
||||||
|
PROPOSED triage or a QA sweep — pick per LOOP.md orientation next session.
|
||||||
|
|
||||||
|
## 2026-07-10 — #76 slice 2: Hibernate notify-on-failure
|
||||||
|
- **Task:** BACKLOG #76 remaining slice 2 — per the settled "keep row, notify
|
||||||
|
on failure" call.
|
||||||
|
- **Did:** `modules/home/rofi.nix` power menu Hibernate arm now runs
|
||||||
|
`systemctl hibernate 2>/dev/null || notify-send "Hibernate" "…see
|
||||||
|
docs/MIGRATION.md"`. On success the call blocks until resume, so the notify
|
||||||
|
only fires on a genuine failure (e.g. swap=0 no-swap machine). Row stays
|
||||||
|
unconditional (no hiding).
|
||||||
|
- **Verified:** **V1** — `nix build …homeConfigurations.nomarchy.activationPackage`
|
||||||
|
green (rebuilds `nomarchy-menu.drv`; `writeShellScriptBin` runs bash -n at
|
||||||
|
build). Control-flow proof on the arm extracted from the built script:
|
||||||
|
failing `systemctl` → notify fires; succeeding → no notify. `nix flake
|
||||||
|
check --no-build` green.
|
||||||
|
- **Pending:** slice 3 = the V2 hibernate→resume VM test + V3 laptop
|
||||||
|
(Hibernate→power off→resume on the LUKS+@swap machine; plus confirm the
|
||||||
|
no-swap notify surfaces in a real session). Both remain open on #76.
|
||||||
|
- **Next suggestion:** slice 3 — add a `checks.*` hibernate VM test (as far
|
||||||
|
as QEMU allows) and queue the V3 laptop steps in HARDWARE-QUEUE.
|
||||||
|
|
||||||
|
## 2026-07-10 — #76 slice 1: MIGRATION.md hibernation runbook
|
||||||
|
- **Task:** BACKLOG #76 remaining slice 1 — docs runbook for enabling
|
||||||
|
hibernation on an existing machine (per the settled "docs not tool" call).
|
||||||
|
- **Did:** Added § "Enabling hibernation on an existing machine (no reinstall)"
|
||||||
|
to `docs/MIGRATION.md`: create `@swap` subvol (subvolid=5) + `mkswapfile`
|
||||||
|
(NOCOW), read `map-swapfile -r` offset, wire `fileSystems."/swap"` +
|
||||||
|
`swapDevices` + `resumeDevice` + `resume_offset` into system.nix. Flagged
|
||||||
|
the mount as required on the hand-edit path (installer gets it from
|
||||||
|
disko-gen hardware-config). Notes zram-priority reservation, no-LUKS
|
||||||
|
variant, `swap=0` opt-out. Worked example = this dev machine.
|
||||||
|
- **Verified:** **V0** — read-only commands run live against this machine's
|
||||||
|
real LUKS(`cryptroot`)+btrfs(`@`)+`/swap/swapfile` layout (RAM 31G, fsuuid,
|
||||||
|
`findmnt SOURCE`, `mkswapfile` flags, zram@100 vs swapfile@-1 confirms the
|
||||||
|
design intent). `nix flake check --no-build` green.
|
||||||
|
- **Pending:** slice 2 — Hibernate row notify-on-failure (V1/V2); then the
|
||||||
|
V2 hibernate VM test + V3 laptop.
|
||||||
|
- **Next suggestion:** slice 2 (menu notify-on-failure).
|
||||||
|
|
||||||
|
## 2026-07-10 — #76 hibernation design calls (no commit yet → this bookkeeping)
|
||||||
|
- **Task:** BACKLOG #76 design slice — Bernardo made the open calls.
|
||||||
|
- **Did:** Recon showed the hibernation half is **already built for new
|
||||||
|
installs** (installer defaults swap=RAM hibernation-ready `@swap` swapfile;
|
||||||
|
`patch-template.py` writes swapDevices/resumeDevice/resume_offset; Power
|
||||||
|
menu Hibernate row + LUKS/hyprlock handling exist). Settled 3 calls:
|
||||||
|
(1) swap sizing = **exactly RAM** (no change); (2) migration = **docs
|
||||||
|
runbook in MIGRATION.md**, not a tool; (3) no-swap Hibernate = **keep row,
|
||||||
|
notify on failure**. Folded into BACKLOG #76 (design ✓, re-sliced remaining:
|
||||||
|
MIGRATION.md runbook + notify-on-failure menu) and Decisions.
|
||||||
|
- **Verified:** **V0** — `nix flake check --no-build` green (docs-only).
|
||||||
|
- **Pending:** two remaining slices below; V2 hibernate VM test + V3 laptop.
|
||||||
|
- **Next suggestion:** slice 1 — `docs/MIGRATION.md` hibernation-enable runbook
|
||||||
|
(V0), then slice 2 — Hibernate notify-on-failure (V1/V2).
|
||||||
|
|
||||||
|
## 2026-07-10 — #76 zram slice: compressed-RAM swap default
|
||||||
|
- **Task:** BACKLOG #76 (`[big]`) — split; took the low-risk zram-default slice.
|
||||||
|
- **Did:** `zramSwap` on by default in `modules/nixos/oom.nix` (zstd, 50% RAM,
|
||||||
|
priority 100) as the memory-pressure layer before earlyoom, following the
|
||||||
|
earlyoom mkDefault precedent (no toggle). High priority keeps day-to-day
|
||||||
|
paging in zram so a disk swapfile stays reserved for #76's hibernate image.
|
||||||
|
Added `checks.zram-swap` runNixOSTest (imports oom.nix). Split #76 in
|
||||||
|
BACKLOG → remainder is the hibernation half (swapfile/resume/installer).
|
||||||
|
- **Verified:** **V2** — `nix build .#checks…zram-swap` PASS: /dev/zram0 active
|
||||||
|
in /proc/swaps, `[zstd]` comp_algorithm, priority 100. **V1** full distro
|
||||||
|
toplevel builds. **V0** flake check --no-build green.
|
||||||
|
- **Pending:** hibernation half of #76 still open (needs hibernate→resume VM
|
||||||
|
test + V3 laptop). No V3 for zram (under-load tuning is secondary).
|
||||||
|
- **Next suggestion:** #76 hibernation half — design default swapSize + the
|
||||||
|
resume/installer wiring; it's the boot-critical, V2-hibernate + V3 part.
|
||||||
|
|
||||||
|
## 2026-07-10 — #41 hardware-confirmed: OpenFile portal floats (V3 close)
|
||||||
|
- **Task:** BACKLOG #41 — final on-hardware confirmation (Bernardo reported PASS).
|
||||||
|
- **Did:** #41 already out of NEXT + portal rule shipped
|
||||||
|
(`hyprland.nix` xdg-desktop-portal-gtk float/center). Bernardo verified the
|
||||||
|
GTK OpenFile file-picker floats on real hardware; dropped the "OpenFile not
|
||||||
|
re-probed / optional residual" caveats in HARDWARE-QUEUE — #41 fully closed.
|
||||||
|
- **Verified:** **V3** (Bernardo, Latitude). No code change. Bookkeeping only;
|
||||||
|
QA-sweep guards run green this session: `nix flake check --no-build`,
|
||||||
|
option-docs/template-sot/theme-sync-validate builds, contrast 24×7.
|
||||||
|
- **Pending:** none for #41. NEXT head is #76 (hibernation + zram, `[big]`).
|
||||||
|
- **Next suggestion:** split #76 — take the low-risk **zram-default** slice
|
||||||
|
(V2-testable headlessly) before the boot-critical hibernate/resume work.
|
||||||
|
|
||||||
|
## 2026-07-10 — Close #41 GTK portal float rule
|
||||||
|
- **Task:** BACKLOG #41 residual (GTK file-chooser portal class).
|
||||||
|
- **Did:** Added float+center for `xdg-desktop-portal-gtk` /
|
||||||
|
`.xdg-desktop-portal-gtk-wrapped` (desktop + libexec name from package).
|
||||||
|
Polkit already PASS on Latitude. Removed #41 from NEXT; only #20 remains.
|
||||||
|
- **Verified:** **V0** windowrule-syntax check path unchanged; class from
|
||||||
|
package `xdg-desktop-portal-gtk.desktop` / community configs. SoftGL
|
||||||
|
still cannot materialize portal dialogs.
|
||||||
|
- **Pending:** optional hardware spot-check OpenFile floats (HARDWARE-QUEUE).
|
||||||
|
- **Next suggestion:** #20 is `[human]` only — or LATER/PROPOSED triage.
|
||||||
|
|
||||||
|
## 2026-07-10 — Product calls: charge live, identity, btop, NVIDIA skip
|
||||||
|
- **Task:** Bernardo decisions on remaining PROPOSED.
|
||||||
|
- **Did:** (1) Instant charge-limit: udev GROUP=users on threshold,
|
||||||
|
menu/powermgmt live sysfs + state, oneshot reads live theme-state +
|
||||||
|
AC re-apply. (2) Look & Feel already had Theme/wallpaper/night light;
|
||||||
|
added Reset wallpaper (auto). (3) NVIDIA first-class deferred past v1
|
||||||
|
(HARDWARE.md). (4) Identity retunes: white L-steps, lumon subtext,
|
||||||
|
hackerman surface+UI warn/bad, matte-black subtext, miasma bad.
|
||||||
|
(5) Hand `btop.theme` for 9 identity/whole-swap slugs from palette map.
|
||||||
|
- **Verified:** **V0** contrast 24×7; **V1** battery-charge-limit VM
|
||||||
|
(udev restart). **V3** live charge write on real laptop (HARDWARE-QUEUE).
|
||||||
|
- **Next suggestion:** only #20/#41 blocked, or Decisions.
|
||||||
|
|
||||||
|
## 2026-07-10 — Summer pair polish + status CSS + preview recapture
|
||||||
|
- **Task:** finish agent-able PROPOSED theme leftovers.
|
||||||
|
- **Did:** summer-day/night — JSON hierarchy (night subtext/muted/overlay
|
||||||
|
ladder; day overlay≠muted); waybar CSS tokens track JSON roles; font
|
||||||
|
13px both; day battery 25/10; VPN pill + full status states
|
||||||
|
(.recording/.available/.on/.activated/battery warn). Recaptured
|
||||||
|
executive-slate + neon-glass `preview.png` via theme-shot desktop
|
||||||
|
(480×270). Closed those PROPOSED lines.
|
||||||
|
- **Verified:** **V0** contrast 24×7 + wholeswap; **V2** theme-shot
|
||||||
|
summer-day/night/executive-slate/neon-glass — viewed bar crops +
|
||||||
|
previews. Identity taste retunes stay PROPOSED.
|
||||||
|
- **Next suggestion:** only blocked NEXT (#20/#41) or product Decisions.
|
||||||
|
|
||||||
|
## 2026-07-10 — #74 matrix flags + #75 V2 install (default arm)
|
||||||
|
- **Task:** NEXT #74 unattended matrix · #75 SoT V2 install after ISO.
|
||||||
|
- **Did:** `NOMARCHY_TEST_SWAP_GB` / `NOMARCHY_TEST_NO_LUKS` drive
|
||||||
|
`tools/test-install.sh` go.sh + first-boot LUKS skip; nixpkgs OVMF
|
||||||
|
fallback. Full offline install V2 (SWAP=2 LUKS) after live ISO rebuild
|
||||||
|
with starter packages — first-boot themed Boreal, no red banner.
|
||||||
|
Closed #74/#75. Other matrix arms scripted (not full-ISO re-run).
|
||||||
|
- **Verified:** **V2** — install powered off ~460s; first-boot.png
|
||||||
|
viewed (`/tmp/nomarchy-vm-75/first-boot.png`): themed wallpaper +
|
||||||
|
waybar (logo, clock, modules), doctor glyph, no autogen banner.
|
||||||
|
- **Next suggestion:** NEXT only #20 [human] + #41 [blocked:hw].
|
||||||
|
|
||||||
|
## 2026-07-10 — Loop batch land #64–#73 (parallel worktrees)
|
||||||
|
- **Task:** agent-actionable NEXT #64–#73 via worktree agents; land on main.
|
||||||
|
- **Did:** #64 MIGRATION snapshots · #65 real-uid chown · #66 theme-state
|
||||||
|
fail-closed reader · #67/#68 theme fidelity+ANSI · #69/#70 audit+import
|
||||||
|
tools · #71 IR portal docs · #72 template-sot check · #73 MOTD/first-boot
|
||||||
|
fingerprint+doctor tips. Agents did not push; parent cherry-picked
|
||||||
|
(stripped agent BACKLOG races). Closed #64–#73.
|
||||||
|
- **Verified:** **V0/V1** — theme-contrast 24×7 pass; template-sot green;
|
||||||
|
bash -n install; flake check --no-build green.
|
||||||
|
- **Next suggestion:** #74 unattended matrix + #75 V2 install (KVM).
|
||||||
|
|
||||||
|
## 2026-07-10 — Promote agent-actionable PROPOSED → NEXT #64–#75
|
||||||
|
- **Task:** Bernardo: bump non-hw/non-human PROPOSED categories 1+2.
|
||||||
|
- **Did:** **#64** MIGRATION snapshots · **#65** installer chown ·
|
||||||
|
**#66** mkFlake theme-state errors · **#67** theme fidelity nits ·
|
||||||
|
**#68** #52 residual ANSI · **#69** audit identity exemptions ·
|
||||||
|
**#70** import-pipeline hierarchy · **#71** IR portal docs (a) ·
|
||||||
|
**#72** template SoT CI · **#73** hardware hints residual · **#74**
|
||||||
|
unattended install matrix · **#75** SoT V2 install. Breadcrumbs in
|
||||||
|
PROPOSED; actionable items sit above #20/#41 in NEXT.
|
||||||
|
- **Verified:** V0 (backlog only).
|
||||||
|
- **Next suggestion:** queue head **#64** (docs) or batch #67/#68 themes.
|
||||||
|
|
||||||
|
## 2026-07-10 — PROPOSED cleanup (stale exam residue)
|
||||||
|
- **Task:** Bernardo: clean stale PROPOSED items after A/B/C + #52 ship.
|
||||||
|
- **Did:** Rewrote PROPOSED to open work only — dropped #52 shipped
|
||||||
|
high-ROI bullets, neon-glass quarantine, empty/shipped installer
|
||||||
|
audit sections, template seed drift (keys already present), soft
|
||||||
|
gaps fixed by #50, and “no action” kanagawa/boreal notes. Kept
|
||||||
|
product day-2, installer follow-ups, theme polish residuals, tooling.
|
||||||
|
- **Verified:** V0 (backlog hygiene); spot-checked template keys,
|
||||||
|
latte/mocha #52 fixes, usbutils, package exports.
|
||||||
|
- **Next suggestion:** triage the lean PROPOSED list or HARDWARE-QUEUE.
|
||||||
|
|
||||||
|
## 2026-07-10 — Pending closeout: #14, #57 V2, #41 slice
|
||||||
|
- **Task:** pending V2/CI/float leftovers after A/B/C batch.
|
||||||
|
- **Did:** **#14 closed** — first scheduled lock bump landed
|
||||||
|
`8fded63` (2026-07-06 Mon 05:20 UTC) as `nomarchy-bump` /
|
||||||
|
`chore(lock): scheduled upstream bump` (Actions token push works).
|
||||||
|
**#57 V2** — `checks.live-install-entry` asserts live HM gen has
|
||||||
|
`nomarchy-install.desktop` (Name/Terminal/Exec) + installer bin +
|
||||||
|
Tools menu gate in `nomarchy-menu`. **#41 slice** — float/center rules
|
||||||
|
for hyprpolkitagent + pinentry-qt from package app-id strings;
|
||||||
|
`tools/capture-float-classes.nix` harness (softGL empty clients);
|
||||||
|
residual GTK portal stays NEXT `#41 [blocked:hw]`. HARDWARE-QUEUE
|
||||||
|
float confirm steps added.
|
||||||
|
- **Verified:** **V2** live-install-entry green; windowrule-syntax green.
|
||||||
|
SoftGL class capture inconclusive (empty clients). **V3** float confirm
|
||||||
|
+ fingerprint/#60 still HARDWARE-QUEUE. **#20** still `[human]`.
|
||||||
|
- **Next suggestion:** Bernardo: #20 KVM runner, or HARDWARE-QUEUE pass.
|
||||||
|
|
||||||
|
## 2026-07-10 — #55 fingerprint menu + #56 human rebuild errors
|
||||||
|
- **Task:** NEXT #55 (System › Fingerprint) + #56 (rebuild failure UX).
|
||||||
|
- **Did:** #55 — System row self-gated on fprintd-list; enroll/list/verify/
|
||||||
|
delete-all terminal flows; Use for login writes
|
||||||
|
`settings.fingerprint.pam` (hardware.nix default from stateFile);
|
||||||
|
HARDWARE.md §5 + HARDWARE-QUEUE V3 enroll. #56 — sys-update /
|
||||||
|
sys-rebuild / home-update tee last 40 lines + nomarchy-doctor pointer;
|
||||||
|
theme-sync run_switch notify/die text. Closed #55/#56.
|
||||||
|
- **Verified:** **V1** — HM template rebuild (menu + theme-sync);
|
||||||
|
option-docs green; failure-footer shell smoke. **V3 pending:** real
|
||||||
|
fingerprint enroll (queue). **V2 pending:** forced bad sys-rebuild in
|
||||||
|
VM (footer text pure/logic covered).
|
||||||
|
- **Next suggestion:** queue was A–C; remaining NEXT is blocked items
|
||||||
|
(#14/#20/#41) or PROPOSED triage.
|
||||||
|
|
||||||
|
## 2026-07-10 — #58 nomarchy-detect-hw CLI
|
||||||
|
- **Task:** NEXT #58 — post-install re-probe package (HARDWARE.md §8).
|
||||||
|
- **Did:** `pkgs/nomarchy-detect-hw` wrapping installer’s hardware-db;
|
||||||
|
human report + snippets + `--raw` protocol; overlay + systemPackages;
|
||||||
|
HARDWARE.md §8 updated. Closed #58.
|
||||||
|
- **Verified:** **V1** — package builds; ran on this AMD laptop: correct
|
||||||
|
MODULE/NOMARCHY lines (amd cpu/gpu, laptop, ssd, fingerprint, IR cam,
|
||||||
|
NPU) and printable system.nix snippets.
|
||||||
|
- **Next suggestion:** #55 fingerprint menu or #56 human rebuild errors.
|
||||||
|
|
||||||
|
## 2026-07-10 — #57 live Install Nomarchy affordance
|
||||||
|
- **Task:** NEXT #57 — durable live-ISO install surface (not only toast).
|
||||||
|
- **Did:** `xdg.desktopEntries.nomarchy-install` (Terminal=true) on live
|
||||||
|
HM; Tools › Install Nomarchy self-gated on `command -v nomarchy-install`;
|
||||||
|
welcome toast wording updated. Closed #57.
|
||||||
|
- **Verified:** **V1** — live config eval exposes entry name/exec;
|
||||||
|
`downstream-template-home` rebuilds with menu gate. **V2 pending:**
|
||||||
|
ISO rebuild + smoke that the .desktop appears in the session.
|
||||||
|
- **Next suggestion:** #58 `nomarchy-detect-hw` CLI.
|
||||||
|
|
||||||
|
## 2026-07-10 — #63 generated Waybar logo + powermenu
|
||||||
|
- **Task:** NEXT #63 — reverse parity: generated bar lacked whole-swap
|
||||||
|
logo → menu and power icon → power menu.
|
||||||
|
- **Did:** `custom/nomarchy` (left, Nomarchy.ttf U+F000) + `custom/powermenu`
|
||||||
|
(right, U+F011 → `nomarchy-menu power`) in generated settings/style.
|
||||||
|
Fixed Nix `\uf000` pitfall (no `\u` escapes — use literal UTF-8).
|
||||||
|
Whole-swaps already had both; no JSON/CSS edits. Closed #63.
|
||||||
|
- **Verified:** **V2** — `THEME=tokyo-night` theme-shot: left monogram N,
|
||||||
|
right power glyph, accent styling; config modules-left/right correct.
|
||||||
|
Crop paths under `/tmp/theme-shot-tn2-*.png`.
|
||||||
|
- **Next suggestion:** product path #55 fingerprint menu (or #57 live install).
|
||||||
|
|
||||||
## 2026-07-10 — #60 name-agnostic system-battery scan
|
## 2026-07-10 — #60 name-agnostic system-battery scan
|
||||||
- **Task:** NEXT #60 — align charge-limit / menu / waybar / doctor /
|
- **Task:** NEXT #60 — align charge-limit / menu / waybar / doctor /
|
||||||
installer chassis with battery-notify’s type=Battery + !scope=Device
|
installer chassis with battery-notify’s type=Battery + !scope=Device
|
||||||
|
|||||||
2134
agent/JOURNAL.md
2134
agent/JOURNAL.md
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,12 @@ here the moment a debugging session teaches you something a future
|
|||||||
iteration would otherwise rediscover.
|
iteration would otherwise rediscover.
|
||||||
|
|
||||||
## Testing & VM recipes
|
## 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
|
(usually identical to desktop). Guest asserts on
|
||||||
`~/.config/btop/themes/nomarchy.theme` prove baking; the TUI look is
|
`~/.config/btop/themes/nomarchy.theme` prove baking; the TUI look is
|
||||||
hardware/GL tier (HARDWARE-QUEUE).
|
hardware/GL tier (HARDWARE-QUEUE).
|
||||||
@@ -70,14 +75,17 @@ iteration would otherwise rediscover.
|
|||||||
examples to crib from: `distro-id` (boots + `switch-to-configuration
|
examples to crib from: `distro-id` (boots + `switch-to-configuration
|
||||||
dry-activate`), `hardware-toggles` (kernel cmdline/PAM assertions),
|
dry-activate`), `hardware-toggles` (kernel cmdline/PAM assertions),
|
||||||
`battery-charge-limit` (fake Mains adapter via `test_power`, real udev
|
`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
|
- Themed-desktop screenshots work headlessly: software-GL Hyprland
|
||||||
(`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP
|
(`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP
|
||||||
dump — prototyped 2026-06-19, kept as the fallback for theme previews
|
dump — prototyped 2026-06-19, kept as the fallback for theme previews
|
||||||
(§ Visual theme picker).
|
(§ Visual theme picker).
|
||||||
- Hyprland/Ghostty need guest GL (`virtio-vga-gl`, `gl=on`) in
|
- Hyprland needs guest GL (`virtio-vga-gl`, `gl=on`) in interactive QEMU
|
||||||
interactive QEMU or the session won't start; black screen ≈ missing GL
|
or the session won't start; black screen ≈ missing GL
|
||||||
(docs/TESTING.md § gotchas).
|
(docs/TESTING.md § gotchas). Kitty is the sole terminal (no Ghostty).
|
||||||
- No KVM = slow, not broken; don't read slowness as failure.
|
- No KVM = slow, not broken; don't read slowness as failure.
|
||||||
|
|
||||||
## Known-broken / watchlist
|
## Known-broken / watchlist
|
||||||
@@ -92,10 +100,10 @@ iteration would otherwise rediscover.
|
|||||||
- **NixOS release bump is a trap:** the discarded attempt
|
- **NixOS release bump is a trap:** the discarded attempt
|
||||||
(branch deleted 2026-06-22) hit a Hyprland OOM blocker; a redo is a
|
(branch deleted 2026-06-22) hit a Hyprland OOM blocker; a redo is a
|
||||||
deliberate `v2`, never part of routine lock bumps.
|
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
|
state write re-copies the source before eval — the wallpapers-artifact
|
||||||
split (BACKLOG LATER) is the decided fix (§ Faster switches).
|
split (BACKLOG LATER) is the decided fix (§ Faster switches).
|
||||||
- **Friendly theme-state load** (`modules/theme-state-read.nix`, #66):
|
- **Friendly theme-state load** (`modules/state-read.nix`, #66):
|
||||||
`builtins.tryEval` does **not** catch `readFile`/`fromJSON` failures —
|
`builtins.tryEval` does **not** catch `readFile`/`fromJSON` failures —
|
||||||
gate with `pathExists` + empty/non-object checks before `fromJSON`.
|
gate with `pathExists` + empty/non-object checks before `fromJSON`.
|
||||||
Subtle JSON syntax errors still surface from nlohmann (line/col);
|
Subtle JSON syntax errors still surface from nlohmann (line/col);
|
||||||
@@ -103,6 +111,15 @@ iteration would otherwise rediscover.
|
|||||||
check onto the whole return set or lazy attr access skips it.
|
check onto the whole return set or lazy attr access skips it.
|
||||||
|
|
||||||
## Design invariants
|
## 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):
|
- **Waybar status is never color-only** (item 28 sweep, iteration #69):
|
||||||
every status module must distinguish its states by SHAPE (glyph) or
|
every status module must distinguish its states by SHAPE (glyph) or
|
||||||
presence (self-hide), never color alone — good/warn/bad collapse under
|
presence (self-hide), never color alone — good/warn/bad collapse under
|
||||||
@@ -120,6 +137,34 @@ iteration would otherwise rediscover.
|
|||||||
without a hierarchy pass.
|
without a hierarchy pass.
|
||||||
|
|
||||||
## Gotchas (cost a debugging session once)
|
## 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
|
- Waybar `layer: top` renders above **even real-fullscreen windows** — the
|
||||||
bar draws over a fullscreen video. `layer: bottom` lets the fullscreen
|
bar draws over a fullscreen video. `layer: bottom` lets the fullscreen
|
||||||
surface cover it while the exclusive zone still reserves the bar's space
|
surface cover it while the exclusive zone still reserves the bar's space
|
||||||
@@ -155,6 +200,10 @@ iteration would otherwise rediscover.
|
|||||||
- Hyprland 0.55 renames `stayfocused` → `stay_focused` (and similar
|
- Hyprland 0.55 renames `stayfocused` → `stay_focused` (and similar
|
||||||
underscore effects); `stayfocused 1` is `invalid field type` at parse
|
underscore effects); `stayfocused 1` is `invalid field type` at parse
|
||||||
(§ polkit workspace rules, 2026-07-10 hardware).
|
(§ 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
|
- Hyprland 0.53 rewrote window rules: `windowrulev2` is a hard error and
|
||||||
the old rule-first `float, class:^…$` no longer parses — both surface a
|
the old rule-first `float, class:^…$` no longer parses — both surface a
|
||||||
red config-error banner on the default desktop. Hyprlang legacy form is
|
red config-error banner on the default desktop. Hyprlang legacy form is
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ consumes the palette from the state JSON, never a side file.
|
|||||||
## Design responsibilities
|
## Design responsibilities
|
||||||
|
|
||||||
1. **Holistic design:** a theme spans Hyprland (borders, shadows,
|
1. **Holistic design:** a theme spans Hyprland (borders, shadows,
|
||||||
animations), Waybar, Ghostty, btop, fastfetch, rofi, and wallpaper —
|
animations), Waybar, Kitty, btop, fastfetch, rofi, and wallpaper —
|
||||||
one coherent identity, no unthemed corner.
|
one coherent identity, no unthemed corner.
|
||||||
2. **Color theory:** create or adapt advanced palettes. Draw inspiration
|
2. **Color theory:** create or adapt advanced palettes. Draw inspiration
|
||||||
from established aesthetics (Everforest, Nord, Gruvbox) or r/unixporn
|
from established aesthetics (Everforest, Nord, Gruvbox) or r/unixporn
|
||||||
|
|||||||
@@ -137,7 +137,10 @@ Maintenance follows the same ladder:
|
|||||||
- **Flake lock bumps**: treat as maximum-blast-radius changes. Build,
|
- **Flake lock bumps**: treat as maximum-blast-radius changes. Build,
|
||||||
boot the VM, run the full regression checklist, and do a visual
|
boot the VM, run the full regression checklist, and do a visual
|
||||||
spot-check of the session (themes can shift with upstream package
|
spot-check of the session (themes can shift with upstream package
|
||||||
changes). Never merge a lock bump on "it evaluates".
|
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`,
|
- **Theme imports / new themes**: import via `tools/import-palettes.py`,
|
||||||
then the full §3 visual protocol; additionally verify the theme-switch
|
then the full §3 visual protocol; additionally verify the theme-switch
|
||||||
round trip (into the new theme and back out).
|
round trip (into the new theme and back out).
|
||||||
|
|||||||
136
docs/HARDWARE.md
136
docs/HARDWARE.md
@@ -3,6 +3,10 @@
|
|||||||
How Nomarchy enables CPUs, GPUs, laptops, firmware, and peripherals — and
|
How Nomarchy enables CPUs, GPUs, laptops, firmware, and peripherals — and
|
||||||
what to do when your machine is not in the happy path.
|
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
|
> **Queue:** [`agent/BACKLOG.md`](../agent/BACKLOG.md) (PROPOSED › Hardware
|
||||||
> product). Product framing: [`VISION.md`](VISION.md) § A. Design history:
|
> product). Product framing: [`VISION.md`](VISION.md) § A. Design history:
|
||||||
> [`ROADMAP.md`](ROADMAP.md). Docs map: [`README.md`](README.md).
|
> [`ROADMAP.md`](ROADMAP.md). Docs map: [`README.md`](README.md).
|
||||||
@@ -132,10 +136,12 @@ confirm → `fwupdmgr update`. It never auto-flashes — `fwupdmgr update`
|
|||||||
confirms each device and prompts for the reboot a capsule needs; pillar 1
|
confirms each device and prompts for the reboot a capsule needs; pillar 1
|
||||||
(rock-stable) forbids silent BIOS writes.
|
(rock-stable) forbids silent BIOS writes.
|
||||||
|
|
||||||
**Hints (#43 / #73):** MOTD cheat-sheet line + `nomarchy-control-center
|
**Hints (#43 / #73):** MOTD cheat-sheet line when `fwupdmgr` is on PATH.
|
||||||
--first-boot` tip when `fwupdmgr` is on PATH. Fingerprint / doctor tips
|
Fingerprint / doctor tips follow the same pattern (fingerprint MOTD only
|
||||||
follow the same pattern (fingerprint MOTD only when
|
when `nomarchy.hardware.fingerprint.enable`). A one-shot toast in the
|
||||||
`nomarchy.hardware.fingerprint.enable`).
|
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
|
**Still queued:** a Doctor “updates available” check, and
|
||||||
Waybar/updates-panel integration.
|
Waybar/updates-panel integration.
|
||||||
@@ -167,29 +173,79 @@ Elan, …) via `lsusb` or `/sys/bus/usb/.../idVendor`. On hit:
|
|||||||
```nix
|
```nix
|
||||||
nomarchy.hardware.fingerprint.enable = true; # services.fprintd
|
nomarchy.hardware.fingerprint.enable = true; # services.fprintd
|
||||||
# nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)
|
# nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)
|
||||||
|
# nomarchy.hardware.fingerprint.parallel = false; # sequential prompt instead
|
||||||
```
|
```
|
||||||
|
|
||||||
### Enroll (menu or CLI)
|
### Enroll (menu or CLI)
|
||||||
|
|
||||||
**Shipped #55:** System › Fingerprint (self-gated when `fprintd-list` is
|
**Shipped #55:** System › Fingerprint (self-gated when `fprintd-list` is
|
||||||
on PATH) — Enroll / List / Verify / Delete all, plus **Use for login**
|
on PATH) — a single **Fingerprint (on/off)** switch, plus Enroll / List /
|
||||||
which writes `settings.fingerprint.pam` and applies on the next
|
Verify / Delete all (all usable while it's off — turning it on needs an
|
||||||
`sys-rebuild` (option default follows theme-state.json).
|
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
|
**Hints (#73):** MOTD line when `fingerprint.enable` is on; first-boot
|
||||||
tip when `fprintd-list` is on PATH (`SUPER+M → System › Fingerprint` /
|
tip when `fprintd-list` is on PATH (`SUPER+M → System › Fingerprint` /
|
||||||
`fprintd-enroll`). No permanent nag without a reader.
|
`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
|
```sh
|
||||||
# CLI still works:
|
# CLI still works:
|
||||||
fprintd-enroll
|
fprintd-enroll
|
||||||
fprintd-list "$USER"
|
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
|
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.
|
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
|
### Doctor
|
||||||
|
|
||||||
`nomarchy-doctor` reports fprintd unit + enroll status when present.
|
`nomarchy-doctor` reports fprintd unit + enroll status when present.
|
||||||
@@ -346,7 +402,40 @@ nomarchy-detect-hw --raw # MODULE / NOMARCHY / DETAIL protocol lines
|
|||||||
Prints suggested `hardwareProfile` and `system.nix` lines; **does not**
|
Prints suggested `hardwareProfile` and `system.nix` lines; **does not**
|
||||||
rewrite the flake. Paste after review, then `sudo nixos-rebuild switch`.
|
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):
|
For contributors (and power users who will PR):
|
||||||
|
|
||||||
@@ -374,15 +463,15 @@ For contributors (and power users who will PR):
|
|||||||
`nixosModules` is queued so lock bumps cannot silently break installs.
|
`nixosModules` is queued so lock bumps cannot silently break installs.
|
||||||
|
|
||||||
5. Optional: note on-hardware QA steps in `agent/HARDWARE-QUEUE.md`.
|
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
|
**Today** (`nomarchy-doctor`): failed units, disk space, state.json
|
||||||
validity/git, generation age, snapper timer. **No** Wi‑Fi, GPU, fwupd,
|
validity/git, generation age, snapper; hardware section self-gates per
|
||||||
or fingerprint checks.
|
machine (NetworkManager, audio sink, GPU smoke, fprintd, fwupd, charge
|
||||||
|
limit, battery health, hibernate/zram). All read-only; each ✖ prints one
|
||||||
**Target checks** (queued, all read-only, each failure prints one fix
|
fix command.
|
||||||
command — same doctor contract):
|
|
||||||
|
|
||||||
| Check | Pass condition | Suggested fix line |
|
| Check | Pass condition | Suggested fix line |
|
||||||
|-------|----------------|--------------------|
|
|-------|----------------|--------------------|
|
||||||
@@ -391,9 +480,12 @@ command — same doctor contract):
|
|||||||
| GPU accel | `glxinfo`/`vainfo` smoke (if installed) | check `hardwareProfile` / drivers |
|
| GPU accel | `glxinfo`/`vainfo` smoke (if installed) | check `hardwareProfile` / drivers |
|
||||||
| Fingerprint | if USB VID matched / fprintd unit | `fprintd-enroll` or enable option |
|
| Fingerprint | if USB VID matched / fprintd unit | `fprintd-enroll` or enable option |
|
||||||
| fwupd | daemon active; optional “updates pending” warn | `fwupdmgr get-updates` |
|
| 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:
|
Full tables: [README § options](../README.md). Hardware-shaped surface:
|
||||||
|
|
||||||
@@ -402,7 +494,7 @@ Full tables: [README § options](../README.md). Hardware-shaped surface:
|
|||||||
| `mkFlake.hardwareProfile` | nixos-hardware name or list |
|
| `mkFlake.hardwareProfile` | nixos-hardware name or list |
|
||||||
| `nomarchy.hardware.intel.enable` / `.guc` / `.computeRuntime` | Intel gap layer |
|
| `nomarchy.hardware.intel.enable` / `.guc` / `.computeRuntime` | Intel gap layer |
|
||||||
| `nomarchy.hardware.amd.enable` / `.pstate` / `.vaapi` / `.rocm.*` | AMD 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.npu.enable` | in-kernel NPU only |
|
||||||
| `nomarchy.hardware.latestKernel` | `linuxPackages_latest` |
|
| `nomarchy.hardware.latestKernel` | `linuxPackages_latest` |
|
||||||
| `nomarchy.hardware.camera.hideIrSensor` / `.irMatch` | dual-sensor webcams (v4l2 only; §7) |
|
| `nomarchy.hardware.camera.hideIrSensor` / `.irMatch` | dual-sensor webcams (v4l2 only; §7) |
|
||||||
@@ -411,7 +503,7 @@ Full tables: [README § options](../README.md). Hardware-shaped surface:
|
|||||||
| `nomarchy.system.bluetooth.enable` | BT stack |
|
| `nomarchy.system.bluetooth.enable` | BT stack |
|
||||||
| `services.fwupd.enable` | LVFS (native NixOS, default on) |
|
| `services.fwupd.enable` | LVFS (native NixOS, default on) |
|
||||||
|
|
||||||
## 12. Ease summary
|
## 13. Ease summary
|
||||||
|
|
||||||
| Situation | Effort |
|
| Situation | Effort |
|
||||||
|-----------|--------|
|
|-----------|--------|
|
||||||
@@ -423,7 +515,7 @@ Full tables: [README § options](../README.md). Hardware-shaped surface:
|
|||||||
| ROCm / NPU userspace | High — opt-in + expert |
|
| ROCm / NPU userspace | High — opt-in + expert |
|
||||||
| Contribute a new DMI line | Medium for someone who can PR |
|
| Contribute a new DMI line | Medium for someone who can PR |
|
||||||
|
|
||||||
## 13. Related files
|
## 14. Related files
|
||||||
|
|
||||||
| Path | Role |
|
| Path | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ ryzenadj)**, **no Secure Boot**, and the **stateVersion override**.
|
|||||||
nomarchy.hardware.amd.enable = true; # amd-pstate + radeonsi VA-API
|
nomarchy.hardware.amd.enable = true; # amd-pstate + radeonsi VA-API
|
||||||
# nomarchy.hardware.amd.rocm.enable = true; # opt-in GPU compute (multi-GB)
|
# 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.
|
# CRITICAL: keep your ORIGINAL install's value — never Nomarchy's 26.05.
|
||||||
system.stateVersion = "24.11";
|
system.stateVersion = "24.11";
|
||||||
}
|
}
|
||||||
@@ -200,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
|
it after the switch). Your old `nomarchy-state.nix` prototype (schema
|
||||||
`nomarchy.theme = "nord"` …) is **retired** — the current distro uses this
|
`nomarchy.theme = "nord"` …) is **retired** — the current distro uses this
|
||||||
JSON. `nord` is a shipped Nomarchy theme, so you lose nothing.
|
JSON. `nord` is a shipped Nomarchy theme, so you lose nothing.
|
||||||
@@ -226,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.
|
# breaks, REBOOT and you are back in your old generation, untouched.
|
||||||
sudo nixos-rebuild test --flake ~/nomarchy-migrate#default
|
sudo nixos-rebuild test --flake ~/nomarchy-migrate#default
|
||||||
|
|
||||||
# Bring the desktop (home-manager) up:
|
# Bring the desktop (home-manager) up BEFORE the first graphical login.
|
||||||
home-manager switch --flake ~/nomarchy-migrate#bernardo
|
# 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
|
Log into Hyprland and sanity‑check: Waybar renders, `SUPER+M` opens the
|
||||||
menu, theming is coherent, `SUPER+?` shows the cheatsheet. Confirm the
|
menu, theming is coherent, `SUPER+?` shows the cheatsheet, **no** yellow
|
||||||
machine‑specific things you care about still work — suspend/hibernate, the
|
Hyprland autogenerated banner. Confirm the machine‑specific things you
|
||||||
AMD GPU (`vainfo` → radeonsi), display brightness.
|
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
|
If anything is wrong: **reboot → old generation.** Nothing is committed as
|
||||||
default yet.
|
default yet.
|
||||||
@@ -245,10 +260,80 @@ default yet.
|
|||||||
- **Power:** verify `powerprofilesctl get` works and the Waybar battery /
|
- **Power:** verify `powerprofilesctl get` works and the Waybar battery /
|
||||||
power‑profile icons open the power menu. Your ryzenadj scripts are gone;
|
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.
|
if you miss a specific TDP behaviour, that's a follow‑up, not a blocker.
|
||||||
- **Theme:** `nomarchy-menu theme` → pick **nord** (writes
|
- **Theme:** `nomarchy-menu theme` → pick a preset (writes
|
||||||
`theme-state.json`).
|
`state.json`).
|
||||||
- **Snapshots:** `nomarchy-menu` → System → Snapshots should see your
|
- **Snapshots:** `nomarchy-menu` → System → Snapshots should see your
|
||||||
existing `@snapshots` subvolume.
|
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
|
- **Secrets/services:** if you relied on agenix‑managed secrets for a
|
||||||
service, layer `agenix` back into `system.nix` as a machine‑specific
|
service, layer `agenix` back into `system.nix` as a machine‑specific
|
||||||
import (Nomarchy doesn't manage secrets). If you don't need them, leave
|
import (Nomarchy doesn't manage secrets). If you don't need them, leave
|
||||||
@@ -299,6 +384,26 @@ lock).
|
|||||||
prototype).
|
prototype).
|
||||||
- Prune old generations: `sudo nix-collect-garbage -d`.
|
- 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)
|
## Enabling hibernation on an existing machine (no reinstall)
|
||||||
@@ -402,6 +507,25 @@ 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
|
## TuringMachine — the decisions, at a glance
|
||||||
|
|
||||||
| Item | Choice |
|
| Item | Choice |
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ live cheatsheet — you never have to memorize this table.
|
|||||||
| `SUPER+SHIFT+T` | Next wallpaper |
|
| `SUPER+SHIFT+T` | Next wallpaper |
|
||||||
| `SUPER+E` | File manager (yazi) |
|
| `SUPER+E` | File manager (yazi) |
|
||||||
| `SUPER+X` | Power menu (lock / suspend / hibernate / reboot …) |
|
| `SUPER+X` | Power menu (lock / suspend / hibernate / reboot …) |
|
||||||
|
| `SUPER+CTRL+L` | Lock screen |
|
||||||
| `SUPER+?` | Keybindings cheatsheet |
|
| `SUPER+?` | Keybindings cheatsheet |
|
||||||
| `Print` | Screenshot region → clipboard |
|
| `Print` | Screenshot region → clipboard |
|
||||||
| `SUPER+CTRL+<key>` | Jump straight to a menu module (clipboard `V`, calc `C`, emoji `E`, capture `S`, …) |
|
| `SUPER+CTRL+<key>` | Jump straight to a menu module (clipboard `V`, calc `C`, emoji `E`, capture `S`, …) |
|
||||||
@@ -80,11 +81,11 @@ Both distros reskin everything from one place. In Nomarchy:
|
|||||||
- **`SUPER+T`** (or `nomarchy-menu theme`) — pick from 24 presets with live
|
- **`SUPER+T`** (or `nomarchy-menu theme`) — pick from 24 presets with live
|
||||||
previews; it rebuilds and applies to Hyprland, Waybar, the terminal,
|
previews; it rebuilds and applies to Hyprland, Waybar, the terminal,
|
||||||
btop and GTK/Qt at once.
|
btop and GTK/Qt at once.
|
||||||
- Or from a shell: `nomarchy-theme-sync apply <slug>` (e.g. `gruvbox`,
|
- Or from a shell: `nomarchy-state-sync apply <slug>` (e.g. `gruvbox`,
|
||||||
`rose-pine`, `nord`).
|
`rose-pine`, `nord`).
|
||||||
- Tweak gaps / rounding / fonts / opacity / icon pack without leaving the
|
- Tweak gaps / rounding / fonts / opacity / icon pack without leaving the
|
||||||
theme system — see [OVERRIDES.md](OVERRIDES.md). One git-tracked JSON
|
theme system — see [OVERRIDES.md](OVERRIDES.md). One git-tracked JSON
|
||||||
(`theme-state.json`) is the single source of truth.
|
(`state.json`) is the single source of truth.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ rule of thumb:
|
|||||||
|
|
||||||
## 1. Appearance (gaps, colors, rounding, fonts, opacity) — use the CLI
|
## 1. Appearance (gaps, colors, rounding, fonts, opacity) — use the CLI
|
||||||
|
|
||||||
Everything that defines the *look* flows from `theme-state.json`, the single
|
Everything that defines the *look* flows from `state.json`, the single
|
||||||
source of truth. Change it with `nomarchy-theme-sync`, which writes the JSON
|
source of truth. Change it with `nomarchy-state-sync`, which writes the JSON
|
||||||
and rebuilds — one generation, applied to Hyprland, Waybar, Ghostty, btop and
|
and rebuilds — one generation, applied to Hyprland, Waybar, Kitty, btop and
|
||||||
Stylix at once:
|
Stylix at once:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nomarchy-theme-sync set ui.gapsOut 16 # gaps, borders, rounding, opacity
|
nomarchy-state-sync set ui.gapsOut 16 # gaps, borders, rounding, opacity
|
||||||
nomarchy-theme-sync set ui.rounding 0
|
nomarchy-state-sync set ui.rounding 0
|
||||||
nomarchy-theme-sync set fonts.mono "FiraCode Nerd Font"
|
nomarchy-state-sync set fonts.mono "FiraCode Nerd Font"
|
||||||
nomarchy-theme-sync apply gruvbox # whole palette
|
nomarchy-state-sync apply gruvbox # whole palette
|
||||||
```
|
```
|
||||||
|
|
||||||
### Icon pack
|
### Icon pack
|
||||||
@@ -28,7 +28,7 @@ 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:
|
opt-in rather than a cost every install pays). To switch to another pack:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nomarchy-theme-sync set icons "Tela-dark" # or "" to return to Papirus-by-mode
|
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
|
Only the pack you name is pulled into your system (the first switch downloads
|
||||||
@@ -55,11 +55,11 @@ 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:
|
day and night themes, and set the sunrise/sunset times. The same from a shell:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nomarchy-theme-sync set settings.autoTheme.day summer-day --no-switch
|
nomarchy-state-sync set settings.autoTheme.day summer-day --no-switch
|
||||||
nomarchy-theme-sync set settings.autoTheme.night summer-night --no-switch
|
nomarchy-state-sync set settings.autoTheme.night summer-night --no-switch
|
||||||
nomarchy-theme-sync set settings.autoTheme.sunset 20:00 --no-switch
|
nomarchy-state-sync set settings.autoTheme.sunset 20:00 --no-switch
|
||||||
nomarchy-theme-sync set settings.autoTheme.enable true --no-switch
|
nomarchy-state-sync set settings.autoTheme.enable true --no-switch
|
||||||
nomarchy-theme-sync auto --force # one rebuild: installs the timer + applies now
|
nomarchy-state-sync auto --force # one rebuild: installs the timer + applies now
|
||||||
```
|
```
|
||||||
|
|
||||||
State lives in `settings.autoTheme`:
|
State lives in `settings.autoTheme`:
|
||||||
@@ -67,14 +67,15 @@ State lives in `settings.autoTheme`:
|
|||||||
| Field | Meaning |
|
| Field | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `enable` | on / off |
|
| `enable` | on / off |
|
||||||
| `day` / `night` | theme slugs — e.g. `summer-day` / `summer-night` (see `nomarchy-theme-sync list`) |
|
| `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) |
|
| `sunrise` / `sunset` | switch times, `"HH:MM"` (24-hour) |
|
||||||
|
|
||||||
A timer re-checks every ~15 minutes: the day theme applies around sunrise, the
|
A timer fires exactly at the configured sunrise and sunset (plus once shortly
|
||||||
night theme around sunset — but it only rebuilds when the active theme
|
after login, and it catches up on a transition missed while the machine was
|
||||||
actually needs to change, so most checks do nothing. Enabling rebuilds once
|
off or asleep) — it only rebuilds when the active theme actually needs to
|
||||||
(to install the timer); disabling is instant. Preview the current decision
|
change. The times are baked into the timer, so editing them (or enabling)
|
||||||
without switching with `nomarchy-theme-sync auto --which`.
|
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
|
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`
|
stay owned by the theme system. If you *insist* on pinning one in `home.nix`
|
||||||
@@ -98,9 +99,8 @@ in your `home.nix` wins — no `mkForce` needed:
|
|||||||
animations.enabled = false;
|
animations.enabled = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
programs.ghostty.settings = {
|
programs.kitty.settings = {
|
||||||
window-padding-x = 4; # was 12
|
window_padding_width = 4; # was 12
|
||||||
window-decoration = true;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -198,7 +198,7 @@ value is theme-owned at normal priority — either change it via the CLI (§1) o
|
|||||||
|
|
||||||
| You want to… | Do this |
|
| 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` |
|
| Change input / misc / monitor / animations / terminal chrome | plain assignment in `home.nix` |
|
||||||
| Arrange monitors declaratively | `nomarchy.monitors` (values via `nwg-displays`) |
|
| Arrange monitors declaratively | `nomarchy.monitors` (values via `nwg-displays`) |
|
||||||
| Add keybinds / autostarts | add to the `bind` / `exec-once` list (concatenates) |
|
| Add keybinds / autostarts | add to the `bind` / `exec-once` list (concatenates) |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ tree for the same facts.
|
|||||||
| Path | Audience | Role |
|
| Path | Audience | Role |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| [../README.md](../README.md) | Everyone | What Nomarchy is, install, options tables |
|
| [../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 |
|
| [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 ✓) |
|
| [ROADMAP.md](ROADMAP.md) | Maintainers + agents | Design/decision records + shipped log (historical ✓) |
|
||||||
| [HARDWARE.md](HARDWARE.md) | Users + agents | Firmware, profiles, drivers, unsupported machines |
|
| [HARDWARE.md](HARDWARE.md) | Users + agents | Firmware, profiles, drivers, unsupported machines |
|
||||||
|
|||||||
@@ -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
|
/nix/store/…-home-manager-generation/activate # run the one you want
|
||||||
```
|
```
|
||||||
|
|
||||||
The same picker lives in the menu: **System › Rollback** lists the
|
The same picker lives in the menu: **System › Recovery › Desktop
|
||||||
recent desktop generations — pick one and it activates.
|
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
|
boreal` (or any preset). If a switch failed halfway, the state file is written
|
||||||
*before* the rebuild — fix the cause and re-run
|
*before* the rebuild — fix the cause and re-run
|
||||||
`home-manager switch --flake ~/.nomarchy` (or `nomarchy-home`).
|
`home-manager switch --flake ~/.nomarchy` (or `nomarchy-home`).
|
||||||
|
|
||||||
Your flake checkout is a git repo, and with auto-commit enabled every
|
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,
|
apply is a commit — and every `nomarchy-pull`/`-rebuild`/`-home` first
|
||||||
`git revert` the culprit, then `nomarchy-home`.
|
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
|
## 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).
|
- Rolling back the *desktop* half is §1 (works from the TTY).
|
||||||
- If greetd/tuigreet itself is broken, that's system-side → §3.
|
- 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,
|
- In a **VM**, a black screen is almost always missing guest OpenGL,
|
||||||
not your config — see docs/TESTING.md §5.
|
not your config — see docs/TESTING.md §5.
|
||||||
- First boot after an install came up *unthemed*: read
|
- 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
|
## 3. A system change broke it — boot an older generation
|
||||||
|
|
||||||
Reboot and pick an older **NixOS generation** in the systemd-boot menu
|
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
|
(hold a key during firmware handoff if the menu doesn't linger). That
|
||||||
last 10 generations are kept). That boots yesterday's system unchanged.
|
boots yesterday's system unchanged.
|
||||||
|
|
||||||
Booting an old generation is temporary — the default entry is still the
|
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
|
broken one. Make the fix stick from the working boot: revert the change
|
||||||
in `~/.nomarchy` (`git -C ~/.nomarchy revert …` or edit `system.nix`
|
in `~/.nomarchy` (`git -C ~/.nomarchy revert …` or edit `system.nix`
|
||||||
back), then `nomarchy-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
|
With `nomarchy.system.snapper.enable` (the installer's default on
|
||||||
BTRFS), the root filesystem has hourly/daily history, and
|
BTRFS), the root filesystem has hourly/daily history, and
|
||||||
`nixos-rebuild-snap` leaves a snapshot right before a rebuild:
|
`nixos-rebuild-snap` leaves a snapshot right before a rebuild:
|
||||||
|
|
||||||
- **GUI:** menu › System › Snapshots (btrfs-assistant; expects a polkit
|
- **GUI:** menu › System › Recovery › Files (BTRFS) (btrfs-assistant;
|
||||||
password prompt).
|
expects a polkit password prompt).
|
||||||
- **Terminal/SSH:** `sudo nomarchy-snapshots` — browse a snapshot's
|
- **Terminal/SSH:** `sudo nomarchy-snapshots` — browse a snapshot's
|
||||||
diff, **restore changed files** (snapper `undochange`), or **roll the
|
diff, **restore changed files** (snapper `undochange`), or **roll the
|
||||||
whole root back** to a snapshot and reboot. Both destructive actions
|
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
|
Snapshots are the undo for *data on disk*; the Nix config model is
|
||||||
undone by generations (§1/§3) — use each for its half.
|
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:
|
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
|
From there you have the full toolbox: `nixos-rebuild boot --flake
|
||||||
/home/<you>/.nomarchy#default` after fixing the flake, or snapper from
|
/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.
|
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 |
|
||||||
1094
docs/ROADMAP.md
1094
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
|
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` —
|
`/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.
|
session may not start.
|
||||||
|
|
||||||
### What the live environment gives you
|
### What the live environment gives you
|
||||||
@@ -56,8 +56,11 @@ session may not start.
|
|||||||
Hyprland via `initial_session`; logging out lands on tuigreet.
|
Hyprland via `initial_session`; logging out lands on tuigreet.
|
||||||
- The flake is seeded writable at `~/.nomarchy` (from the read-only
|
- The flake is seeded writable at `~/.nomarchy` (from the read-only
|
||||||
`/etc/nomarchy` copy) — `$NOMARCHY_PATH` already points there.
|
`/etc/nomarchy` copy) — `$NOMARCHY_PATH` already points there.
|
||||||
- Locked flake inputs are pinned into the ISO store, so
|
- Locked flake inputs are pinned into the ISO store, so a
|
||||||
`home-manager switch` works **without a network**.
|
`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
|
## 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 |
|
| 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 |
|
| 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 |
|
| 4 | `btop` in the terminal is themed | per-theme asset baking |
|
||||||
| 5 | `nomarchy-theme-sync list` prints 24 presets | package, baked themes dir |
|
| 5 | `nomarchy-state-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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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,
|
interactively from the live terminal. If the desktop comes up unthemed,
|
||||||
read `/var/log/nomarchy-hm-preactivate.log` on the installed system.
|
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
|
## 5. VM-specific gotchas
|
||||||
|
|
||||||
- **No KVM** (e.g. nested without acceleration): everything works but
|
- **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`.
|
script's qemu flags; on real hardware check `journalctl -b -u greetd`.
|
||||||
- **Tiny resolution**: the live config forces `monitor = ,highres,auto,1`;
|
- **Tiny resolution**: the live config forces `monitor = ,highres,auto,1`;
|
||||||
if QEMU still picks 1024×768, resize the window — Hyprland follows.
|
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
|
- **First theme switch is the slowest**: it evaluates the flake on a RAM
|
||||||
disk. Subsequent switches reuse the eval cache.
|
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
|
## 6. When something fails
|
||||||
|
|
||||||
- Session/login problems: `journalctl -b -u greetd`, then `journalctl
|
- Session/login problems: `journalctl -b -u greetd`, then `journalctl
|
||||||
--user -b`.
|
--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
|
terminal — the home-manager output streams there. The state file is
|
||||||
written *before* the rebuild, so after fixing you can just re-run
|
written *before* the rebuild, so after fixing you can just re-run
|
||||||
`home-manager switch --flake ~/.nomarchy`.
|
`home-manager switch --flake ~/.nomarchy`.
|
||||||
|
|||||||
@@ -51,17 +51,19 @@ Steam Deck, docs website, binary cache.
|
|||||||
## Theme A — Day-2 confidence (highest product ROI)
|
## Theme A — Day-2 confidence (highest product ROI)
|
||||||
|
|
||||||
The install is already strong. The gap is **after** first boot.
|
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 |
|
| Idea | Intent | Status |
|
||||||
|------|--------|-------------|
|
|------|--------|--------|
|
||||||
| **System › Firmware** | fwupd is on but CLI-only; menu: refresh → list → confirm → update (never auto-flash) | BACKLOG Hardware product |
|
| **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 | BACKLOG Hardware product |
|
| **Fingerprint menu** ✓ | Enroll/list when fprintd present; optional PAM + rebuild note | ✓ shipped #55; V3 enroll HARDWARE-QUEUE |
|
||||||
| **Doctor hardware section** | NM, sink, optional GPU smoke, fprintd, fwupd pending, charge threshold | BACKLOG Hardware product |
|
| **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 (not five submenus) | Menu / doctor |
|
| **Machine health entry** ✓ | One System row → doctor | ✓ System › Doctor + Waybar tripwire |
|
||||||
| **Human rebuild errors** | On failed switch, point at last log lines + `nomarchy-doctor` | pkgs / menu |
|
| **Human rebuild errors** ✓ | Failed switch → last log lines + `nomarchy-doctor` | ✓ shipped #56 |
|
||||||
| **HM pre-activate fail flag** | Durable recovery one-liner on target if bake failed | Installer |
|
| **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 |
|
| 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 |
|
| **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 | BACKLOG PROPOSED already |
|
| **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 | Rollback exists; story is incomplete |
|
| **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 | Avoid permanent nag |
|
| **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 |
|
| Idea | Intent | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| **Dock life** | Lid closed + external only, wake, default audio sink on undock | Display profiles exist — polish edge cases |
|
| **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 | Trust > new power UI |
|
| **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 / charge limit where sysfs allows | Report-only |
|
| **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** | Already PROPOSED `[blocked:hw]` | Keep privilege tradeoff explicit |
|
| **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,7 +93,7 @@ Reference: [HARDWARE.md](HARDWARE.md) §4–§5, §10.
|
|||||||
|
|
||||||
| Idea | Intent | Notes |
|
| Idea | Intent | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| **Fewer themes, sharper** | Finish or quarantine neon-glass; invest in whole-swap quality | Contrast stays gated |
|
| **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 |
|
| **Time-of-day pair** ✓ | Auto light/dark (e.g. summer-day ↔ summer-night) from schedule | ✓ shipped #79 (2026-07-10) — `settings.autoTheme` + timer; still one engine |
|
||||||
| **Theme switch speed** | Wallpapers artifact split (LATER) if switches still feel slow | GOALS: no second pipeline |
|
| **Theme switch speed** | Wallpapers artifact split (LATER) if switches still feel slow | GOALS: no second pipeline |
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@ Reference: [HARDWARE.md](HARDWARE.md) §4–§5, §10.
|
|||||||
| Idea | Intent | Notes |
|
| Idea | Intent | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| **Omarchy migrant one-pager** ✓ | Bindings/theme/install map | ✓ shipped `docs/OMARCHY.md` (#78, 2026-07-10) |
|
| **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 | Ties to HARDWARE.md §9 |
|
| **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 |
|
| **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)
|
## 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)
|
1. `VISION § D` / LATER — wallpapers artifact split (decided deferred;
|
||||||
2. `VISION § A` — System › Firmware (fwupd wrapper)
|
promote only if theme-switch latency still hurts after measurement)
|
||||||
3. `VISION § A` — doctor hardware section (read-only checks)
|
2. `VISION § H` — HARDWARE-QUEUE burn-down (human-only V3; agents only
|
||||||
4. `VISION § A` — fingerprint enroll menu (self-gated)
|
append exact steps)
|
||||||
5. `VISION § B` — first-boot dismissible tips (one surface)
|
3. `VISION § v1.0` — install P0 re-verify before any `v1` fast-forward
|
||||||
6. `VISION § A` — human-facing rebuild failure hint
|
(swap=0 / unattended LUKS contracts; human ships the pointer)
|
||||||
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
|
|
||||||
|
|
||||||
Larger themes (dock life, time-of-day theme, wallpapers split) stay
|
Do **not** re-open Theme A firmware/fingerprint/doctor/rebuild rows —
|
||||||
`[big]` until split.
|
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": {
|
"locked": {
|
||||||
"lastModified": 1783221248,
|
"lastModified": 1784350909,
|
||||||
"narHash": "sha256-ESQnuNHEDChsB4IxoLRhscVahqkDWkTb+qdIz8euYt4=",
|
"narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "af2beae5f0fae0a4310cc0e6aef2572f56090353",
|
"rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -166,11 +166,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1783370751,
|
"lastModified": 1784310968,
|
||||||
"narHash": "sha256-E+3MIMvKuo9k+K+qLQ9YXzsBzkgHuyVLnsEbN2DFfuc=",
|
"narHash": "sha256-rkSPTePrKqs4dg+i7ZFCq93+HrClac6oSwXX927SVjA=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixos-hardware",
|
"repo": "nixos-hardware",
|
||||||
"rev": "662bd6e312d2c8b212e32cb377abaee190749320",
|
"rev": "779c32a00155994c86cde8213a8dd4df139d4355",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -182,11 +182,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1783148766,
|
"lastModified": 1784432872,
|
||||||
"narHash": "sha256-uslt2pqShTIXDdAHRHv2QkYLsVdY8Oqwz0EA48/RSM8=",
|
"narHash": "sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4+mhPoA34rrqS0GkA=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a50de1b7d8a586adc18d2395c19de7d6058e6030",
|
"rev": "fd1462031fdee08f65fd0b4c6b64e22239a77870",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -196,6 +196,39 @@
|
|||||||
"type": "github"
|
"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": {
|
"nur": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": [
|
"flake-parts": [
|
||||||
@@ -226,6 +259,8 @@
|
|||||||
"home-manager": "home-manager",
|
"home-manager": "home-manager",
|
||||||
"nixos-hardware": "nixos-hardware",
|
"nixos-hardware": "nixos-hardware",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
|
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||||
|
"nomarchy-wallpapers": "nomarchy-wallpapers",
|
||||||
"stylix": "stylix"
|
"stylix": "stylix"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -249,11 +284,11 @@
|
|||||||
"tinted-zed": "tinted-zed"
|
"tinted-zed": "tinted-zed"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1783359251,
|
"lastModified": 1784060273,
|
||||||
"narHash": "sha256-HUiCnEVlJ4n+qJlZojiz/zv+P0cqM5zsg1dxpz2J7Mg=",
|
"narHash": "sha256-14rIy2kTs5CufmDpgJrwkR+7IzuCAXyLYDAx6ixfRFc=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "stylix",
|
"repo": "stylix",
|
||||||
"rev": "e602ad042f00409f33c8ad2829cd8d59ba345c7e",
|
"rev": "2245fa9e16034149b6501834b99863a486e94725",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
# without touching the disk. The live session bundles nomarchy-install
|
# without touching the disk. The live session bundles nomarchy-install
|
||||||
# (gum + disko + mkFlake) for a real install; this target also lets you
|
# (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.
|
# test the distro end-to-end on real hardware before committing to disk.
|
||||||
{ lib, pkgs, username, nomarchySrc, ... }:
|
{ lib, pkgs, config, username, nomarchySrc, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
# ISO boot branding: the Nomarchy monogram recolored to the palette accent,
|
# ISO boot branding: the Nomarchy monogram recolored to the palette accent,
|
||||||
# centred on the theme base. Built from the vendored vector logo and the
|
# 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
|
# backs both the isolinux (BIOS) splash and the GRUB (UEFI) theme below, so
|
||||||
# the two boot paths match.
|
# 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);
|
isoColor = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
|
||||||
accent = isoColor "accent" "B79BE8";
|
accent = isoColor "accent" "B79BE8";
|
||||||
base = isoColor "base" "21272F";
|
base = isoColor "base" "21272F";
|
||||||
@@ -84,13 +84,19 @@ in
|
|||||||
isoImage.edition = lib.mkForce "live";
|
isoImage.edition = lib.mkForce "live";
|
||||||
isoImage.splashImage = isoSplash; # isolinux / BIOS
|
isoImage.splashImage = isoSplash; # isolinux / BIOS
|
||||||
isoImage.grubTheme = nomarchyGrubTheme; # GRUB / UEFI
|
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
|
# The minimal-CD profile slims the image for a CONSOLE installer; this
|
||||||
# ISO is the desktop, so re-enable what it strips. Above all
|
# ISO is the desktop, so re-enable what it strips. Above all
|
||||||
# fontconfig (upstream forces it off at mkOverride 500): without it
|
# fontconfig (upstream forces it off at mkOverride 500): without it
|
||||||
# no configured family resolves — Waybar icons render as tofu and
|
# no configured family resolves — Waybar icons render as tofu and
|
||||||
# Ghostty silently falls back to the wrong font (seen on the
|
# Kitty silently falls back to the wrong font (seen on the
|
||||||
# Latitude 5410). Normal priority (100) beats the override.
|
# Latitude 5410 with Ghostty; same fontconfig footgun). Normal priority
|
||||||
|
# (100) beats the override.
|
||||||
fonts.fontconfig.enable = true;
|
fonts.fontconfig.enable = true;
|
||||||
|
|
||||||
# No boot splash on the install medium: the installer ISO boots its
|
# No boot splash on the install medium: the installer ISO boots its
|
||||||
@@ -148,6 +154,16 @@ in
|
|||||||
if [ ! -e "$home/.nomarchy" ]; then
|
if [ ! -e "$home/.nomarchy" ]; then
|
||||||
cp -r ${nomarchySrc} "$home/.nomarchy"
|
cp -r ${nomarchySrc} "$home/.nomarchy"
|
||||||
chmod -R u+w "$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"
|
chown -R ${username}:users "$home/.nomarchy"
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
@@ -158,8 +174,8 @@ in
|
|||||||
Welcome to the Nomarchy live environment.
|
Welcome to the Nomarchy live environment.
|
||||||
|
|
||||||
The graphical session autologins as '${username}' (no password).
|
The graphical session autologins as '${username}' (no password).
|
||||||
Theme switching: nomarchy-theme-sync apply <name> (or SUPER+T)
|
Theme switching: nomarchy-state-sync apply <name> (or SUPER+T)
|
||||||
Wallpapers: nomarchy-theme-sync bg next (or SUPER+SHIFT+T)
|
Wallpapers: nomarchy-state-sync bg next (or SUPER+SHIFT+T)
|
||||||
Install to disk: nomarchy-install
|
Install to disk: nomarchy-install
|
||||||
The flake lives at ~/.nomarchy.
|
The flake lives at ~/.nomarchy.
|
||||||
'';
|
'';
|
||||||
|
|||||||
21
lib.nix
21
lib.nix
@@ -1,5 +1,5 @@
|
|||||||
# Downstream sugar: one call wires a whole machine flake.
|
# 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
|
# is generated once (by `nix flake init -t` or the future installer) and
|
||||||
# never hand-edited. The raw exports (nixosModules/homeModules/overlays)
|
# never hand-edited. The raw exports (nixosModules/homeModules/overlays)
|
||||||
# in flake.nix remain the escape hatch for power users.
|
# in flake.nix remain the escape hatch for power users.
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
let
|
let
|
||||||
inherit (nixpkgs) lib;
|
inherit (nixpkgs) lib;
|
||||||
|
|
||||||
# Shared pure reader (modules/theme-state-read.nix) — re-exported so
|
# Shared pure reader (modules/state-read.nix) — re-exported so
|
||||||
# power users composing without mkFlake can call it too.
|
# power users composing without mkFlake can call it too.
|
||||||
readThemeState = import ./modules/theme-state-read.nix { inherit lib; };
|
readThemeState = import ./modules/state-read.nix { inherit lib; };
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
inherit readThemeState;
|
inherit readThemeState;
|
||||||
@@ -59,13 +59,18 @@ in
|
|||||||
config.allowUnfree = true;
|
config.allowUnfree = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Early fail-closed gate: missing/empty/non-object theme-state.json
|
# Early fail-closed gate: missing/empty/non-object state.json
|
||||||
# throws here with a template + validate pointer, before module
|
# throws here with a template + validate pointer, before module
|
||||||
# evaluation buries a raw readFile/fromJSON stack. Field-level
|
# evaluation buries a raw readFile/fromJSON stack. Field-level
|
||||||
# schema still runs in modules/home/theme.nix after defaults merge.
|
# schema still runs in modules/home/theme.nix after defaults merge.
|
||||||
# Forced via seq on the whole return set — attrNames alone must not
|
# 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).
|
# skip the check (Nix is lazy on unused let bindings and attr values).
|
||||||
statePath = src + "/theme-state.json";
|
# #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;
|
_themeState = readThemeState statePath;
|
||||||
in
|
in
|
||||||
builtins.seq _themeState {
|
builtins.seq _themeState {
|
||||||
@@ -78,7 +83,7 @@ in
|
|||||||
[
|
[
|
||||||
nomarchy.nixosModules.nomarchy
|
nomarchy.nixosModules.nomarchy
|
||||||
# The standalone HM CLI ships with the system so theme switching
|
# 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.
|
# out of the box — same pinned input as the desktop modules.
|
||||||
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
|
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
|
||||||
# System-side theme consumers (Plymouth splash background)
|
# System-side theme consumers (Plymouth splash background)
|
||||||
@@ -94,14 +99,14 @@ in
|
|||||||
|
|
||||||
# Desktop layer — every theme change, no sudo:
|
# Desktop layer — every theme change, no sudo:
|
||||||
# home-manager switch --flake .#<username>
|
# 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 {
|
homeConfigurations.${username} = home-manager.lib.homeManagerConfiguration {
|
||||||
inherit pkgs;
|
inherit pkgs;
|
||||||
modules = [
|
modules = [
|
||||||
nomarchy.homeModules.nomarchy
|
nomarchy.homeModules.nomarchy
|
||||||
(src + "/home.nix")
|
(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.
|
# file is part of the downstream flake's source.
|
||||||
nomarchy.stateFile = statePath;
|
nomarchy.stateFile = statePath;
|
||||||
home = {
|
home = {
|
||||||
|
|||||||
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 ];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,18 +1,37 @@
|
|||||||
# Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer
|
# Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer
|
||||||
# periodically runs `nomarchy-theme-sync auto`, which reads
|
# runs `nomarchy-state-sync auto` at the configured sunrise and sunset,
|
||||||
# settings.autoTheme = { enable, day, night, sunrise, sunset } from the
|
# which reads settings.autoTheme = { enable, day, night, sunrise, sunset }
|
||||||
# state file and applies the day or night preset for the current clock —
|
# from the state file and applies the day or night preset for the current
|
||||||
# through the SAME one engine as a manual `apply` (no second pipeline).
|
# clock — through the SAME one engine as a manual `apply` (no second
|
||||||
|
# pipeline).
|
||||||
#
|
#
|
||||||
# The command self-gates (no-op when disabled) and is idempotent (it only
|
# The trigger times are BAKED into OnCalendar at rebuild (originally this
|
||||||
# rebuilds when the active theme actually needs to change), so the timer
|
# was a 15-min poll; exact times won on wasted wakeups — Bernardo
|
||||||
# can tick freely. Install is gated on the state flag (like nomarchy.updates
|
# 2026-07-18), so a time edit must rebuild: the menu's Sunrise/Sunset
|
||||||
# gates on its enable), so a machine not using the feature carries no timer;
|
# writes run `auto --force`, whose apply is that rebuild. Missed
|
||||||
# enabling it from the menu (slice 3) writes the flag + rebuilds, which is
|
# transitions are covered without polling: Persistent=true catches ones
|
||||||
# what brings the timer into being.
|
# 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, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
lib.mkIf (config.nomarchy.settings.autoTheme.enable or false) {
|
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 = {
|
systemd.user.services.nomarchy-auto-theme = {
|
||||||
Unit.Description = "Apply the day/night theme for the current time";
|
Unit.Description = "Apply the day/night theme for the current time";
|
||||||
Service = {
|
Service = {
|
||||||
@@ -20,15 +39,18 @@ lib.mkIf (config.nomarchy.settings.autoTheme.enable or false) {
|
|||||||
# The rebuild (`home-manager switch`) and its tools resolve from the
|
# The rebuild (`home-manager switch`) and its tools resolve from the
|
||||||
# system + per-user profiles — same PATH shape nomarchy-updates uses.
|
# 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";
|
Environment = "PATH=/run/current-system/sw/bin:/etc/profiles/per-user/${config.home.username}/bin";
|
||||||
ExecStart = "${pkgs.nomarchy-theme-sync}/bin/nomarchy-theme-sync auto";
|
ExecStart = "${pkgs.nomarchy-state-sync}/bin/nomarchy-state-sync auto";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.user.timers.nomarchy-auto-theme = {
|
systemd.user.timers.nomarchy-auto-theme = {
|
||||||
Unit.Description = "Periodic day/night theme check";
|
Unit.Description = "Day/night theme switch at sunrise and sunset";
|
||||||
Timer = {
|
Timer = {
|
||||||
OnStartupSec = "1min"; # settle on the right theme shortly after login
|
OnStartupSec = "1min"; # settle on the right theme shortly after login
|
||||||
OnCalendar = "*:0/15"; # re-check every 15 min; auto no-ops if unchanged
|
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
|
Persistent = true; # catch a transition missed while powered off
|
||||||
};
|
};
|
||||||
Install.WantedBy = [ "timers.target" ];
|
Install.WantedBy = [ "timers.target" ];
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
./options.nix # the nomarchy.* option surface
|
./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
|
./stylix.nix # GTK/Qt/cursor/fonts from the same JSON
|
||||||
./hyprland.nix
|
./hyprland.nix
|
||||||
./waybar.nix
|
./waybar.nix
|
||||||
./ghostty.nix
|
./kitty.nix # sole terminal (themed); doctor/calendar/--class load-bearing
|
||||||
./btop.nix
|
./btop.nix
|
||||||
./rofi.nix # launcher theming + the nomarchy-menu dispatcher
|
./rofi.nix # launcher theming + the nomarchy-menu dispatcher
|
||||||
./swaync.nix # notification daemon, themed from the same JSON
|
./swaync.nix # notification daemon, themed from the same JSON
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
./yazi.nix # flagship TUI file manager, themed + plugins
|
./yazi.nix # flagship TUI file manager, themed + plugins
|
||||||
./osd.nix # swayosd volume/brightness OSD, themed
|
./osd.nix # swayosd volume/brightness OSD, themed
|
||||||
./nightlight.nix # scheduled blue-light filter (hyprsunset), opt-in
|
./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
|
./autotheme.nix # auto day/night theme switch (settings.autoTheme), opt-in
|
||||||
./timezone.nix # keep the Waybar clock in step with auto-timezone changes
|
./timezone.nix # keep the Waybar clock in step with auto-timezone changes
|
||||||
./updates.nix # passive update-awareness indicator + notification, opt-in
|
./updates.nix # passive update-awareness indicator + notification, opt-in
|
||||||
@@ -28,6 +29,8 @@
|
|||||||
./mime.nix # default applications (mimeapps.list), degrades with the suite
|
./mime.nix # default applications (mimeapps.list), degrades with the suite
|
||||||
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
|
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
|
||||||
./battery-notify.nix # low-battery toasts at the bar's 25/10% thresholds
|
./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
|
./satty.nix # satty screenshot annotation tool, themed
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -39,7 +42,31 @@
|
|||||||
services.udiskie.enable = true;
|
services.udiskie.enable = true;
|
||||||
|
|
||||||
# Microphone noise cancellation (rnnoise) and audio EQ.
|
# 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;
|
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
|
# Wifi from the bar: nm-applet lives in waybar's tray (SNI flag via
|
||||||
# preferStatusNotifierItems — without it there is no tray icon).
|
# preferStatusNotifierItems — without it there is no tray icon).
|
||||||
@@ -54,6 +81,11 @@
|
|||||||
xdg.userDirs = {
|
xdg.userDirs = {
|
||||||
enable = lib.mkDefault true;
|
enable = lib.mkDefault true;
|
||||||
createDirectories = lib.mkDefault true;
|
createDirectories = lib.mkDefault true;
|
||||||
|
# Pinned: HM 26.05 flips the default to false (and warns on every eval
|
||||||
|
# for older stateVersions). Keep exporting XDG_*_DIR into the session —
|
||||||
|
# scripts and non-glib apps read the vars, and pinning gives every
|
||||||
|
# downstream the same behavior regardless of its stateVersion.
|
||||||
|
setSessionVariables = lib.mkDefault true;
|
||||||
};
|
};
|
||||||
|
|
||||||
home.stateVersion = lib.mkDefault "26.05";
|
home.stateVersion = lib.mkDefault "26.05";
|
||||||
@@ -75,8 +107,8 @@
|
|||||||
# (system nixpkgs.config alone does not cover those entry points).
|
# (system nixpkgs.config alone does not cover those entry points).
|
||||||
NIXPKGS_ALLOW_UNFREE = "1";
|
NIXPKGS_ALLOW_UNFREE = "1";
|
||||||
|
|
||||||
# Where the Nomarchy flake (and therefore theme-state.json) lives on
|
# Where the Nomarchy flake (and therefore state.json) lives on
|
||||||
# disk. nomarchy-theme-sync writes its state here; rebuilds read from
|
# disk. nomarchy-state-sync writes its state here; rebuilds read from
|
||||||
# here. Clone/symlink your flake to this path.
|
# here. Clone/symlink your flake to this path.
|
||||||
NOMARCHY_PATH = "$HOME/.nomarchy";
|
NOMARCHY_PATH = "$HOME/.nomarchy";
|
||||||
};
|
};
|
||||||
|
|||||||
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;
|
cfg = config.nomarchy;
|
||||||
c = cfg.theme.colors;
|
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"
|
logo = pkgs.runCommand "nomarchy-fastfetch-logo"
|
||||||
{ nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg pkgs.chafa ]; } ''
|
{ 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
|
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
|
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
|
# Gaps, borders and colors are baked from the JSON at eval time; theme
|
||||||
# changes arrive via `home-manager switch`.
|
# changes arrive via `home-manager switch`.
|
||||||
{ config, lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
@@ -8,7 +8,7 @@ let
|
|||||||
c = t.colors;
|
c = t.colors;
|
||||||
inherit (config.nomarchy.lib) rgb rgba;
|
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).
|
# to remember per-device layouts (same path night-light's toggle takes).
|
||||||
sync = lib.getExe config.nomarchy.package;
|
sync = lib.getExe config.nomarchy.package;
|
||||||
|
|
||||||
@@ -20,14 +20,29 @@ let
|
|||||||
# --custom-progress is the mic level (0.0-1.0), --custom-icon the
|
# --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
|
# muted/active glyph. Pure bash builtins for parsing so the script needs no
|
||||||
# PATH beyond the two pinned tools.
|
# 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" ''
|
micMute = pkgs.writeShellScript "nomarchy-mic-mute" ''
|
||||||
${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_SOURCE@ toggle
|
${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_SOURCE@ toggle
|
||||||
out=$(${pkgs.wireplumber}/bin/wpctl get-volume @DEFAULT_SOURCE@)
|
out=$(${pkgs.wireplumber}/bin/wpctl get-volume @DEFAULT_SOURCE@)
|
||||||
set -- $out # "Volume: 0.85 [MUTED]" -> $2 is the level
|
set -- $out # "Volume: 0.85 [MUTED]" -> $2 is the level
|
||||||
case "$out" in
|
case "$out" in
|
||||||
*MUTED*) icon=microphone-sensitivity-muted-symbolic ;;
|
*MUTED*) icon=microphone-sensitivity-muted-symbolic; led=1 ;;
|
||||||
*) icon=microphone-sensitivity-high-symbolic ;;
|
*) icon=microphone-sensitivity-high-symbolic; led=0 ;;
|
||||||
esac
|
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"
|
${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}")
|
(e: "${e.mods}, ${e.key}, exec, ${focusOrLaunch}/bin/nomarchy-focus-or-launch ${e.class} ${e.command}")
|
||||||
lofEntries;
|
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
|
workspaceBinds = builtins.concatLists (builtins.genList
|
||||||
(i:
|
(i:
|
||||||
let ws = toString (i + 1);
|
let
|
||||||
|
ws = toString (i + 1);
|
||||||
|
key = if i == 9 then "0" else ws;
|
||||||
in [
|
in [
|
||||||
"$mod, ${ws}, workspace, ${ws}"
|
"$mod, ${key}, workspace, ${ws}"
|
||||||
"$mod SHIFT, ${ws}, movetoworkspace, ${ws}"
|
"$mod SHIFT, ${key}, movetoworkspace, ${ws}"
|
||||||
])
|
])
|
||||||
9);
|
10);
|
||||||
|
|
||||||
# The keyboard binds — single source shared with the SUPER+? cheatsheet
|
# The keyboard binds — single source shared with the SUPER+? cheatsheet
|
||||||
# (rofi.nix renders the same list). Edit them in ./keybinds.nix.
|
# (rofi.nix renders the same list). Edit them in ./keybinds.nix.
|
||||||
@@ -106,6 +124,22 @@ let
|
|||||||
profileWorkspaceRules =
|
profileWorkspaceRules =
|
||||||
lib.mapAttrsToList monitorLib.workspaceRule activeProfile.workspaces;
|
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
|
# Profile applier — on PATH only when profiles are declared (the menu
|
||||||
# row self-gates on it). apply: the profile's rules live via hyprctl +
|
# 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
|
# 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
|
case "''${2:-}" in
|
||||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||||
(name: p:
|
(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"
|
" ${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"
|
+ lib.concatMapStringsSep "\n"
|
||||||
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
|
(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
|
# Workspace pins: the keyword sets the session rule, the dispatch
|
||||||
# moves an already-open workspace over. Pins from a previously
|
# moves an already-open workspace over. Pins from a previously
|
||||||
# applied profile linger until reload/rebuild (hyprctl can only
|
# applied profile linger until reload/rebuild (hyprctl can only
|
||||||
@@ -180,29 +242,140 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
esac
|
esac
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Hotplug auto-switch (opt-in via the menu's Auto-switch row →
|
# Hyprland hotplug watcher: owns the safety-critical dock lifecycle plus
|
||||||
# settings.displayProfileAuto, read LIVE on every output change so the
|
# optional profile auto-switching. A low-level logind lid-switch inhibitor
|
||||||
# toggle is instant, no rebuild). Polls like the keyboard watcher —
|
# is acquired as soon as an external appears. On final external removal,
|
||||||
# exec-once, not a systemd unit (graphical-session-bound units raced
|
# the internal panel is enabled and workspaces restored before the watcher
|
||||||
# Hyprland's IPC on relogin; see the waybar note below). Only reacts to
|
# waits for a physically open lid and releases the inhibitor. The normal
|
||||||
# CHANGES after session start: the baked config already encodes the
|
# undocked lid-close path is therefore unchanged after release.
|
||||||
# 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.
|
|
||||||
displayProfileWatch = pkgs.writeShellScriptBin "nomarchy-display-profile-watch" ''
|
displayProfileWatch = pkgs.writeShellScriptBin "nomarchy-display-profile-watch" ''
|
||||||
set -u
|
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() {
|
auto_on() {
|
||||||
v=$(${sync} get settings.displayProfileAuto 2>/dev/null) || v=false
|
v=$(${sync} get settings.displayProfileAuto 2>/dev/null) || v=false
|
||||||
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
||||||
}
|
}
|
||||||
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
|
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() {
|
check_monitors() {
|
||||||
cur=$(outputs)
|
cur=$(outputs)
|
||||||
[ "$cur" = "$1" ] && return
|
|
||||||
[ -n "$cur" ] || return
|
[ -n "$cur" ] || return
|
||||||
auto_on || return
|
auto_on || return
|
||||||
|
command -v nomarchy-display-profile >/dev/null 2>&1 || return
|
||||||
|
|
||||||
target=$(nomarchy-display-profile match $cur) || target="base"
|
target=$(nomarchy-display-profile match $cur) || target="base"
|
||||||
|
|
||||||
@@ -213,34 +386,232 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
|||||||
[ "$target" = "$(nomarchy-display-profile active)" ] && return
|
[ "$target" = "$(nomarchy-display-profile active)" ] && return
|
||||||
nomarchy-display-profile apply "$target"
|
nomarchy-display-profile apply "$target"
|
||||||
fi
|
fi
|
||||||
echo "$cur"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prev=$(outputs)
|
# Entering dock mode is the mirror of the automatic undock below: the
|
||||||
|
# session already comes back to the panel by itself when the last
|
||||||
# Listen to Hyprland's IPC socket for instant hotplug reaction
|
# external leaves, so an arriving external has to take it the other way
|
||||||
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do
|
# or the pair is only half-automatic and the menu has to finish the job.
|
||||||
case "$line" in
|
# A display profile is a deliberate, more specific layout, so it wins —
|
||||||
monitoradded*|monitorremoved*)
|
# the caller only reaches here when none is in effect.
|
||||||
res=$(check_monitors "$prev")
|
auto_dock() {
|
||||||
[ -n "$res" ] && prev="$res"
|
internal=$(internal_output)
|
||||||
;;
|
target=$(external_outputs | ${pkgs.coreutils}/bin/head -n 1)
|
||||||
esac
|
[ -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
|
done
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Candidate layouts offered by the new-keyboard picker: the session
|
keyboardTool = import ./keyboard-tool.nix { inherit pkgs lib config; };
|
||||||
# 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 != [ ];
|
|
||||||
|
|
||||||
# On a keyboard that connects after login (not declared in
|
# On a keyboard that connects after login (not declared in
|
||||||
# keyboard.devices, not already remembered), ask for a layout and persist it
|
# 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).
|
# test (hotplug isn't verifiable in CI).
|
||||||
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
|
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
|
||||||
set -u
|
set -u
|
||||||
layouts="${lib.concatStringsSep " " pickerLayouts}"
|
|
||||||
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
|
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
|
||||||
sync=${sync}
|
tool=${keyboardTool}/bin/nomarchy-keyboard-layout
|
||||||
|
|
||||||
apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
|
|
||||||
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
|
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
|
||||||
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
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;
|
# Hyprland calls every evdev node that can emit a key a "keyboard": the
|
||||||
# an absent key (sparse state) reads as an empty object.
|
# lid and power buttons, a monitor's control channel, and each extra HID
|
||||||
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
|
# collection a real keyboard exposes. One dock with one keyboard on it
|
||||||
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
|
# therefore used to ask which layout to use four times, for devices that
|
||||||
|
# cannot type. udev already draws this distinction properly — it sets
|
||||||
# Persist a pick INSTANTLY: merge it into the map and write the whole object
|
# ID_INPUT_KEYBOARD only for full typing keyboards — so take the answer
|
||||||
# back at the dot-free parent path, so a device name containing a dot can't
|
# from there and translate the names into Hyprland's own spelling:
|
||||||
# corrupt the dotted set-path. --no-switch = write only, no rebuild.
|
# spaces to '-', then lowercase (its deviceNameToInternalString).
|
||||||
remember() {
|
typing_keyboards() {
|
||||||
map=$(saved_map | jq -c --arg k "$1" --arg v "$2" '. + {($k): $v}') || return
|
for dev in /sys/class/input/event*; do
|
||||||
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch
|
[ -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
|
# Startup: re-apply remembered layouts, never prompt — so the built-in
|
||||||
# keyboard just stays on the session default.
|
# keyboard just stays on the session default.
|
||||||
prev=" "
|
prev=" "
|
||||||
for kb in $(keyboards); do
|
for kb in $(keyboards); do
|
||||||
prev="$prev$kb "
|
prev="$prev$kb "
|
||||||
is_declared "$kb" && continue
|
is_declared "$kb" && continue
|
||||||
s=$(saved_for "$kb"); [ -n "$s" ] && apply "$kb" "$s"
|
"$tool" restore "$kb" || true
|
||||||
done
|
done
|
||||||
|
|
||||||
# Watch for keyboards that connect later.
|
# Watch for keyboards that connect later.
|
||||||
while :; do
|
while :; do
|
||||||
sleep 3
|
sleep 3
|
||||||
cur=" "
|
cur=" "
|
||||||
|
new=
|
||||||
for kb in $(keyboards); do
|
for kb in $(keyboards); do
|
||||||
cur="$cur$kb "
|
cur="$cur$kb "
|
||||||
case "$prev" in *" $kb "*) continue ;; esac
|
case "$prev" in *" $kb "*) continue ;; esac
|
||||||
is_declared "$kb" && continue
|
is_declared "$kb" && continue
|
||||||
s=$(saved_for "$kb")
|
new="$new $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
|
|
||||||
done
|
done
|
||||||
prev="$cur"
|
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
|
done
|
||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
@@ -324,6 +724,24 @@ in
|
|||||||
# (Hyprland then boots into emergency mode with no binds).
|
# (Hyprland then boots into emergency mode with no binds).
|
||||||
configType = "hyprlang";
|
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 = {
|
settings = {
|
||||||
"$mod" = "SUPER";
|
"$mod" = "SUPER";
|
||||||
"$terminal" = config.nomarchy.terminal;
|
"$terminal" = config.nomarchy.terminal;
|
||||||
@@ -347,7 +765,7 @@ in
|
|||||||
"awww-daemon"
|
"awww-daemon"
|
||||||
# Paint the wallpaper as soon as the session is up (waits for
|
# Paint the wallpaper as soon as the session is up (waits for
|
||||||
# the daemon internally).
|
# the daemon internally).
|
||||||
"nomarchy-theme-sync wallpaper"
|
"nomarchy-state-sync wallpaper"
|
||||||
# Polkit authentication agent — without one, EVERY pkexec/polkit
|
# Polkit authentication agent — without one, EVERY pkexec/polkit
|
||||||
# prompt in the session fails silently (btrfs-assistant-launcher
|
# prompt in the session fails silently (btrfs-assistant-launcher
|
||||||
# was the discovery case). hyprpolkitagent is Hyprland's own Qt
|
# 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
|
# the clean pkill a theme switch now does — respawns the bar instead
|
||||||
# of orphaning the session bar-less.
|
# of orphaning the session bar-less.
|
||||||
] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar"
|
] ++ 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 ──────────────────────────────────────────
|
# ── Theme-driven look ──────────────────────────────────────────
|
||||||
# These flow from theme-state.json and stay at NORMAL priority:
|
# These flow from state.json and stay at NORMAL priority:
|
||||||
# change them with `nomarchy-theme-sync set ui.<key>` (the intended
|
# change them with `nomarchy-state-sync set ui.<key>` (the intended
|
||||||
# path), or lib.mkForce in home.nix to hardcode against the theme.
|
# path), or lib.mkForce in home.nix to hardcode against the theme.
|
||||||
# The non-theme knobs around them are lib.mkDefault — override
|
# The non-theme knobs around them are lib.mkDefault — override
|
||||||
# those with a plain home.nix assignment. See docs/OVERRIDES.md.
|
# those with a plain home.nix assignment. See docs/OVERRIDES.md.
|
||||||
@@ -394,7 +814,12 @@ in
|
|||||||
};
|
};
|
||||||
shadow = {
|
shadow = {
|
||||||
enabled = t.ui.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;
|
render_power = lib.mkDefault 3;
|
||||||
color = rgba c.mantle "aa";
|
color = rgba c.mantle "aa";
|
||||||
};
|
};
|
||||||
@@ -446,6 +871,10 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
misc = {
|
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_hyprland_logo = lib.mkDefault true;
|
||||||
disable_splash_rendering = lib.mkDefault true;
|
disable_splash_rendering = lib.mkDefault true;
|
||||||
force_default_wallpaper = lib.mkDefault 0;
|
force_default_wallpaper = lib.mkDefault 0;
|
||||||
@@ -454,6 +883,35 @@ in
|
|||||||
# instead of compositor black — on light themes the black flash
|
# instead of compositor black — on light themes the black flash
|
||||||
# reads as a glitch (item 28c, seen in the capture harness).
|
# reads as a glitch (item 28c, seen in the capture harness).
|
||||||
background_color = rgb c.base;
|
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
|
# Window rules — float + center small config/utility dialogs that
|
||||||
@@ -484,13 +942,24 @@ in
|
|||||||
"float 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
|
"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)?)$"
|
"center 1, match:class (?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
|
||||||
|
|
||||||
# Calendar popup (item 42): the Waybar clock's on-click runs
|
# Calendar popup (item 42): nomarchy-calendar → calcurse in a
|
||||||
# nomarchy-calendar → calcurse in a ghostty window tagged with a
|
# kitty window tagged with a distinct --class, so it floats centered.
|
||||||
# distinct --class, so it floats centered instead of tiling.
|
# 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)$"
|
"float 1, match:class ^(com\\.nomarchy\\.calendar)$"
|
||||||
"size 60% 65%, match:class ^(com\\.nomarchy\\.calendar)$"
|
|
||||||
"center 1, 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)
|
# Polkit auth (hyprpolkitagent — app id / binary name in the package)
|
||||||
# and pinentry-qt (keys.nix default; org.gnupg.pinentry-qt desktop).
|
# and pinentry-qt (keys.nix default; org.gnupg.pinentry-qt desktop).
|
||||||
# workspace current: agent was started on ws1 at login; without this
|
# workspace current: agent was started on ws1 at login; without this
|
||||||
@@ -567,11 +1036,11 @@ in
|
|||||||
home.packages =
|
home.packages =
|
||||||
lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
|
lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
|
||||||
[ pkgs.nwg-displays ]
|
[ pkgs.nwg-displays ]
|
||||||
# The new-keyboard watcher on PATH (when enabled) so it's discoverable and
|
# Runtime hardware watchers/helpers stay on PATH for manual recovery and
|
||||||
# runnable by name for debugging — Hyprland still exec-once's it by store path.
|
# debugging. The display-profile switcher itself remains gated so the
|
||||||
++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch
|
# menu does not advertise an empty Profiles submenu.
|
||||||
# The display-profile switcher (the menu's Profiles row gates on it)
|
++ lib.optionals config.nomarchy.hyprland.enable
|
||||||
# + its hotplug watcher, on PATH for debugging like the keyboard one.
|
([ keyboardTool keyboardWatch displayTransitionTool displayProfileWatch
|
||||||
++ lib.optionals (config.nomarchy.hyprland.enable && profiles != { })
|
displayDumpTool displayWakeTool ]
|
||||||
[ displayProfileTool displayProfileWatch ];
|
++ lib.optional (profiles != { }) displayProfileTool);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# hyprlock + hypridle — screen locking and idle management, themed from
|
# 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
|
# lock, display off, suspend, lock-before-sleep), hyprlock is the
|
||||||
# themed lock screen itself (also behind the power menu's Lock entry).
|
# themed lock screen itself (also behind the power menu's Lock entry).
|
||||||
{ config, lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
@@ -19,9 +19,32 @@ let
|
|||||||
done
|
done
|
||||||
exit 1
|
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
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf cfg.idle.enable {
|
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 = {
|
programs.hyprlock = {
|
||||||
enable = true;
|
enable = true;
|
||||||
settings = {
|
settings = {
|
||||||
@@ -43,9 +66,21 @@ in
|
|||||||
check_color = rgb c.warn;
|
check_color = rgb c.warn;
|
||||||
fail_color = rgb c.bad;
|
fail_color = rgb c.bad;
|
||||||
rounding = t.ui.rounding;
|
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 = [{
|
label = [{
|
||||||
monitor = "";
|
monitor = "";
|
||||||
text = "$TIME";
|
text = "$TIME";
|
||||||
@@ -55,7 +90,32 @@ in
|
|||||||
position = "0, 120";
|
position = "0, 120";
|
||||||
halign = "center";
|
halign = "center";
|
||||||
valign = "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
|
# can't be safely dropped after the fact — killing the locker trips
|
||||||
# its "go to a tty" crash failsafe). See nomarchy-lock-before-sleep
|
# its "go to a tty" crash failsafe). See nomarchy-lock-before-sleep
|
||||||
# in modules/nixos/default.nix.
|
# 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 = [
|
listener = [
|
||||||
# Lock and screen-off are the same on either power source —
|
# Lock and screen-off are the same on either power source —
|
||||||
@@ -79,15 +142,28 @@ in
|
|||||||
{ timeout = 300; on-timeout = "loginctl lock-session"; }
|
{ timeout = 300; on-timeout = "loginctl lock-session"; }
|
||||||
{
|
{
|
||||||
timeout = 600;
|
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-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
|
# Suspend only on battery, and sooner than the old fixed 30 min
|
||||||
# (it now only fires unplugged). Plugged in, the machine stays
|
# (it now only fires unplugged). Plugged in, the machine stays
|
||||||
# up — long builds, media, presentations aren't killed mid-idle.
|
# up — long builds, media, presentations aren't killed mid-idle.
|
||||||
# Closing the lid still suspends on AC (logind's default): that's
|
# nomarchy-suspend (#115): on battery + hibernate wired + toggle
|
||||||
# an explicit "I'm done", distinct from sitting idle.
|
# on → suspend-then-hibernate (1h → disk); else plain suspend.
|
||||||
{ timeout = 900; on-timeout = "${onAc} || systemctl 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,74 +7,91 @@
|
|||||||
# key — the key name
|
# key — the key name
|
||||||
# action — the Hyprland dispatcher (+args) that the bind runs
|
# action — the Hyprland dispatcher (+args) that the bind runs
|
||||||
# desc — human label, shown in the cheatsheet
|
# desc — human label, shown in the cheatsheet
|
||||||
|
# group — cheatsheet section: Window | Workspace | Menu | Media (#108)
|
||||||
#
|
#
|
||||||
# `extra` holds cheatsheet-only rows for binds generated elsewhere
|
# `extra` holds cheatsheet-only rows for binds generated elsewhere
|
||||||
# (per-workspace numbers, mouse drags) so they still show up under SUPER+?.
|
# (per-workspace numbers, mouse drags) so they still show up under SUPER+?.
|
||||||
{
|
{
|
||||||
binds = [
|
binds = [
|
||||||
{ mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; }
|
{ 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"; }
|
{ 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"; }
|
{ 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)"; }
|
{ mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; group = "Window"; }
|
||||||
{ mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; }
|
{ mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; group = "Window"; }
|
||||||
{ mods = "$mod"; key = "F"; action = "fullscreen"; desc = "Fullscreen"; }
|
{ mods = "$mod"; key = "F"; action = "fullscreen"; desc = "Fullscreen"; group = "Window"; }
|
||||||
{ mods = "$mod"; key = "V"; action = "togglefloating"; desc = "Toggle floating"; }
|
{ mods = "$mod"; key = "V"; action = "togglefloating"; desc = "Toggle floating"; group = "Window"; }
|
||||||
{ mods = "$mod SHIFT"; key = "E"; action = "exit"; desc = "Exit Hyprland"; }
|
{ mods = "$mod SHIFT"; key = "E"; action = "exit"; desc = "Exit Hyprland"; group = "Window"; }
|
||||||
|
|
||||||
# Theme picker (menu dispatcher): apply writes the state and runs
|
# Theme picker (menu dispatcher): apply writes the state and runs
|
||||||
# home-manager switch (progress via notify-send).
|
# 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).
|
# 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
|
# Power menu via the dispatcher. Not Escape: Super+Escape gets
|
||||||
# swallowed before reaching the dispatcher on some setups.
|
# 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
|
# SUPER+? — ? is Shift+/. SHIFT stays in the modmask (you hold it), but
|
||||||
# the keysym must be the BASE key `slash`, not `question`: Hyprland
|
# the keysym must be the BASE key `slash`, not `question`: Hyprland
|
||||||
# resolves the sym with Shift consumed, so `question` never matches while
|
# resolves the sym with Shift consumed, so `question` never matches while
|
||||||
# Shift is down — same as the `$mod SHIFT, 1` workspace binds. (item 26
|
# 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.)
|
# fixed the modmask but kept the shifted keysym → still dead; item 32.)
|
||||||
# The cheatsheet still renders this row as SUPER + ? (rofi.nix).
|
# 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
|
# Menu functions — SUPER+CTRL+<mnemonic> jumps straight to a
|
||||||
# nomarchy-menu module (all also reachable from the SUPER+M picker).
|
# 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 = "V"; action = "exec, nomarchy-menu clipboard"; desc = "Clipboard history"; group = "Menu"; }
|
||||||
{ mods = "$mod CTRL"; key = "C"; action = "exec, nomarchy-menu calc"; desc = "Calculator"; }
|
{ 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"; }
|
{ 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"; }
|
{ 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"; }
|
{ 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)"; }
|
{ 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"; }
|
{ mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; group = "Menu"; }
|
||||||
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; }
|
{ mods = "$mod CTRL"; key = "R"; action = "exec, nomarchy-menu airplane"; desc = "Airplane mode (Wi-Fi + Bluetooth)"; group = "Menu"; }
|
||||||
{ mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker (→ clipboard)"; }
|
{ mods = "$mod CTRL"; key = "K"; action = "exec, nomarchy-menu keyboard"; desc = "Keyboard layout"; group = "Menu"; }
|
||||||
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; }
|
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; group = "Media"; }
|
||||||
{ mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; }
|
{ mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker (→ clipboard)"; group = "Menu"; }
|
||||||
{ mods = "$mod SHIFT"; key = "C"; action = "exec, hyprpicker -a"; desc = "Color picker (→ clipboard)"; }
|
{ 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.
|
# Focus — SUPER + arrow keys.
|
||||||
{ mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; }
|
{ mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; group = "Window"; }
|
||||||
{ mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; }
|
{ mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; group = "Window"; }
|
||||||
{ mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; }
|
{ mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; group = "Window"; }
|
||||||
{ mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; }
|
{ mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; group = "Window"; }
|
||||||
|
|
||||||
# Multi-monitor workspace movement — SUPER + ALT + arrow keys.
|
# 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 = "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"; }
|
{ 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"; }
|
{ 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"; }
|
{ 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).
|
# Screenshots (the menu's Capture module has the rest: OCR, recording).
|
||||||
# Bare Print → region to clipboard; the two → file binds save a
|
# Bare Print → region to clipboard; the two → file binds save a
|
||||||
# timestamped PNG under ~/Pictures/Screenshots and toast the path, the
|
# timestamped PNG under ~/Pictures/Screenshots and toast the path, the
|
||||||
# same plumbing the Capture menu's "→ file" rows use.
|
# same plumbing the Capture menu's "→ file" rows use.
|
||||||
{ mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy && notify-send Screenshot \"Region copied to clipboard.\""; desc = "Screenshot region → clipboard"; }
|
{ 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"; }
|
{ 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"; }
|
{ 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"; }
|
{ 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
|
# Rendered only when the session has >1 layout (a comma in
|
||||||
@@ -83,14 +100,14 @@
|
|||||||
# step. `current` targets the focused keyboard, so a board with its
|
# 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.
|
# own per-device layout (a single one) is a no-op, never a leak.
|
||||||
multiLayoutBinds = [
|
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 = [
|
extra = [
|
||||||
{ keys = "SUPER + 1-9"; desc = "Switch to workspace 1-9"; }
|
{ keys = "SUPER + 1-9, 0"; desc = "Switch to workspace 1-10 (0 = 10)"; group = "Workspace"; }
|
||||||
{ keys = "SUPER + SHIFT + 1-9"; desc = "Move window to workspace 1-9"; }
|
{ 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"; }
|
{ keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; group = "Window"; }
|
||||||
{ keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; }
|
{ keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; group = "Media"; }
|
||||||
{ keys = "Bar: click"; desc = "Caffeine — hold the screen awake (idle inhibitor)"; }
|
{ 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,15 +44,22 @@ lib.mkIf config.nomarchy.mime.enable {
|
|||||||
"audio/x-m4a" = "io.bassi.Amberol.desktop";
|
"audio/x-m4a" = "io.bassi.Amberol.desktop";
|
||||||
"audio/aac" = "io.bassi.Amberol.desktop";
|
"audio/aac" = "io.bassi.Amberol.desktop";
|
||||||
|
|
||||||
# The template's active editor; degrades if you drop vscode.
|
# Prefer the template's vscode; fall through to gnome-text-editor
|
||||||
"text/plain" = "code.desktop";
|
# (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).
|
# The system-side Thunar (nomarchy.system.fileManager).
|
||||||
"inode/directory" = "thunar.desktop";
|
"inode/directory" = "thunar.desktop";
|
||||||
|
|
||||||
# Template ships chromium (chromium-browser.desktop). Entry is
|
# Template ships chromium (chromium-browser.desktop), and since #103
|
||||||
# skipped by GIO if the package is deleted; install another browser
|
# so does the live ISO — an entry naming a package nothing installs is
|
||||||
# and/or override these keys to retarget.
|
# 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";
|
"text/html" = "chromium-browser.desktop";
|
||||||
"x-scheme-handler/http" = "chromium-browser.desktop";
|
"x-scheme-handler/http" = "chromium-browser.desktop";
|
||||||
"x-scheme-handler/https" = "chromium-browser.desktop";
|
"x-scheme-handler/https" = "chromium-browser.desktop";
|
||||||
|
|||||||
@@ -45,29 +45,42 @@ let
|
|||||||
v=$(${sync} get settings.nightlight.on 2>/dev/null) || v=${onDefault}
|
v=$(${sync} get settings.nightlight.on 2>/dev/null) || v=${onDefault}
|
||||||
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
||||||
}
|
}
|
||||||
installed() { systemctl --user cat "$unit" >/dev/null 2>&1; }
|
# LoadState=loaded means a real unit (not the empty-file mask HM/packages
|
||||||
start() { systemctl --user start "$unit" 2>/dev/null || true; }
|
# 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; }
|
stop() { systemctl --user stop "$unit" 2>/dev/null || true; }
|
||||||
case "''${1:-toggle}" in
|
case "''${1:-toggle}" in
|
||||||
should-start) is_on ;; # ExecCondition gate (login/reboot)
|
should-start) is_on ;; # ExecCondition gate (login/reboot)
|
||||||
status)
|
status)
|
||||||
# Waybar (polls every 3s): moon while running; print nothing otherwise so
|
# Waybar (polls every 3s): moon while running; print nothing otherwise so
|
||||||
# the module self-hides — enable / re-enable from the System menu.
|
# the module self-hides — enable / re-enable from Look & Feel.
|
||||||
systemctl --user is-active --quiet "$unit" \
|
# `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'
|
&& printf '{"text":"","tooltip":"Night light on — ${if geo then "follows your location" else "warm on schedule"} (click to disable)","class":"on"}\n'
|
||||||
exit 0 ;;
|
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)
|
on)
|
||||||
if installed; then write_on true; start; else install_feature; start; fi ;;
|
if installed; then write_on true; start; else install_feature; start; fi ;;
|
||||||
off) write_on false; stop ;;
|
off) write_on false; stop ;;
|
||||||
toggle)
|
toggle)
|
||||||
if systemctl --user is-active --quiet "$unit"; then
|
if active; then
|
||||||
write_on false; stop # on -> off (instant)
|
write_on false; stop # on -> off (instant)
|
||||||
elif installed; then
|
elif installed; then
|
||||||
write_on true; start # installed, off -> on (instant)
|
write_on true; start # installed, off -> on (instant)
|
||||||
else
|
else
|
||||||
install_feature; start # first enable (rebuilds)
|
install_feature; start # first enable (rebuilds)
|
||||||
fi ;;
|
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
|
esac
|
||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -88,11 +88,11 @@ in
|
|||||||
# ── Required ───────────────────────────────────────────────────
|
# ── Required ───────────────────────────────────────────────────
|
||||||
stateFile = lib.mkOption {
|
stateFile = lib.mkOption {
|
||||||
type = lib.types.path;
|
type = lib.types.path;
|
||||||
example = lib.literalExpression "./theme-state.json";
|
example = lib.literalExpression "./state.json";
|
||||||
description = ''
|
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
|
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.
|
on-disk copy; rebuilds bake it into the generation.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
@@ -100,10 +100,18 @@ in
|
|||||||
# ── Preferences ────────────────────────────────────────────────
|
# ── Preferences ────────────────────────────────────────────────
|
||||||
terminal = lib.mkOption {
|
terminal = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = config.nomarchy.settings.terminal or "ghostty";
|
default = config.nomarchy.settings.terminal or "kitty";
|
||||||
description = "Terminal emulator command, used by keybinds and $TERMINAL.";
|
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 {
|
keyboard.layout = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = config.nomarchy.settings.keyboard.layout or "us";
|
default = config.nomarchy.settings.keyboard.layout or "us";
|
||||||
@@ -128,13 +136,13 @@ in
|
|||||||
default = [ ];
|
default = [ ];
|
||||||
example = [ "de" "fr" ];
|
example = [ "de" "fr" ];
|
||||||
description = ''
|
description = ''
|
||||||
Extra candidate layouts offered by the interactive new-keyboard
|
Extra candidate layouts shown first by the interactive new-keyboard
|
||||||
picker. When non-empty, a small watcher runs in the session: when a
|
picker (all installed XKB layouts remain searchable). A small watcher
|
||||||
keyboard connects that isn't covered by nomarchy.keyboard.devices and
|
always runs in the Hyprland session: when a keyboard connects that
|
||||||
hasn't been chosen before, it pops a rofi picker (these layouts plus
|
isn't covered by nomarchy.keyboard.devices and hasn't been chosen
|
||||||
the primary nomarchy.keyboard.layout), applies the choice to that
|
before, it pops a rofi picker, applies the choice to that keyboard only
|
||||||
keyboard only (a per-device kb_layout, so the built-in board is left
|
(a per-device kb_layout, so the built-in board is left alone), and
|
||||||
alone), and remembers it in the git-tracked in-flake state
|
remembers it in the git-tracked in-flake state
|
||||||
(settings.keyboard.devices, not ~/.local/state) — re-applied
|
(settings.keyboard.devices, not ~/.local/state) — re-applied
|
||||||
automatically on later reconnects and across reboots. Each remembered
|
automatically on later reconnects and across reboots. Each remembered
|
||||||
choice graduates into nomarchy.keyboard.devices on the next rebuild
|
choice graduates into nomarchy.keyboard.devices on the next rebuild
|
||||||
@@ -174,9 +182,9 @@ in
|
|||||||
|
|
||||||
package = lib.mkOption {
|
package = lib.mkOption {
|
||||||
type = lib.types.package;
|
type = lib.types.package;
|
||||||
default = pkgs.nomarchy-theme-sync;
|
default = pkgs.nomarchy-state-sync;
|
||||||
defaultText = lib.literalExpression "pkgs.nomarchy-theme-sync";
|
defaultText = lib.literalExpression "pkgs.nomarchy-state-sync";
|
||||||
description = "The nomarchy-theme-sync package (provided by overlays.default).";
|
description = "The nomarchy-state-sync package (provided by overlays.default).";
|
||||||
};
|
};
|
||||||
|
|
||||||
themesDir = lib.mkOption {
|
themesDir = lib.mkOption {
|
||||||
@@ -364,13 +372,37 @@ in
|
|||||||
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
|
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
|
||||||
swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; };
|
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; };
|
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.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; };
|
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; };
|
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; };
|
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; };
|
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; };
|
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; };
|
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; };
|
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; };
|
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
|
# lock). The media keys drive it via swayosd-client (it performs the
|
||||||
# action AND shows the OSD), so changing volume or brightness gives the
|
# action AND shows the OSD), so changing volume or brightness gives the
|
||||||
# visual feedback that bare wpctl/brightnessctl didn't. Themed from
|
# 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).
|
# system-side (modules/nixos/default.nix → services.udev.packages).
|
||||||
{ config, lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ lib.mkIf config.nomarchy.rofi.enable {
|
|||||||
filefile="$run/nomarchy-record.file"
|
filefile="$run/nomarchy-record.file"
|
||||||
|
|
||||||
alive() { [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; }
|
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
|
case "''${1:-}" in
|
||||||
start)
|
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
|
# Shell experience — zsh (the default login shell, set system-side) with
|
||||||
# autosuggestions + syntax highlighting, a themed starship prompt, and
|
# autosuggestions + syntax highlighting, a themed starship prompt, and
|
||||||
# modern CLI tools wired in: bat (cat), eza (ls), zoxide (cd). bat uses
|
# 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.
|
# from the same JSON); starship is themed from the palette directly.
|
||||||
#
|
#
|
||||||
# home.shell.enableZshIntegration = true is the single lever that makes
|
# 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.
|
# its zsh hooks — no per-module flags needed.
|
||||||
{ config, lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
# bat: "ansi" follows the terminal's 16-color palette → tracks the
|
# 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 = {
|
programs.bat = {
|
||||||
enable = true;
|
enable = true;
|
||||||
config = {
|
config = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Stylix — themes the long tail of applications (GTK, Qt, cursors,
|
# 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
|
# is owned by the Nomarchy engine and updates instantly; everything
|
||||||
# Stylix touches updates on the next home-manager switch. That is why
|
# Stylix touches updates on the next home-manager switch. That is why
|
||||||
# autoEnable is off and the trio's Stylix targets stay disabled.
|
# autoEnable is off and the trio's Stylix targets stay disabled.
|
||||||
@@ -16,6 +16,31 @@ let
|
|||||||
c = t.colors;
|
c = t.colors;
|
||||||
hex = lib.removePrefix "#";
|
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.
|
# Map the Nomarchy palette onto base16 roles.
|
||||||
base16 = {
|
base16 = {
|
||||||
base00 = hex c.base; # default background
|
base00 = hex c.base; # default background
|
||||||
@@ -35,6 +60,7 @@ let
|
|||||||
base0E = hex c.accentAlt;# magenta
|
base0E = hex c.accentAlt;# magenta
|
||||||
base0F = hex c.bad; # brown/deprecated
|
base0F = hex c.bad; # brown/deprecated
|
||||||
};
|
};
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf cfg.stylix.enable {
|
config = lib.mkIf cfg.stylix.enable {
|
||||||
@@ -45,7 +71,148 @@ in
|
|||||||
base16Scheme = base16;
|
base16Scheme = base16;
|
||||||
|
|
||||||
targets = {
|
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;
|
qt.enable = true;
|
||||||
# No-op unless programs.zathura is on (viewers.nix enables it).
|
# No-op unless programs.zathura is on (viewers.nix enables it).
|
||||||
zathura.enable = true;
|
zathura.enable = true;
|
||||||
@@ -91,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
|
# GTK4/libadwaita and Qt6 apps decide dark vs light from the XDG
|
||||||
# portal's color-scheme (org.freedesktop.appearance), which
|
# portal's color-scheme (org.freedesktop.appearance), which
|
||||||
# xdg-desktop-portal-gtk sources from this gsettings key. Stylix sets
|
# xdg-desktop-portal-gtk sources from this gsettings key. Stylix sets
|
||||||
# the GTK theme and `polarity` but not this, so a light theme still
|
# 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
|
# rendered libadwaita/Qt apps dark (and vice-versa). Drive it from the
|
||||||
# palette mode. (programs.dconf.enable is already on system-side.)
|
# palette mode. (programs.dconf.enable is already on system-side.)
|
||||||
dconf.settings."org/gnome/desktop/interface".color-scheme =
|
dconf.settings."org/gnome/desktop/interface" = {
|
||||||
if t.mode == "light" then "prefer-light" else "prefer-dark";
|
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
|
# 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
|
# all: the theme-switch progress toasts, the CLI's font warnings and the
|
||||||
# live ISO's welcome message were all invisible.
|
# live ISO's welcome message were all invisible.
|
||||||
{ config, lib, ... }:
|
{ config, lib, ... }:
|
||||||
@@ -29,7 +29,7 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
style = ''
|
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
|
contrast @base in EVERY palette are used: subtext/surface mean
|
||||||
"on-surface" in some light themes (summer-day: subtext==base,
|
"on-surface" in some light themes (summer-day: subtext==base,
|
||||||
surface==text — body text was invisible on hardware, item 25).
|
surface==text — body text was invisible on hardware, item 25).
|
||||||
@@ -71,12 +71,47 @@ in
|
|||||||
font-size: ${toString t.fonts.size}pt;
|
font-size: ${toString t.fonts.size}pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-center .notification-row:focus,
|
/* Unscoped on purpose: the floating popup wraps .notification in
|
||||||
.control-center .notification-row:hover {
|
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);
|
background: alpha(@text, 0.1);
|
||||||
border-radius: ${r}px;
|
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 {
|
.widget-title {
|
||||||
color: @text;
|
color: @text;
|
||||||
font-weight: bold;
|
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,28 +1,29 @@
|
|||||||
# Nomarchy theming engine.
|
# 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
|
# ingested at evaluation time — pure, because flake files are store
|
||||||
# paths — and exposed to every other module as `config.nomarchy.theme`.
|
# 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
|
# 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
|
# read-only generation. No runtime patching, no partial states; theme
|
||||||
# history is generation history.
|
# history is generation history.
|
||||||
#
|
#
|
||||||
# The one runtime exception is the wallpaper (swww is imperative by
|
# The one runtime exception is the wallpaper (swww is imperative by
|
||||||
# nature): applied at session start, after every switch (hook below),
|
# nature): applied at session start, after every switch (hook below), and
|
||||||
# and cycled instantly with `nomarchy-theme-sync bg next`.
|
# after output hotplug (hyprland.nix); cycled instantly with
|
||||||
|
# `nomarchy-state-sync bg next`.
|
||||||
{ config, lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.nomarchy;
|
cfg = config.nomarchy;
|
||||||
|
|
||||||
# Fail-closed load: missing / empty / non-object get a short pointer at
|
# Fail-closed load: missing / empty / non-object get a short pointer at
|
||||||
# the template + `nomarchy-theme-sync validate`, not a raw readFile stack
|
# the template + `nomarchy-state-sync validate`, not a raw readFile stack
|
||||||
# from deep inside a consumer (nightlight, hyprland, …). Field-level
|
# from deep inside a consumer (nightlight, hyprland, …). Field-level
|
||||||
# checks below still run on the merged result.
|
# checks below still run on the merged result.
|
||||||
themeState = import ../theme-state-read.nix { inherit lib; } cfg.stateFile;
|
themeState = import ../state-read.nix { inherit lib; } cfg.stateFile;
|
||||||
|
|
||||||
# Defaults guarantee evaluation succeeds on a sparse or older state
|
# Defaults guarantee evaluation succeeds on a sparse or older state
|
||||||
# file (e.g. one written before a schema field was added). The shipped
|
# file (e.g. one written before a schema field was added). The shipped
|
||||||
@@ -70,6 +71,8 @@ let
|
|||||||
# name in hyprland.nix — the monitor twin of the keyboard graduation.
|
# name in hyprland.nix — the monitor twin of the keyboard graduation.
|
||||||
# settings.displayProfile / displayProfileAuto: active named layout +
|
# settings.displayProfile / displayProfileAuto: active named layout +
|
||||||
# auto-switch on plug events (hyprland.nix / Display menu).
|
# 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
|
# Defaulted so a sparse/older state file still evaluates; nomarchy.settings
|
||||||
# exposes them.
|
# exposes them.
|
||||||
settings = {
|
settings = {
|
||||||
@@ -82,13 +85,14 @@ let
|
|||||||
autoTimezone = false;
|
autoTimezone = false;
|
||||||
displayProfile = "";
|
displayProfile = "";
|
||||||
displayProfileAuto = false;
|
displayProfileAuto = false;
|
||||||
|
firstBootShown = false;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
parsed = lib.recursiveUpdate defaults themeState;
|
parsed = lib.recursiveUpdate defaults themeState;
|
||||||
|
|
||||||
# ── Friendly eval-time validation ───────────────────────────────────
|
# ── 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
|
# 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
|
# fail with the field, the problem, and the fix — not a Nix stack
|
||||||
# trace deep in some consumer. Checks run on `parsed` (after the
|
# trace deep in some consumer. Checks run on `parsed` (after the
|
||||||
@@ -133,14 +137,14 @@ let
|
|||||||
if problems == [ ] then parsed
|
if problems == [ ] then parsed
|
||||||
else throw ''
|
else throw ''
|
||||||
|
|
||||||
Nomarchy: your theme-state.json is invalid:
|
Nomarchy: your state.json is invalid:
|
||||||
${lib.concatMapStrings (p: " ✖ ${p}\n") problems}
|
${lib.concatMapStrings (p: " ✖ ${p}\n") problems}
|
||||||
Fix the named field(s) in the theme-state.json of your flake
|
Fix the named field(s) in the state.json of your flake
|
||||||
checkout (usually ~/.nomarchy/theme-state.json — the store copy at
|
checkout (usually ~/.nomarchy/state.json — the store copy at
|
||||||
${toString cfg.stateFile} is a snapshot of it). The tool prints
|
${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:
|
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
|
# A border value is a palette key (look it up in colors) unless it's
|
||||||
# already a literal hex. Unknown roles are rejected by the field checks
|
# already a literal hex. Unknown roles are rejected by the field checks
|
||||||
@@ -151,7 +155,7 @@ let
|
|||||||
|
|
||||||
Nomarchy: border role "${v}" is not in the palette (colors.*).
|
Nomarchy: border role "${v}" is not in the palette (colors.*).
|
||||||
Use one of: ${lib.concatStringsSep ", " colorRoles}
|
Use one of: ${lib.concatStringsSep ", " colorRoles}
|
||||||
or a literal "#RRGGBB". Validate with: nomarchy-theme-sync validate'');
|
or a literal "#RRGGBB". Validate with: nomarchy-state-sync validate'');
|
||||||
border = {
|
border = {
|
||||||
active = resolveColor checked.border.active;
|
active = resolveColor checked.border.active;
|
||||||
inactive = resolveColor checked.border.inactive;
|
inactive = resolveColor checked.border.inactive;
|
||||||
@@ -197,10 +201,73 @@ let
|
|||||||
name = "nomarchy-icon-themes";
|
name = "nomarchy-icon-themes";
|
||||||
paths = [ papirusPkg matchedPack ];
|
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
|
in
|
||||||
{
|
{
|
||||||
config = {
|
config = {
|
||||||
nomarchy.theme = checked // { inherit iconTheme border iconThemePackage; };
|
# 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
|
# Feature toggles the menu writes (settings.nightlight.enable, …), exposed
|
||||||
# alongside the appearance state. Feature modules mkDefault-read from here
|
# 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
|
# timezone. Waybar's clock module captures the zone once at construction, so a
|
||||||
# runtime timezone change (automatic-timezoned, nomarchy.system.autoTimezone)
|
# runtime timezone change (automatic-timezoned, nomarchy.system.autoTimezone)
|
||||||
# would NOT show until a relogin. A tiny watcher subscribes to timedate1's
|
# 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
|
# so the clock follows your location live. Also catches a manual
|
||||||
# `timedatectl set-timezone`.
|
# `timedatectl set-timezone`.
|
||||||
#
|
#
|
||||||
@@ -24,7 +24,8 @@ let
|
|||||||
cur=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
|
cur=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
|
||||||
[ "$cur" = "$last" ] && continue
|
[ "$cur" = "$last" ] && continue
|
||||||
last=$cur
|
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
|
done
|
||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.nomarchy.updates;
|
cfg = config.nomarchy.updates;
|
||||||
|
termSheet = import ./term-sheet.nix { inherit pkgs; };
|
||||||
|
|
||||||
nomarchy-updates = pkgs.writeShellScriptBin "nomarchy-updates" ''
|
nomarchy-updates = pkgs.writeShellScriptBin "nomarchy-updates" ''
|
||||||
set -u
|
set -u
|
||||||
@@ -70,7 +71,8 @@ let
|
|||||||
echo 0
|
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
|
case "''${1:-status}" in
|
||||||
check)
|
check)
|
||||||
@@ -113,7 +115,23 @@ let
|
|||||||
fi
|
fi
|
||||||
"$0" check
|
"$0" check
|
||||||
echo "Done — press enter."; read -r _ || true ;;
|
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
|
esac
|
||||||
'';
|
'';
|
||||||
in
|
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:
|
# Waybar — two-tier theming:
|
||||||
#
|
#
|
||||||
# 1. Default: structure, fonts, geometry AND palette baked from
|
# 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
|
# 2. Whole-swap: themes with their own visual identity ship
|
||||||
# <themesDir>/<slug>/waybar.css (and optionally waybar.jsonc) which
|
# <themesDir>/<slug>/waybar.css (and optionally waybar.jsonc) which
|
||||||
@@ -11,13 +11,18 @@
|
|||||||
|
|
||||||
let
|
let
|
||||||
t = config.nomarchy.theme;
|
t = config.nomarchy.theme;
|
||||||
|
termSheet = import ./term-sheet.nix { inherit pkgs; };
|
||||||
|
|
||||||
# Show the active-keyboard-layout indicator only when more than one layout
|
# Whether the keyboard-layout indicator is worth bar space, and the filter
|
||||||
# is in play — multiple session layouts (comma-separated) or per-device
|
# that holds a whole-swap to the same answer. Contract + rationale (and
|
||||||
# overrides (nomarchy.keyboard.devices) — so single-layout bars stay clean.
|
# checks.waybar-language) live in ./waybar-language.nix.
|
||||||
showLanguage = lib.hasInfix "," config.nomarchy.keyboard.layout
|
langLib = import ./waybar-language.nix { inherit lib; };
|
||||||
|| config.nomarchy.keyboard.layouts != [ ]
|
showLanguage = langLib.show {
|
||||||
|| config.nomarchy.keyboard.devices != { };
|
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-
|
# Power-profile indicator (power-profiles-daemon). Both scripts self-
|
||||||
# gate: they exit silently unless this is a laptop (a battery is
|
# 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
|
# 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
|
# leave the session bar-less until relogin (seen on hardware: a theme
|
||||||
# switch crashed waybar mid-reload). Respawns on ANY exit — a plain
|
# switch crashed waybar mid-reload). Respawns on ANY exit — a plain
|
||||||
# `pkill -x waybar` is now a clean restart, which nomarchy-theme-sync
|
# `pkill -x 'waybar|\.waybar-wrapped'` (nixpkgs wraps the binary, so
|
||||||
# uses instead of the crash-prone in-place SIGUSR2 reload when it sees
|
# the comm is `.waybar-wrapped`) is now a clean restart, which
|
||||||
# this supervisor running. Crash-loop guard: 5 exits within 10s of
|
# 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
|
# their start → give up with a critical notification instead of
|
||||||
# spinning. Stop the bar for real: pkill -f nomarchy-waybar (TERM is
|
# spinning. Stop the bar for real: pkill -f nomarchy-waybar (TERM is
|
||||||
# trapped to take the child down too).
|
# trapped to take the child down too).
|
||||||
@@ -89,33 +95,34 @@ let
|
|||||||
[ -n "$next" ] && powerprofilesctl set "$next"
|
[ -n "$next" ] && powerprofilesctl set "$next"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Opens calcurse (a lightweight TUI calendar, month view by default) in a
|
# Opens calcurse (lightweight TUI calendar) in a floating, centered kitty
|
||||||
# floating, centered ghostty window — bound to the Waybar clock's on-click.
|
# window — Waybar clock on-click. Distinct --class → hyprland.nix float
|
||||||
# A distinct --class gives the window a matchable app-id for the
|
# rules. Kitty is always installed (kitty.nix). The sheet sizes itself
|
||||||
# float+center+size windowrule (hyprland.nix); --gtk-single-instance=false
|
# (#139: a percentage `size` rule is silently ignored) — see term-sheet.nix.
|
||||||
# forces a fresh standalone window (ghostty defaults single-instance on).
|
|
||||||
# calcurse is installed with the bar (home.packages below); the gate is
|
|
||||||
# only for someone who force-removed it. ghostty is always installed
|
|
||||||
# (ghostty.nix), so it can be relied on for the --class window.
|
|
||||||
calendarLauncher = pkgs.writeShellScriptBin "nomarchy-calendar" ''
|
calendarLauncher = pkgs.writeShellScriptBin "nomarchy-calendar" ''
|
||||||
if ! command -v calcurse >/dev/null 2>&1; then
|
if ! command -v calcurse >/dev/null 2>&1; then
|
||||||
notify-send "Calendar" "calcurse isn't installed (removed from home.packages?)." 2>/dev/null
|
notify-send "Calendar" "calcurse isn't installed (removed from home.packages?)." 2>/dev/null
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
# VPN indicator — shows a shield when a NetworkManager VPN/WireGuard
|
||||||
# connection is active OR Tailscale is up; prints nothing otherwise so the
|
# 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" ''
|
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=""
|
ts=""
|
||||||
if command -v tailscale >/dev/null 2>&1; then
|
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
|
fi
|
||||||
[ -n "$active" ] || [ -n "$ts" ] || exit 0
|
[ -n "$names$ts" ] || exit 0
|
||||||
printf '{"text":"","tooltip":"VPN active (click to manage)","class":"on"}\n'
|
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
|
# 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).
|
# normal tiling. Keep in sync with the whole-swap jsoncs (parity rule).
|
||||||
layer = "bottom";
|
layer = "bottom";
|
||||||
position = "top";
|
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-top = t.ui.gapsOut;
|
||||||
margin-left = t.ui.gapsOut;
|
margin-left = t.ui.gapsOut;
|
||||||
margin-right = t.ui.gapsOut;
|
margin-right = t.ui.gapsOut;
|
||||||
@@ -170,7 +180,7 @@ let
|
|||||||
# — BACKLOG #63). Click targets are existing nomarchy-menu entry points.
|
# — BACKLOG #63). Click targets are existing nomarchy-menu entry points.
|
||||||
modules-left = [ "custom/nomarchy" "hyprland/workspaces" "hyprland/window" ];
|
modules-left = [ "custom/nomarchy" "hyprland/workspaces" "hyprland/window" ];
|
||||||
modules-center = [ "clock" ];
|
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"
|
++ lib.optional showLanguage "hyprland/language"
|
||||||
++ [ "battery" "custom/doctor" "custom/updates" "custom/notification" "custom/powermenu" ];
|
++ [ "battery" "custom/doctor" "custom/updates" "custom/notification" "custom/powermenu" ];
|
||||||
|
|
||||||
@@ -204,7 +214,7 @@ let
|
|||||||
# into clock + clock#date — one click surface for the calendar).
|
# into clock + clock#date — one click surface for the calendar).
|
||||||
format = "{:%H:%M · %a %d %b}";
|
format = "{:%H:%M · %a %d %b}";
|
||||||
# Left-click → the calendar (nomarchy-calendar → calcurse in a floating
|
# Left-click → the calendar (nomarchy-calendar → calcurse in a floating
|
||||||
# ghostty). Tooltip is plain date/zone only — embedding {calendar}
|
# kitty). Tooltip is plain date/zone only — embedding {calendar}
|
||||||
# produced an empty popup on hardware (2026-07-10); month view is
|
# produced an empty popup on hardware (2026-07-10); month view is
|
||||||
# one click away.
|
# one click away.
|
||||||
on-click = "nomarchy-calendar";
|
on-click = "nomarchy-calendar";
|
||||||
@@ -260,6 +270,13 @@ let
|
|||||||
format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span>{capacity}%";
|
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-charging = "<span size='${toString (t.fonts.size + 2)}pt'> </span>{capacity}%";
|
||||||
format-icons = [ " " " " " " " " " " ];
|
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
|
# Click either the battery or the power-profile icon → the combined
|
||||||
# power menu (profile + charge cap). The granular System rows stay.
|
# power menu (profile + charge cap). The granular System rows stay.
|
||||||
on-click = "nomarchy-menu powermgmt";
|
on-click = "nomarchy-menu powermgmt";
|
||||||
@@ -322,6 +339,17 @@ let
|
|||||||
on-click = "nomarchy-nightlight toggle";
|
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
|
# Update awareness. Self-gates: hidden unless nomarchy.updates is enabled
|
||||||
# AND the periodic check found something (the helper prints nothing then).
|
# AND the periodic check found something (the helper prints nothing then).
|
||||||
# signal 9 lets the checker refresh it instantly; click opens the upgrade
|
# signal 9 lets the checker refresh it instantly; click opens the upgrade
|
||||||
@@ -331,7 +359,7 @@ let
|
|||||||
return-type = "json";
|
return-type = "json";
|
||||||
interval = 1800;
|
interval = 1800;
|
||||||
signal = 9;
|
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
|
# swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON
|
||||||
@@ -366,15 +394,32 @@ let
|
|||||||
};
|
};
|
||||||
|
|
||||||
generatedStyle = ''
|
generatedStyle = ''
|
||||||
/* Palette baked from theme-state.json */
|
/* Palette baked from state.json */
|
||||||
${colorDefs}
|
${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-family: "${t.fonts.ui}", "${t.fonts.mono}";
|
||||||
font-size: ${toString t.fonts.size}pt;
|
font-size: ${toString t.fonts.size}pt;
|
||||||
min-height: 0;
|
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 {
|
window#waybar {
|
||||||
background: alpha(@base, 0.85);
|
background: alpha(@base, 0.85);
|
||||||
color: @text;
|
color: @text;
|
||||||
@@ -382,14 +427,20 @@ let
|
|||||||
border-radius: ${toString t.ui.rounding}px;
|
border-radius: ${toString t.ui.rounding}px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dim states use the palette's @muted role: since item 28b it is
|
/* Dim states use the palette's @muted role on DARK themes: since
|
||||||
floor-guaranteed legible on @base in every theme (muted/base >=
|
item 28b it is floor-guaranteed legible on @base (muted/base >=
|
||||||
2.0, gated by tools/check-theme-contrast.py) — the palettes that
|
2.0, gated by tools/check-theme-contrast.py). On LIGHT themes that
|
||||||
once made it vanish (gruvbox muted≈base, item 27) were retuned.
|
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). */
|
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 {
|
#workspaces button {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
color: @muted;
|
margin: 3px 0;
|
||||||
|
color: ${if t.mode == "light" then "@subtext" else "@muted"};
|
||||||
border-radius: ${toString t.ui.rounding}px;
|
border-radius: ${toString t.ui.rounding}px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,14 +468,18 @@ let
|
|||||||
color: @accent;
|
color: @accent;
|
||||||
font-family: Nomarchy;
|
font-family: Nomarchy;
|
||||||
font-size: ${toString (t.fonts.size + 4)}pt;
|
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; }
|
#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);
|
color: alpha(@text, 0.85);
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
#custom-powermenu { padding: 0 16px 0 10px; }
|
||||||
#custom-powermenu:hover { color: @bad; }
|
#custom-powermenu:hover { color: @bad; }
|
||||||
|
|
||||||
/* Group rhythm (item 28c): a wider breath before each functional
|
/* Group rhythm (item 28c): a wider breath before each functional
|
||||||
@@ -445,6 +500,7 @@ let
|
|||||||
|
|
||||||
/* Night-light active → warm tone, matching the filter it represents. */
|
/* Night-light active → warm tone, matching the filter it represents. */
|
||||||
#custom-nightlight.on { color: @warn; }
|
#custom-nightlight.on { color: @warn; }
|
||||||
|
#custom-airplane.on { color: @warn; }
|
||||||
|
|
||||||
/* Updates pending → accent, to draw the eye. */
|
/* Updates pending → accent, to draw the eye. */
|
||||||
#custom-updates.available { color: @accent; }
|
#custom-updates.available { color: @accent; }
|
||||||
@@ -457,7 +513,7 @@ let
|
|||||||
(size+2)pt Pango icon span the icon+text modules use — bump their
|
(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 /
|
font-size to match, or these glyphs read smaller than the volume /
|
||||||
battery / language icons beside them. */
|
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 —
|
/* The speedometer + caffeine glyphs render small in their em box —
|
||||||
size them up a touch more so they read at a glance. */
|
size them up a touch more so they read at a glance. */
|
||||||
@@ -494,7 +550,7 @@ in
|
|||||||
# on a warm relogin — it started before the socket was up, exited, landed
|
# 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
|
# 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
|
# 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.
|
# session target, so we don't depend on its lifecycle.
|
||||||
systemd.enable = false;
|
systemd.enable = false;
|
||||||
|
|
||||||
@@ -505,7 +561,7 @@ in
|
|||||||
# docs/OVERRIDES.md.
|
# docs/OVERRIDES.md.
|
||||||
settings.mainBar = lib.mkDefault (
|
settings.mainBar = lib.mkDefault (
|
||||||
if hasConfigOverride
|
if hasConfigOverride
|
||||||
then builtins.fromJSON (builtins.readFile configOverride)
|
then gateLanguage (builtins.fromJSON (builtins.readFile configOverride))
|
||||||
else generatedSettings
|
else generatedSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# yazi — the flagship file manager: a fast, keyboard-driven TUI that
|
# yazi — the flagship file manager: a fast, keyboard-driven TUI that
|
||||||
# fits the distro's identity (Ghostty's Kitty-graphics previews, rofi
|
# fits the distro's identity (Kitty graphics previews, rofi
|
||||||
# menus, everything from one JSON). Themed from theme-state.json and
|
# menus, everything from one JSON). Themed from state.json and
|
||||||
# shipped with a curated plugin set. The GUI half (Thunar, "open folder"
|
# shipped with a curated plugin set. The GUI half (Thunar, "open folder"
|
||||||
# handler) is nomarchy.system.fileManager on the system side.
|
# 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,
|
# Theme from the palette. yazi merges this over its built-in theme,
|
||||||
# so only the accent-bearing UI is specified; everything else keeps
|
# 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 = {
|
theme = {
|
||||||
mgr = {
|
mgr = {
|
||||||
cwd = { fg = c.accent; };
|
cwd = { fg = c.accent; };
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# nixosModules.nomarchy in flake.nix) and layer your machine specifics
|
# nixosModules.nomarchy in flake.nix) and layer your machine specifics
|
||||||
# (bootloader, hostname, users, hardware) on top. Host concerns are
|
# (bootloader, hostname, users, hardware) on top. Host concerns are
|
||||||
# deliberately NOT set here. Everything user-facing (Hyprland config,
|
# 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, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
@@ -20,9 +20,26 @@ let
|
|||||||
nomarchyLogoFont = pkgs.runCommandLocal "nomarchy-logo-font" { } ''
|
nomarchyLogoFont = pkgs.runCommandLocal "nomarchy-logo-font" { } ''
|
||||||
install -Dm444 ${./branding/Nomarchy.ttf} $out/share/fonts/truetype/Nomarchy.ttf
|
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
|
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 = {
|
config = {
|
||||||
# Distro branding. distroName flows into /etc/os-release PRETTY_NAME,
|
# Distro branding. distroName flows into /etc/os-release PRETTY_NAME,
|
||||||
@@ -38,11 +55,32 @@ in
|
|||||||
# restore them pointing at the project instead.
|
# restore them pointing at the project instead.
|
||||||
system.nixos.distroName = lib.mkDefault "Nomarchy";
|
system.nixos.distroName = lib.mkDefault "Nomarchy";
|
||||||
system.nixos.distroId = 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 {
|
system.nixos.extraOSReleaseArgs = lib.mkDefault {
|
||||||
HOME_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
HOME_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||||||
DOCUMENTATION_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
DOCUMENTATION_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||||||
SUPPORT_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";
|
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
|
# MOTD on TTY/SSH login (the desktop auto-logs into Hyprland, so this
|
||||||
@@ -57,7 +95,7 @@ in
|
|||||||
nomarchy-pull update flake inputs (nomarchy, nixpkgs, …)
|
nomarchy-pull update flake inputs (nomarchy, nixpkgs, …)
|
||||||
nomarchy-rebuild rebuild the system (current lock)
|
nomarchy-rebuild rebuild the system (current lock)
|
||||||
nomarchy-home rebuild the desktop / Home Manager
|
nomarchy-home rebuild the desktop / Home Manager
|
||||||
nomarchy-theme-sync apply <theme> switch the whole palette
|
nomarchy-state-sync apply <theme> switch the whole palette
|
||||||
nomarchy-doctor read-only health check
|
nomarchy-doctor read-only health check
|
||||||
SUPER+? keybindings cheatsheet
|
SUPER+? keybindings cheatsheet
|
||||||
SUPER+M → System › Firmware check LVFS firmware updates (fwupd)
|
SUPER+M → System › Firmware check LVFS firmware updates (fwupd)
|
||||||
@@ -125,6 +163,10 @@ in
|
|||||||
alsa.support32Bit = true;
|
alsa.support32Bit = true;
|
||||||
pulse.enable = true;
|
pulse.enable = true;
|
||||||
wireplumber.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 ─────────────────────────────────────────────
|
# ── Desktop services ─────────────────────────────────────────────
|
||||||
@@ -151,6 +193,27 @@ in
|
|||||||
notifications.x11.enable = lib.mkDefault true;
|
notifications.x11.enable = lib.mkDefault true;
|
||||||
notifications.wall.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.
|
# Core security: enable AppArmor to confine desktop apps and services.
|
||||||
security.apparmor.enable = true;
|
security.apparmor.enable = true;
|
||||||
security.apparmor.killUnconfinedConfinables = false;
|
security.apparmor.killUnconfinedConfinables = false;
|
||||||
@@ -184,6 +247,13 @@ in
|
|||||||
# system unit hooked to the sleep targets: it locks on the RAM-resume
|
# 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
|
# sleeps always, and on hibernate only when the disk is unencrypted (no
|
||||||
# LUKS gate to rely on). Replaces hypridle's old before_sleep_cmd.
|
# 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 =
|
systemd.services.nomarchy-lock-before-sleep =
|
||||||
let
|
let
|
||||||
encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
|
encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
|
||||||
@@ -202,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 —
|
# zsh as the default login shell (the desktop's shell experience —
|
||||||
# starship/bat/eza/zoxide — is configured home-side in shell.nix).
|
# starship/bat/eza/zoxide — is configured home-side in shell.nix).
|
||||||
# programs.zsh.enable wires /etc/zshrc, completion and /etc/shells;
|
# programs.zsh.enable wires /etc/zshrc, completion and /etc/shells;
|
||||||
@@ -213,14 +297,21 @@ in
|
|||||||
# overrides this regardless.
|
# overrides this regardless.
|
||||||
users.defaultUserShell = lib.mkOverride 500 pkgs.zsh;
|
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;
|
hardware.bluetooth.enable = lib.mkDefault cfg.bluetooth.enable;
|
||||||
services.blueman.enable = lib.mkDefault cfg.bluetooth.enable;
|
services.blueman.enable = lib.mkDefault cfg.bluetooth.enable;
|
||||||
|
|
||||||
# ── Foreign binaries: nix-ld ─────────────────────────────────────
|
# ── Foreign binaries: nix-ld ─────────────────────────────────────
|
||||||
# An ld.so shim so dynamically-linked binaries not built for NixOS
|
# An ld.so shim so dynamically-linked binaries not built for NixOS
|
||||||
# (downloaded tools, language servers, pip/npm-installed ELFs, the
|
# (downloaded tools, language servers, pip/npm-installed ELFs) run
|
||||||
# npx-fetched claude-code) run without manual patchelf. On by default —
|
# without manual patchelf. On by default — a pragmatic-desktop
|
||||||
# a pragmatic-desktop expectation.
|
# expectation.
|
||||||
programs.nix-ld.enable = lib.mkDefault true;
|
programs.nix-ld.enable = lib.mkDefault true;
|
||||||
|
|
||||||
# ── Firmware ─────────────────────────────────────────────────────
|
# ── Firmware ─────────────────────────────────────────────────────
|
||||||
@@ -291,7 +382,7 @@ in
|
|||||||
# ── Fonts ────────────────────────────────────────────────────────
|
# ── Fonts ────────────────────────────────────────────────────────
|
||||||
# The ten most popular Nerd Fonts ship by default, so any of them
|
# 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
|
# 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 = {
|
fonts = {
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
nerd-fonts.jetbrains-mono
|
nerd-fonts.jetbrains-mono
|
||||||
@@ -328,9 +419,8 @@ in
|
|||||||
|
|
||||||
# ── Essential packages ───────────────────────────────────────────
|
# ── Essential packages ───────────────────────────────────────────
|
||||||
environment.systemPackages = with pkgs; [
|
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-doctor # read-only health check (System › Doctor)
|
||||||
nomarchy-control-center # TUI control center
|
|
||||||
nomarchy-detect-hw # post-install hardware re-probe (HARDWARE.md §8)
|
nomarchy-detect-hw # post-install hardware re-probe (HARDWARE.md §8)
|
||||||
|
|
||||||
# Day-to-day lifecycle (README §3): nomarchy-pull / -rebuild / -home
|
# Day-to-day lifecycle (README §3): nomarchy-pull / -rebuild / -home
|
||||||
@@ -446,22 +536,30 @@ in
|
|||||||
# users without root. Harmless baseline even if the OSD is disabled.
|
# users without root. Harmless baseline even if the OSD is disabled.
|
||||||
services.udev.packages = [ pkgs.swayosd ];
|
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 itself ───────────────────────────────────────────────────
|
||||||
nix = {
|
nix = {
|
||||||
settings = {
|
settings = {
|
||||||
experimental-features = [ "nix-command" "flakes" ];
|
experimental-features = [ "nix-command" "flakes" ];
|
||||||
auto-optimise-store = lib.mkDefault true;
|
auto-optimise-store = lib.mkDefault true;
|
||||||
# The downstream flake (~/.nomarchy) is meant to be a live working
|
# 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
|
# switch (and you needn't commit each tweak), so the "Git tree is
|
||||||
# dirty" warning fires on every rebuild and is pure noise here.
|
# dirty" warning fires on every rebuild and is pure noise here.
|
||||||
warn-dirty = lib.mkDefault false;
|
warn-dirty = lib.mkDefault false;
|
||||||
};
|
};
|
||||||
gc = {
|
# Generation age+floor policy + store GC: modules/nixos/gen-prune.nix (#128).
|
||||||
automatic = lib.mkDefault true;
|
|
||||||
dates = lib.mkDefault "weekly";
|
|
||||||
options = lib.mkDefault "--delete-older-than 14d";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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:
|
# drives the desktop (nomarchy.system.stateFile; the Plymouth model:
|
||||||
# baked at SYSTEM rebuild, so it follows the theme as of the last
|
# baked at SYSTEM rebuild, so it follows the theme as of the last
|
||||||
# sys-update, not the last instant apply).
|
# sys-update, not the last instant apply).
|
||||||
@@ -14,16 +14,70 @@
|
|||||||
# theme's colors). ANSI "black" stays dark even in light themes —
|
# theme's colors). ANSI "black" stays dark even in light themes —
|
||||||
# the greeter reads terminal-dark there, the same convention every
|
# the greeter reads terminal-dark there, the same convention every
|
||||||
# terminal applies to ANSI colors.
|
# 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, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.nomarchy.system;
|
cfg = config.nomarchy.system;
|
||||||
distroName = config.system.nixos.distroName;
|
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 =
|
state =
|
||||||
if cfg.stateFile != null
|
if cfg.stateFile != null
|
||||||
then builtins.fromJSON (builtins.readFile cfg.stateFile)
|
then import ../state-read.nix { inherit lib; } cfg.stateFile
|
||||||
else { };
|
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
|
# A sparse/hand-rolled state without a proper ansi block just skips the
|
||||||
# theming (stock tuigreet grey) — never an eval error.
|
# theming (stock tuigreet grey) — never an eval error.
|
||||||
ansi = state.ansi or [ ];
|
ansi = state.ansi or [ ];
|
||||||
@@ -44,6 +98,15 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = {
|
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
|
# VT palette from the theme (RRGGBB, no #; lands as vt.default_* kernel
|
||||||
# params). mkDefault so a downstream console.colors wins.
|
# params). mkDefault so a downstream console.colors wins.
|
||||||
console.colors = lib.mkIf themed (lib.mkDefault (map (lib.removePrefix "#") ansi));
|
console.colors = lib.mkIf themed (lib.mkDefault (map (lib.removePrefix "#") ansi));
|
||||||
|
|||||||
@@ -15,14 +15,66 @@
|
|||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.nomarchy.hardware;
|
cfg = config.nomarchy.hardware;
|
||||||
# Fingerprint PAM can follow theme-state.json (menu toggle → next
|
# Fingerprint PAM can follow state.json (menu toggle → next
|
||||||
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55). Missing or
|
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55). Missing or
|
||||||
# invalid JSON fails closed (theme-state-read.nix) instead of a raw stack.
|
# invalid JSON fails closed (state-read.nix) instead of a raw stack.
|
||||||
hwState =
|
hwState =
|
||||||
if config.nomarchy.system.stateFile != null
|
if config.nomarchy.system.stateFile != null
|
||||||
then import ../theme-state-read.nix { inherit lib; } config.nomarchy.system.stateFile
|
then import ../state-read.nix { inherit lib; } config.nomarchy.system.stateFile
|
||||||
else { };
|
else { };
|
||||||
pamFromState = (hwState.settings or { }).fingerprint.pam or false;
|
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
|
in
|
||||||
{
|
{
|
||||||
options.nomarchy.hardware = {
|
options.nomarchy.hardware = {
|
||||||
@@ -104,14 +156,28 @@ in
|
|||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = pamFromState;
|
default = pamFromState;
|
||||||
defaultText = lib.literalExpression
|
defaultText = lib.literalExpression
|
||||||
"(settings.fingerprint.pam from theme-state.json) or false";
|
"(settings.fingerprint.pam from state.json) or false";
|
||||||
description = ''
|
description = ''
|
||||||
Use the fingerprint for login and sudo (PAM). Opt-in — password-only
|
Use the fingerprint for login and sudo (PAM). Opt-in — password-only
|
||||||
stays the default for the cautious; enroll a finger first. Defaults
|
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.
|
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 ''
|
npu.enable = lib.mkEnableOption ''
|
||||||
@@ -193,12 +259,35 @@ in
|
|||||||
})
|
})
|
||||||
|
|
||||||
# ── Fingerprint ────────────────────────────────────────────────────
|
# ── 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 {
|
(lib.mkIf cfg.fingerprint.enable {
|
||||||
services.fprintd.enable = true;
|
services.fprintd.enable = true;
|
||||||
})
|
# Ships whenever a reader exists, regardless of the pam flag: the
|
||||||
(lib.mkIf (cfg.fingerprint.enable && cfg.fingerprint.pam) {
|
# toggle's whole job is to turn the flag back on while it's off.
|
||||||
security.pam.services.login.fprintAuth = true;
|
environment.systemPackages = [ nomarchy-fingerprint ];
|
||||||
security.pam.services.sudo.fprintAuth = true;
|
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) ──────
|
# ── Newest kernel for very-new hardware (opt-in escape hatch) ──────
|
||||||
|
|||||||
@@ -11,27 +11,34 @@
|
|||||||
|
|
||||||
greeter.autoLogin = lib.mkOption {
|
greeter.autoLogin = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.str;
|
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";
|
example = "ada";
|
||||||
description = ''
|
description = ''
|
||||||
Log this user straight into Hyprland on boot (greetd
|
Log this user straight into Hyprland on boot (greetd
|
||||||
initial_session); logging out lands on the normal greeter.
|
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 ''
|
plymouth.enable = lib.mkEnableOption ''
|
||||||
the Nomarchy Plymouth boot splash (logo + progress + LUKS prompt),
|
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; };
|
Recolors on system rebuilds — theme switches don't touch the initrd'' // { default = true; };
|
||||||
|
|
||||||
stateFile = lib.mkOption {
|
stateFile = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.path;
|
type = lib.types.nullOr lib.types.path;
|
||||||
default = null;
|
default = null;
|
||||||
example = lib.literalExpression "./theme-state.json";
|
example = lib.literalExpression "./state.json";
|
||||||
description = ''
|
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
|
Plymouth splash background). lib.mkFlake wires it automatically
|
||||||
from your flake; null falls back to the Boreal base color.
|
from your flake; null falls back to the Boreal base color.
|
||||||
'';
|
'';
|
||||||
@@ -44,7 +51,16 @@
|
|||||||
option'' // { default = true; };
|
option'' // { default = true; };
|
||||||
|
|
||||||
audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { 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 ''
|
autoTimezone.enable = lib.mkEnableOption ''
|
||||||
automatic timezone detection (geoclue + automatic-timezoned): the
|
automatic timezone detection (geoclue + automatic-timezoned): the
|
||||||
@@ -102,7 +118,14 @@
|
|||||||
|
|
||||||
batteryChargeLimit = lib.mkOption {
|
batteryChargeLimit = lib.mkOption {
|
||||||
type = lib.types.nullOr (lib.types.ints.between 50 100);
|
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
|
# Dell Adaptive charge mode ignores the end threshold unless we
|
||||||
# also select Custom (power.nix oneshot); see Latitude 5310 QA.
|
# also select Custom (power.nix oneshot); see Latitude 5310 QA.
|
||||||
example = 80;
|
example = 80;
|
||||||
@@ -120,6 +143,26 @@
|
|||||||
Needs nomarchy.system.power.laptop.
|
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
|
# 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
|
# drives the desktop (nomarchy.system.stateFile, wired automatically by
|
||||||
# lib.mkFlake). One caveat by design: theme switches are Home
|
# lib.mkFlake). One caveat by design: theme switches are Home
|
||||||
# Manager-only and never touch the initrd, so the splash follows the
|
# Manager-only and never touch the initrd, so the splash follows the
|
||||||
# theme as of the last SYSTEM rebuild (`sys-update`), not the last
|
# theme as of the last SYSTEM rebuild (`sys-update`), not the last
|
||||||
# `nomarchy-theme-sync apply`.
|
# `nomarchy-state-sync apply`.
|
||||||
{ config, lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
@@ -12,7 +12,7 @@ let
|
|||||||
|
|
||||||
state =
|
state =
|
||||||
if cfg.stateFile != null
|
if cfg.stateFile != null
|
||||||
then import ../theme-state-read.nix { inherit lib; } cfg.stateFile
|
then import ../state-read.nix { inherit lib; } cfg.stateFile
|
||||||
else { };
|
else { };
|
||||||
colorOf = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
|
colorOf = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
|
||||||
# Fallbacks match the distro default theme (Boreal) when stateFile is null.
|
# Fallbacks match the distro default theme (Boreal) when stateFile is null.
|
||||||
@@ -24,8 +24,20 @@ let
|
|||||||
text = colorOf "text" "#D3DAE0";
|
text = colorOf "text" "#D3DAE0";
|
||||||
surface = colorOf "surface" "#303A46";
|
surface = colorOf "surface" "#303A46";
|
||||||
accent = colorOf "accent" "#B79BE8";
|
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";
|
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
|
# Plymouth's Window.SetBackgroundTopColor takes three floats in
|
||||||
# 0.0–1.0; the .plymouth metadata's ConsoleLogBackgroundColor takes a
|
# 0.0–1.0; the .plymouth metadata's ConsoleLogBackgroundColor takes a
|
||||||
# 0xRRGGBB hex. Nix has no float math: multiply, integer-divide, pad.
|
# 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 if lib.stringLength s == 2 then "0${s}"
|
||||||
else s;
|
else s;
|
||||||
in "0.${padded}";
|
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 {
|
nomarchy-plymouth = pkgs.stdenv.mkDerivation {
|
||||||
pname = "nomarchy-plymouth";
|
pname = "nomarchy-plymouth";
|
||||||
@@ -53,6 +67,17 @@ let
|
|||||||
mkdir -p "$themedir"
|
mkdir -p "$themedir"
|
||||||
cp * "$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
|
# Recolor the splash art from the palette (flat fill, alpha kept) so
|
||||||
# it reads on any base instead of the shipped fixed navy.
|
# it reads on any base instead of the shipped fixed navy.
|
||||||
recolor() { ${magick} "$themedir/$1" -fill "#$2" -colorize 100 "$themedir/$1"; }
|
recolor() { ${magick} "$themedir/$1" -fill "#$2" -colorize 100 "$themedir/$1"; }
|
||||||
@@ -62,6 +87,7 @@ let
|
|||||||
recolor entry.png ${surface} # password field box
|
recolor entry.png ${surface} # password field box
|
||||||
recolor progress_box.png ${surface} # progress track
|
recolor progress_box.png ${surface} # progress track
|
||||||
recolor progress_bar.png ${accent} # progress fill
|
recolor progress_bar.png ${accent} # progress fill
|
||||||
|
recolor keyboard.png ${subtext} # passphrase keyboard-layout hint
|
||||||
|
|
||||||
# Point the .plymouth metadata into the store
|
# Point the .plymouth metadata into the store
|
||||||
sed -i "s|/usr/share/plymouth/themes/nomarchy|$themedir|g" \
|
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_R@|${channel 0}|g' \
|
||||||
-e 's|@BG_G@|${channel 2}|g' \
|
-e 's|@BG_G@|${channel 2}|g' \
|
||||||
-e 's|@BG_B@|${channel 4}|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"
|
"$themedir/nomarchy.script"
|
||||||
sed -i 's|@BG_HEX@|${base}|g' \
|
sed -i 's|@BG_HEX@|${base}|g' \
|
||||||
"$themedir/nomarchy.plymouth"
|
"$themedir/nomarchy.plymouth"
|
||||||
|
|||||||
@@ -3,18 +3,125 @@
|
|||||||
Window.SetBackgroundTopColor(@BG_R@, @BG_G@, @BG_B@);
|
Window.SetBackgroundTopColor(@BG_R@, @BG_G@, @BG_B@);
|
||||||
Window.SetBackgroundBottomColor(@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.sprite = Sprite();
|
||||||
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.SetOpacity (1);
|
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
|
# Use these to adjust the progress bar timing
|
||||||
global.fake_progress_limit = 0.7; # Target percentage for fake progress (0.0 to 1.0)
|
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 ()
|
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++;
|
global.animation_frame++;
|
||||||
|
|
||||||
# Animate fake progress to limit over time with easing
|
# Animate fake progress to limit over time with easing
|
||||||
@@ -91,12 +202,18 @@ fun show_password_dialog()
|
|||||||
{
|
{
|
||||||
lock.sprite.SetOpacity(1);
|
lock.sprite.SetOpacity(1);
|
||||||
entry.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()
|
fun hide_password_dialog()
|
||||||
{
|
{
|
||||||
lock.sprite.SetOpacity(0);
|
lock.sprite.SetOpacity(0);
|
||||||
entry.sprite.SetOpacity(0);
|
entry.sprite.SetOpacity(0);
|
||||||
|
kbd.icon_sprite.SetOpacity(0);
|
||||||
|
kbd.text_sprite.SetOpacity(0);
|
||||||
for (index = 0; bullet.sprites[index]; index++)
|
for (index = 0; bullet.sprites[index]; index++)
|
||||||
bullet.sprites[index].SetOpacity(0);
|
bullet.sprites[index].SetOpacity(0);
|
||||||
}
|
}
|
||||||
@@ -121,30 +238,7 @@ fun stop_fake_progress()
|
|||||||
|
|
||||||
#----------------------------------------- Dialogue --------------------------------
|
#----------------------------------------- Dialogue --------------------------------
|
||||||
|
|
||||||
lock.image = Image("lock.png");
|
# Images and sprites are created at the top; every position lives in layout().
|
||||||
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
|
|
||||||
bullet.sprites = [];
|
bullet.sprites = [];
|
||||||
|
|
||||||
fun display_normal_callback ()
|
fun display_normal_callback ()
|
||||||
@@ -206,21 +300,18 @@ Plymouth.SetDisplayPasswordFunction(display_password_callback);
|
|||||||
|
|
||||||
progress_box.image = Image("progress_box.png");
|
progress_box.image = Image("progress_box.png");
|
||||||
progress_box.sprite = Sprite(progress_box.image);
|
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_box.sprite.SetOpacity(0);
|
||||||
|
|
||||||
progress_bar.original_image = Image("progress_bar.png");
|
progress_bar.original_image = Image("progress_bar.png");
|
||||||
progress_bar.sprite = Sprite();
|
progress_bar.sprite = Sprite();
|
||||||
progress_bar.image = progress_bar.original_image.Scale(1, progress_bar.original_image.GetHeight());
|
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);
|
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)
|
fun progress_callback (duration, progress)
|
||||||
{
|
{
|
||||||
global.real_progress = progress;
|
global.real_progress = progress;
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
# Active power management. One concern, one file: this is the system
|
# Active power management. One concern, one file: this is the system
|
||||||
# side — the power daemon (power-profiles-daemon by default, or TLP),
|
# side — the power daemon (power-profiles-daemon by default, or TLP),
|
||||||
# thermald, and the battery charge limit. The profile *switcher* and the
|
# thermald, the battery charge limit, and suspend-then-hibernate (#115).
|
||||||
# Waybar *indicator* live home-side (rofi.nix / waybar.nix); they self-
|
# The profile *switcher* and the Waybar *indicator* live home-side
|
||||||
# gate on powerprofilesctl being present, so there's no system→home wiring
|
# (rofi.nix / waybar.nix); they self-gate on powerprofilesctl being
|
||||||
# to keep in sync (the same way the Waybar battery widget auto-hides on
|
# present, so there's no system→home wiring to keep in sync (the same
|
||||||
# desktops). See the roadmap in README.md.
|
# way the Waybar battery widget auto-hides on desktops). See the
|
||||||
|
# roadmap in README.md.
|
||||||
{ config, lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.nomarchy.system.power;
|
cfg = config.nomarchy.system.power;
|
||||||
|
sysCfg = config.nomarchy.system;
|
||||||
ppd = cfg.backend == "ppd";
|
ppd = cfg.backend == "ppd";
|
||||||
tlp = cfg.backend == "tlp";
|
tlp = cfg.backend == "tlp";
|
||||||
|
|
||||||
|
# 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
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
@@ -31,6 +48,38 @@ in
|
|||||||
services.power-profiles-daemon.enable = lib.mkDefault ppd;
|
services.power-profiles-daemon.enable = lib.mkDefault ppd;
|
||||||
services.tlp.enable = lib.mkDefault tlp;
|
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
|
# thermald is Intel-only, so off unless asked (the installer enables
|
||||||
# it on a GenuineIntel CPU). Sits happily next to either backend.
|
# it on a GenuineIntel CPU). Sits happily next to either backend.
|
||||||
services.thermald.enable = lib.mkDefault cfg.thermal.enable;
|
services.thermald.enable = lib.mkDefault cfg.thermal.enable;
|
||||||
@@ -44,22 +93,29 @@ in
|
|||||||
# Instant menu path (user decision 2026-07-10): the threshold node is
|
# Instant menu path (user decision 2026-07-10): the threshold node is
|
||||||
# group-writable for `users` so nomarchy-menu can echo live without
|
# group-writable for `users` so nomarchy-menu can echo live without
|
||||||
# rebuild; this oneshot still owns boot + AC-replug re-apply. Prefer
|
# rebuild; this oneshot still owns boot + AC-replug re-apply. Prefer
|
||||||
# live theme-state.json under /home/*/.nomarchy so a menu change
|
# live state.json under /home/*/.nomarchy so a menu change
|
||||||
# survives reboot before the next sys-rebuild bakes the Nix option.
|
# survives reboot before the next sys-rebuild bakes the Nix option.
|
||||||
systemd.services.nomarchy-battery-charge-limit = lib.mkIf cfg.laptop {
|
systemd.services.nomarchy-battery-charge-limit = lib.mkIf cfg.laptop {
|
||||||
description = "Apply battery charge end threshold from state or config";
|
description = "Apply battery charge end threshold from state or config";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
path = [ pkgs.jq pkgs.coreutils ];
|
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 = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
};
|
||||||
script = ''
|
script = ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Baked generation default (empty = full charge / no cap).
|
# Baked generation default (empty = full charge / no cap).
|
||||||
limit=${if cfg.batteryChargeLimit != null then toString cfg.batteryChargeLimit else ""}
|
limit=${if cfg.batteryChargeLimit != null then toString cfg.batteryChargeLimit else ""}
|
||||||
# Prefer the newest live state write (menu path, no rebuild yet).
|
# Prefer the newest live state write (menu path, no rebuild yet).
|
||||||
for st in /home/*/.nomarchy/theme-state.json; do
|
for st in /home/*/.nomarchy/state.json; do
|
||||||
[ -r "$st" ] || continue
|
[ -r "$st" ] || continue
|
||||||
v=$(jq -r '.settings.power.batteryChargeLimit // empty' "$st" 2>/dev/null || true)
|
v=$(jq -r '.settings.power.batteryChargeLimit // empty' "$st" 2>/dev/null || true)
|
||||||
case "$v" in
|
case "$v" in
|
||||||
@@ -139,10 +195,14 @@ in
|
|||||||
|
|
||||||
# Writable threshold for the logged-in user (menu live apply) + AC
|
# 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.
|
# re-apply of the oneshot. Laptop only; no-op when the attr is absent.
|
||||||
# Immediate + delayed restart: Dell/Latitude firmware often stomps the
|
# Immediate + delayed start: Dell/Latitude firmware often stomps the
|
||||||
# threshold to 100 right after unplug; a single immediate oneshot
|
# threshold to 100 right after unplug; a single immediate oneshot
|
||||||
# loses the race (hardware: unplug→100, plug→80). systemd-run hands
|
# loses the race (hardware: unplug→100, plug→80). systemd-run hands
|
||||||
# the delayed restart off so udev's short RUN budget is not held.
|
# 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 (
|
services.udev.extraRules = lib.mkIf cfg.laptop (
|
||||||
let
|
let
|
||||||
systemctl = "${config.systemd.package}/bin/systemctl";
|
systemctl = "${config.systemd.package}/bin/systemctl";
|
||||||
@@ -152,8 +212,8 @@ in
|
|||||||
SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_types", 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_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}=="Battery", TEST=="charge_control_start_threshold", GROUP="users", MODE="0664"
|
||||||
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemctl} --no-block restart nomarchy-battery-charge-limit.service"
|
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 restart 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"
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,19 @@
|
|||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.nomarchy.services;
|
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
|
in
|
||||||
{
|
{
|
||||||
options.nomarchy.services = {
|
options.nomarchy.services = {
|
||||||
@@ -74,10 +87,18 @@ in
|
|||||||
with the lmstudio/alpaca GUIs). CPU by default — set
|
with the lmstudio/alpaca GUIs). CPU by default — set
|
||||||
`services.ollama.acceleration` natively for GPU offload'';
|
`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 ''
|
printing.enable = lib.mkEnableOption ''
|
||||||
CUPS printing with Avahi/mDNS, so network printers are auto-discovered
|
CUPS printing with Avahi/mDNS, so network printers are auto-discovered
|
||||||
(add vendor drivers via `services.printing.drivers`); the menu's
|
(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 ''
|
openrgb.enable = lib.mkEnableOption ''
|
||||||
the OpenRGB daemon and GUI for controlling RGB lighting on peripherals
|
the OpenRGB daemon and GUI for controlling RGB lighting on peripherals
|
||||||
@@ -124,6 +145,14 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkMerge [
|
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 {
|
(lib.mkIf cfg.tailscale.enable {
|
||||||
services.tailscale.enable = true;
|
services.tailscale.enable = true;
|
||||||
# Let the login user drive tailscale (up/down/set — and so the VPN menu's
|
# Let the login user drive tailscale (up/down/set — and so the VPN menu's
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# /etc/localtime at runtime.
|
# /etc/localtime at runtime.
|
||||||
#
|
#
|
||||||
# In-flake state, menu-driven (the keyboard/night-light philosophy): the on/off
|
# 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
|
# (git-tracked, reproducible), written by the System-menu toggle
|
||||||
# (nomarchy-autotimezone). Because this is a SYSTEM service — not a user unit it
|
# (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
|
# can start/stop instantly like night-light — the toggle drives a system rebuild
|
||||||
@@ -16,14 +16,14 @@ let
|
|||||||
|
|
||||||
# Read the same state file the rest of the system side uses (Plymouth too),
|
# Read the same state file the rest of the system side uses (Plymouth too),
|
||||||
# wired by lib.mkFlake. The flag defaults off when stateFile is null;
|
# wired by lib.mkFlake. The flag defaults off when stateFile is null;
|
||||||
# a set-but-missing/invalid path fails closed via theme-state-read.nix.
|
# a set-but-missing/invalid path fails closed via state-read.nix.
|
||||||
state =
|
state =
|
||||||
if cfg.stateFile != null
|
if cfg.stateFile != null
|
||||||
then import ../theme-state-read.nix { inherit lib; } cfg.stateFile
|
then import ../state-read.nix { inherit lib; } cfg.stateFile
|
||||||
else { };
|
else { };
|
||||||
stateEnabled = (state.settings or { }).autoTimezone or false;
|
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 +
|
# 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
|
# writes the state); sudos only the system switch, like sys-update. Writes
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Pure theme-state.json loader — fail closed with a short, actionable
|
# Pure state.json loader — fail closed with a short, actionable
|
||||||
# message instead of a raw `readFile` / `fromJSON` stack buried in a
|
# message instead of a raw `readFile` / `fromJSON` stack buried in a
|
||||||
# consumer. Used by modules/home/theme.nix (required path), the NixOS
|
# consumer. Used by modules/home/theme.nix (required path), the NixOS
|
||||||
# stateFile consumers, and lib.mkFlake (early gate).
|
# stateFile consumers, and lib.mkFlake (early gate).
|
||||||
@@ -6,43 +6,53 @@
|
|||||||
# Field-level schema checks stay in theme.nix (post-defaults). This file
|
# Field-level schema checks stay in theme.nix (post-defaults). This file
|
||||||
# only gates *existence* and *JSON-shape* so the first failure the user
|
# only gates *existence* and *JSON-shape* so the first failure the user
|
||||||
# sees points at the state file, not at nightlight.nix.
|
# 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 }:
|
{ lib }:
|
||||||
|
|
||||||
path:
|
path:
|
||||||
|
|
||||||
let
|
let
|
||||||
pathStr = toString path;
|
pathStr = toString path;
|
||||||
|
baseName = baseNameOf pathStr;
|
||||||
|
|
||||||
tip = ''
|
tip = ''
|
||||||
Fix:
|
Fix:
|
||||||
• Missing file → copy the template next to your flake.nix:
|
• Missing file → copy the template next to your flake.nix:
|
||||||
templates/downstream/theme-state.json
|
templates/downstream/state.json
|
||||||
or regenerate from a preset:
|
or regenerate from a preset:
|
||||||
nomarchy-theme-sync apply boreal
|
nomarchy-state-sync apply boreal
|
||||||
• Bad syntax / wrong shape → edit theme-state.json (trailing commas
|
• Bad syntax / wrong shape → edit ${baseName} (trailing commas
|
||||||
are the usual culprit) or reset with `apply` as above.
|
are the usual culprit) or reset with `apply` as above.
|
||||||
• Field-level schema (colors, ui, border, …):
|
• Field-level schema (colors, ui, border, …):
|
||||||
nomarchy-theme-sync validate
|
nomarchy-state-sync validate
|
||||||
Eval-time field checks live in modules/home/theme.nix.'';
|
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 = ''
|
missingMsg = ''
|
||||||
|
|
||||||
Nomarchy: theme-state.json is missing:
|
Nomarchy: state file is missing:
|
||||||
${pathStr}
|
${pathStr}
|
||||||
|
|
||||||
This file is required (appearance + menu settings). Add it to your
|
This file is required (appearance + menu settings). Add state.json to
|
||||||
flake checkout so evaluation stays pure.
|
your flake checkout — git-tracked — so evaluation stays pure.
|
||||||
${tip}'';
|
${tip}'';
|
||||||
|
|
||||||
emptyMsg = ''
|
emptyMsg = ''
|
||||||
|
|
||||||
Nomarchy: theme-state.json is empty:
|
Nomarchy: state file is empty:
|
||||||
${pathStr}
|
${pathStr}
|
||||||
${tip}'';
|
${tip}'';
|
||||||
|
|
||||||
notObjectMsg = ''
|
notObjectMsg = ''
|
||||||
|
|
||||||
Nomarchy: theme-state.json must be a JSON object `{ ... }`:
|
Nomarchy: state file must be a JSON object `{ ... }`:
|
||||||
${pathStr}
|
${pathStr}
|
||||||
${tip}'';
|
${tip}'';
|
||||||
|
|
||||||
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,249 +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
|
|
||||||
|
|
||||||
# Post-install hardware/day-2 tips (#43 Firmware; #73 fingerprint +
|
|
||||||
# doctor). Self-gated on the same CLI presence the System menu uses;
|
|
||||||
# first-boot is once, so no dismiss state file is needed.
|
|
||||||
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
|
|
||||||
if command -v fprintd-list >/dev/null 2>&1; then
|
|
||||||
gum style --foreground 245 \
|
|
||||||
"Tip: SUPER+M → System › Fingerprint to enroll a finger (or fprintd-enroll)."
|
|
||||||
fi
|
|
||||||
if command -v nomarchy-doctor >/dev/null 2>&1; then
|
|
||||||
gum style --foreground 245 \
|
|
||||||
"Tip: run nomarchy-doctor (or SUPER+M → System › Doctor) for a read-only health check."
|
|
||||||
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)))
|
avail=$(numfmt --to=iec $((availkb * 1024)))
|
||||||
if [ "$use" -ge 90 ]; then
|
if [ "$use" -ge 90 ]; then
|
||||||
bad "$mp is ${use}% full (${avail} free)" \
|
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
|
else
|
||||||
ok "$mp has space (${use}% used, ${avail} free)"
|
ok "$mp has space (${use}% used, ${avail} free)"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# ── the flake + state file ───────────────────────────────────────────
|
# ── the flake + state file ───────────────────────────────────────────
|
||||||
if [ -f "$flake/theme-state.json" ]; then
|
# #107: prefer state.json; theme-state.json still counts until migrated.
|
||||||
if jq empty "$flake/theme-state.json" 2>/dev/null; then
|
state_file=
|
||||||
ok "theme-state.json parses"
|
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
|
else
|
||||||
bad "theme-state.json is not valid JSON (rebuilds will fail)" \
|
bad "$state_base is not valid JSON (rebuilds will fail)" \
|
||||||
"nomarchy-theme-sync validate (names the spot; fix it, or re-apply a theme)"
|
"nomarchy-state-sync validate (names the spot; fix it, or re-apply a theme)"
|
||||||
fi
|
fi
|
||||||
if git -C "$flake" ls-files --error-unmatch theme-state.json >/dev/null 2>&1; then
|
if git -C "$flake" ls-files --error-unmatch "$state_base" >/dev/null 2>&1; then
|
||||||
ok "theme-state.json is git-tracked"
|
ok "$state_base is git-tracked"
|
||||||
else
|
else
|
||||||
bad "theme-state.json is NOT git-tracked (flake evaluation can't see it)" \
|
bad "$state_base is NOT git-tracked (flake evaluation can't see it)" \
|
||||||
"git -C $flake add theme-state.json"
|
"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
|
fi
|
||||||
else
|
else
|
||||||
skip "theme-state.json (no flake checkout at $flake)"
|
skip "state.json (no flake checkout at $flake)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -d "$flake/.git" ]; then
|
if [ -d "$flake/.git" ]; then
|
||||||
@@ -98,6 +110,19 @@ if [ -d "$flake/.git" ]; then
|
|||||||
fi
|
fi
|
||||||
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 ───────────────────────────────────────────────────
|
# ── generation age ───────────────────────────────────────────────────
|
||||||
# The profile SYMLINK's own mtime is the generation's creation time
|
# The profile SYMLINK's own mtime is the generation's creation time
|
||||||
# (store paths themselves are all epoch-1).
|
# (store paths themselves are all epoch-1).
|
||||||
@@ -115,6 +140,21 @@ else
|
|||||||
skip "system generation age (no system profile)"
|
skip "system generation age (no system profile)"
|
||||||
fi
|
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) ──────────────────────────────────
|
# ── snapper timeline (when enabled) ──────────────────────────────────
|
||||||
if [ -n "$(systemctl list-unit-files snapper-timeline.timer --no-legend --plain 2>/dev/null)" ]; then
|
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
|
if systemctl is-active --quiet snapper-timeline.timer; then
|
||||||
@@ -127,6 +167,33 @@ else
|
|||||||
skip "snapper (not enabled on this machine)"
|
skip "snapper (not enabled on this machine)"
|
||||||
fi
|
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 ═════════════════════════════════════════════════════════
|
# ══ hardware ═════════════════════════════════════════════════════════
|
||||||
# Every check below self-gates: it skips cleanly when the tool, service,
|
# 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
|
# or device isn't present, so the section shrinks to fit the machine and
|
||||||
@@ -263,6 +330,61 @@ if [ "$bat_seen" -eq 1 ] && [ "$bat_limited" -eq 0 ]; then
|
|||||||
skip "battery charge limit (none set — charges to 100%)"
|
skip "battery charge limit (none set — charges to 100%)"
|
||||||
fi
|
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) ──────────────────────────────────
|
# ── hibernate / sleep (BACKLOG #76) ──────────────────────────────────
|
||||||
# Read-only: is there a working hibernate path? zram is RAM-only and can't
|
# 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)
|
# hold a resume image, so hibernation needs a disk swap (partition or file)
|
||||||
|
|||||||
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
|
, pciutils # lspci
|
||||||
, usbutils # lsusb (fingerprint-reader VID probe; sysfs is the fallback)
|
, usbutils # lsusb (fingerprint-reader VID probe; sysfs is the fallback)
|
||||||
, btrfs-progs # inspect-internal map-swapfile (hibernation offset)
|
, 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.
|
# 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)
|
, nomarchyLock # the distro's flake.lock (for offline lock composition)
|
||||||
, hardwareModuleNames # newline-separated nixos-hardware module names
|
, hardwareModuleNames # newline-separated nixos-hardware module names
|
||||||
, nixpkgsPath # pinned nixpkgs source (NIX_PATH for disko's eval)
|
, nixpkgsPath # pinned nixpkgs source (NIX_PATH for disko's eval)
|
||||||
@@ -53,7 +54,7 @@ stdenvNoCC.mkDerivation {
|
|||||||
mkdir -p "$share/template"
|
mkdir -p "$share/template"
|
||||||
# Full downstream template is the SoT; install script copies + patches.
|
# Full downstream template is the SoT; install script copies + patches.
|
||||||
cp ${templateDir}/flake.nix ${templateDir}/system.nix \
|
cp ${templateDir}/flake.nix ${templateDir}/system.nix \
|
||||||
${templateDir}/home.nix ${templateDir}/theme-state.json \
|
${templateDir}/home.nix ${templateDir}/state.json \
|
||||||
"$share/template/"
|
"$share/template/"
|
||||||
install -Dm644 patch-template.py "$share/patch-template.py"
|
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
|
util-linux gptfdisk parted cryptsetup lvm2 pciutils usbutils btrfs-progs
|
||||||
]} \
|
]} \
|
||||||
--set NOMARCHY_INSTALL_SHARE "$share" \
|
--set NOMARCHY_INSTALL_SHARE "$share" \
|
||||||
|
--set NOMARCHY_XKB_RULES ${xkeyboard_config}/share/X11/xkb/rules/base.lst \
|
||||||
--set NOMARCHY_NIXPKGS ${nixpkgsPath} \
|
--set NOMARCHY_NIXPKGS ${nixpkgsPath} \
|
||||||
--set NOMARCHY_FLAKE_URL ${lib.escapeShellArg flakeUrl} \
|
--set NOMARCHY_FLAKE_URL ${lib.escapeShellArg flakeUrl} \
|
||||||
--set NOMARCHY_REV ${lib.escapeShellArg (toString rev)} \
|
--set NOMARCHY_REV ${lib.escapeShellArg (toString rev)} \
|
||||||
|
|||||||
@@ -26,6 +26,121 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
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:
|
# Baked in by the package wrapper:
|
||||||
# NOMARCHY_INSTALL_SHARE — disko-config.nix, hardware-db.sh,
|
# NOMARCHY_INSTALL_SHARE — disko-config.nix, hardware-db.sh,
|
||||||
# compose-lock.py, flake.lock, template/,
|
# compose-lock.py, flake.lock, template/,
|
||||||
@@ -109,13 +224,43 @@ live_disk=""
|
|||||||
live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true)
|
live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true)
|
||||||
[[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 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 \
|
# Installable whole disks only (#112): drop floppy/pseudo/tiny devices that
|
||||||
| awk -v skip="/dev/${live_disk:-NONE}" \
|
# OVMF and some firmwares expose as TYPE=disk (e.g. /dev/fd0 first in the
|
||||||
'$NF == "disk" && $1 != skip && $1 !~ /loop|zram|sr[0-9]/ {NF--; print}')
|
# list — a blind Enter would erase nothing useful and break the install).
|
||||||
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found."
|
# 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
|
if [[ "$UNATTENDED" == "1" ]]; then
|
||||||
TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}"
|
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
|
else
|
||||||
choice=$(printf '%s\n' "${disks[@]}" \
|
choice=$(printf '%s\n' "${disks[@]}" \
|
||||||
| gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)")
|
| 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 ─────────────────────────────────────────────────
|
# ─── Swap / hibernation ─────────────────────────────────────────────────
|
||||||
# A swapfile ≥ RAM on its own BTRFS subvolume makes hibernation possible;
|
# A swapfile ≥ RAM on its own BTRFS subvolume makes hibernation possible;
|
||||||
# the resume offset is wired into the config below.
|
# the resume offset is wired into the config below.
|
||||||
|
section "Swap & hibernation"
|
||||||
|
|
||||||
ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)
|
ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)
|
||||||
if [[ "$UNATTENDED" == "1" ]]; then
|
if [[ "$UNATTENDED" == "1" ]]; then
|
||||||
SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}"
|
SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}"
|
||||||
else
|
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" \
|
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)")
|
--placeholder "swap size in GiB (≥ RAM enables hibernation, 0 = none)")
|
||||||
fi
|
fi
|
||||||
[[ "$SWAP_GB" =~ ^[0-9]+$ ]] || fail "Swap size must be a whole number of GiB."
|
[[ "$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 ───────────────────────────────────────────────────────
|
# ─── User account ───────────────────────────────────────────────────────
|
||||||
section "Your account"
|
section "Your account"
|
||||||
@@ -214,17 +364,51 @@ else
|
|||||||
| sed 's/$/.UTF-8/' \
|
| sed 's/$/.UTF-8/' \
|
||||||
| gum filter --placeholder "language / locale (type to search)" \
|
| gum filter --placeholder "language / locale (type to search)" \
|
||||||
|| echo en_US.UTF-8)
|
|| echo en_US.UTF-8)
|
||||||
KB_LAYOUT=$(localectl list-x11-keymap-layouts 2>/dev/null \
|
keyboard_layouts=$(keyboard_layout_catalog) || \
|
||||||
| gum filter --placeholder "keyboard layout (type to search)" \
|
fail "Could not read the installed keyboard-layout catalog."
|
||||||
|| echo us)
|
[[ -n "$keyboard_layouts" ]] || \
|
||||||
KB_VARIANT=""
|
fail "The installed keyboard-layout catalog is empty."
|
||||||
if [[ "$KB_LAYOUT" != "us" ]] || gum confirm --default=No "Pick a keyboard variant (intl, nodeadkeys, …)?"; then
|
if gum confirm --default=yes "Use the standard US English keyboard (no special variant)?"; then
|
||||||
KB_VARIANT=$( { echo "(none)"; localectl list-x11-keymap-variants "$KB_LAYOUT" 2>/dev/null; } \
|
KB_LAYOUT="us"
|
||||||
| gum filter --placeholder "variant for $KB_LAYOUT (pick '(none)' for the default)" \
|
KB_VARIANT=""
|
||||||
|| echo "(none)")
|
else
|
||||||
[[ "$KB_VARIANT" == "(none)" ]] && KB_VARIANT=""
|
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
|
||||||
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'."
|
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] || fail "Invalid username '$USERNAME'."
|
||||||
[[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \
|
[[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \
|
||||||
|| timedatectl list-timezones 2>/dev/null | grep -qx "$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)
|
HASHED_PASSWORD=$(printf '%s' "$PASSWORD" | mkpasswd -m sha-512 -s)
|
||||||
unset PASSWORD
|
unset PASSWORD
|
||||||
info "User: $USERNAME @ $HOSTNAME_ ($TIMEZONE)"
|
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 ───────────────────────────────────────────────────
|
# ─── Hardware profile ───────────────────────────────────────────────────
|
||||||
section "Hardware detection"
|
section "Hardware detection"
|
||||||
@@ -287,10 +477,11 @@ fi
|
|||||||
gum style --border normal --padding "0 2" \
|
gum style --border normal --padding "0 2" \
|
||||||
"Disk: $TARGET_DISK (WILL BE ERASED)" \
|
"Disk: $TARGET_DISK (WILL BE ERASED)" \
|
||||||
"Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 + desktop auto-login" || echo none)" \
|
"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" \
|
"User: $USERNAME" \
|
||||||
"Hostname: $HOSTNAME_" \
|
"Hostname: $HOSTNAME_" \
|
||||||
"Timezone: $TIMEZONE" \
|
"Timezone: $TIMEZONE" \
|
||||||
|
"Keyboard: $KEYBOARD_SUMMARY" \
|
||||||
"Hardware: ${HW_PROFILES[*]:-none}" \
|
"Hardware: ${HW_PROFILES[*]:-none}" \
|
||||||
"Snapshots: snapper timeline on /" \
|
"Snapshots: snapper timeline on /" \
|
||||||
"Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")— $SOURCE_NET"
|
"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" \
|
cp "$SHARE/template/flake.nix" \
|
||||||
"$SHARE/template/system.nix" \
|
"$SHARE/template/system.nix" \
|
||||||
"$SHARE/template/home.nix" \
|
"$SHARE/template/home.nix" \
|
||||||
"$SHARE/template/theme-state.json" \
|
"$SHARE/template/state.json" \
|
||||||
"$FLAKE_DIR/"
|
"$FLAKE_DIR/"
|
||||||
|
|
||||||
# Detected hardware → flags for the patcher (safe defaults active).
|
# 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)
|
"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,
|
"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
|
PYJSON
|
||||||
|
|
||||||
@@ -487,7 +679,7 @@ if (( compose_ok != 0 )); then
|
|||||||
(cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock)
|
(cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock)
|
||||||
fi
|
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"
|
cd "$FLAKE_DIR"
|
||||||
git init -q
|
git init -q
|
||||||
@@ -510,7 +702,7 @@ success "Configuration written to ~$USERNAME/.nomarchy"
|
|||||||
section "Installing (this takes a while)"
|
section "Installing (this takes a while)"
|
||||||
|
|
||||||
# Seed the target store with the flake source + all inputs so the first
|
# 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
|
# before the machine has ever seen a network. Two steps because
|
||||||
# `flake archive --to` enforces signatures and locally-evaluated source
|
# `flake archive --to` enforces signatures and locally-evaluated source
|
||||||
# paths have none; plain `nix copy` accepts --no-check-sigs.
|
# paths have none; plain `nix copy` accepts --no-check-sigs.
|
||||||
@@ -610,10 +802,28 @@ nix-daemon &
|
|||||||
daemon_pid=\$!
|
daemon_pid=\$!
|
||||||
trap 'kill \$daemon_pid 2>/dev/null || true' EXIT
|
trap 'kill \$daemon_pid 2>/dev/null || true' EXIT
|
||||||
sleep 2
|
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
|
# BACKUP_EXT: collisions can't abort the activation (a stray
|
||||||
# autogenerated config gets moved aside instead).
|
# autogenerated config gets moved aside instead).
|
||||||
runuser -u "$USERNAME" -- bash -lc \
|
if command -v dbus-run-session >/dev/null 2>&1; then
|
||||||
"USER=$USERNAME HOME=/home/$USERNAME NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \$out/activate"
|
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
|
EOF
|
||||||
# NOMARCHY_TEST_FORCE_HM_FAIL=1 — unattended harness only (#54 V2): take
|
# NOMARCHY_TEST_FORCE_HM_FAIL=1 — unattended harness only (#54 V2): take
|
||||||
# the failure arm so the durable recovery hint is exercised without a
|
# the failure arm so the durable recovery hint is exercised without a
|
||||||
|
|||||||
@@ -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:
|
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
||||||
if old not in text:
|
if old not in text:
|
||||||
sys.exit(f"patch-template: missing placeholder for {label}: {old!r}")
|
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',
|
f'username = "{nix_str(v["username"])}"; # <- your login name',
|
||||||
"flake username",
|
"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 []
|
profiles = v.get("hardwareProfiles") or []
|
||||||
if profiles:
|
if profiles:
|
||||||
items = " ".join(f'"{nix_str(p)}"' for p in 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:
|
def patch_home(text: str, v: dict) -> str:
|
||||||
layout = nix_str(v["keyboardLayout"])
|
layout = nix_str(v["keyboardLayout"])
|
||||||
variant = nix_str(v.get("keyboardVariant") or "")
|
variant = nix_str(keyboard_variant(v))
|
||||||
text = replace_once(
|
text = replace_once(
|
||||||
text,
|
text,
|
||||||
' nomarchy.keyboard.layout = "us";',
|
' 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.",
|
" # active; heavier opt-ins stay in the commented catalog below.",
|
||||||
]
|
]
|
||||||
|
|
||||||
if v.get("autoLogin"):
|
# Auto-login is deliberately NOT emitted here — it is seeded into
|
||||||
user = nix_str(v["username"])
|
# state.json instead (patch_state). A line in system.nix outranks
|
||||||
lines += [
|
# the state default, which would make the System › Auto-login toggle
|
||||||
" # LUKS passphrase already gates this machine — skip the greeter password.",
|
# write JSON that nothing reads.
|
||||||
f' nomarchy.system.greeter.autoLogin = "{user}";',
|
|
||||||
]
|
|
||||||
|
|
||||||
if v.get("laptop"):
|
if v.get("laptop"):
|
||||||
lines += [
|
lines += [
|
||||||
@@ -213,7 +228,7 @@ def patch_system(text: str, v: dict) -> str:
|
|||||||
text = replace_once(
|
text = replace_once(
|
||||||
text,
|
text,
|
||||||
' services.xserver.xkb.variant = "";',
|
' 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",
|
"xkb variant",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -244,6 +259,23 @@ def patch_system(text: str, v: dict) -> str:
|
|||||||
return text
|
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:
|
def main() -> None:
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
sys.exit("usage: patch-template.py <flake-dir>")
|
sys.exit("usage: patch-template.py <flake-dir>")
|
||||||
@@ -254,6 +286,7 @@ def main() -> None:
|
|||||||
"flake.nix": patch_flake,
|
"flake.nix": patch_flake,
|
||||||
"home.nix": patch_home,
|
"home.nix": patch_home,
|
||||||
"system.nix": patch_system,
|
"system.nix": patch_system,
|
||||||
|
"state.json": patch_state,
|
||||||
}
|
}
|
||||||
for name, fn in mapping.items():
|
for name, fn in mapping.items():
|
||||||
path = flake_dir / name
|
path = flake_dir / name
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Installed on both NixOS (systemPackages) and Home Manager so a home
|
# Installed on both NixOS (systemPackages) and Home Manager so a home
|
||||||
# switch can refresh them without waiting for a full system rebuild —
|
# switch can refresh them without waiting for a full system rebuild —
|
||||||
# otherwise a broken nomarchy-pull can never update itself.
|
# otherwise a broken nomarchy-pull can never update itself.
|
||||||
{ lib, writeShellScriptBin, nvd, jq, symlinkJoin }:
|
{ lib, writeShellScriptBin, nvd, jq, symlinkJoin, nomarchy-what-changed }:
|
||||||
|
|
||||||
let
|
let
|
||||||
# Shared preamble: refuse root, resolve flake path.
|
# Shared preamble: refuse root, resolve flake path.
|
||||||
@@ -15,6 +15,45 @@ let
|
|||||||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
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" ''
|
nomarchy-pull = writeShellScriptBin "nomarchy-pull" ''
|
||||||
${preamble "nomarchy-pull"}
|
${preamble "nomarchy-pull"}
|
||||||
if [ ! -e "$flake/flake.nix" ]; then
|
if [ ! -e "$flake/flake.nix" ]; then
|
||||||
@@ -22,6 +61,9 @@ let
|
|||||||
echo " Set NOMARCHY_PATH or put your machine flake in ~/.nomarchy." >&2
|
echo " Set NOMARCHY_PATH or put your machine flake in ~/.nomarchy." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# Optional: pull *your* machine config only if this checkout tracks a
|
||||||
# remote. Distro updates come from the nomarchy flake *input* below —
|
# remote. Distro updates come from the nomarchy flake *input* below —
|
||||||
# many installs have no upstream on ~/.nomarchy (local auto-commits).
|
# many installs have no upstream on ~/.nomarchy (local auto-commits).
|
||||||
@@ -52,6 +94,9 @@ let
|
|||||||
echo "nomarchy-rebuild: no flake.nix at $flake" >&2
|
echo "nomarchy-rebuild: no flake.nix at $flake" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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)
|
before=$(readlink -f /run/current-system)
|
||||||
log=$(mktemp)
|
log=$(mktemp)
|
||||||
trap 'rm -f "$log"' EXIT
|
trap 'rm -f "$log"' EXIT
|
||||||
@@ -78,6 +123,13 @@ let
|
|||||||
else
|
else
|
||||||
echo "nomarchy-rebuild: what changed:"
|
echo "nomarchy-rebuild: what changed:"
|
||||||
${nvd}/bin/nvd diff "$before" "$after" || true
|
${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
|
fi
|
||||||
'';
|
'';
|
||||||
|
|
||||||
@@ -87,6 +139,19 @@ let
|
|||||||
echo "nomarchy-home: no flake.nix at $flake" >&2
|
echo "nomarchy-home: no flake.nix at $flake" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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)
|
log=$(mktemp)
|
||||||
trap 'rm -f "$log"' EXIT
|
trap 'rm -f "$log"' EXIT
|
||||||
set +e
|
set +e
|
||||||
@@ -102,7 +167,27 @@ let
|
|||||||
echo "Recovery: home-manager generations (or docs/RECOVERY.md)"
|
echo "Recovery: home-manager generations (or docs/RECOVERY.md)"
|
||||||
exit "$rc"
|
exit "$rc"
|
||||||
fi
|
fi
|
||||||
echo "nomarchy-home: desktop applied."
|
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
|
# Legacy aliases
|
||||||
@@ -125,7 +210,9 @@ symlinkJoin {
|
|||||||
sys-update
|
sys-update
|
||||||
sys-rebuild
|
sys-rebuild
|
||||||
home-update
|
home-update
|
||||||
|
nomarchy-what-changed
|
||||||
];
|
];
|
||||||
|
passthru.autocommit = nomarchy-autocommit;
|
||||||
meta = {
|
meta = {
|
||||||
description = "Nomarchy machine-flake lifecycle: pull, rebuild, home";
|
description = "Nomarchy machine-flake lifecycle: pull, rebuild, home";
|
||||||
mainProgram = "nomarchy-pull";
|
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
|
#!/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).
|
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
|
Theme changes are applied by Home Manager: this tool writes the new state
|
||||||
and runs `home-manager switch` (override with $NOMARCHY_REBUILD, or skip
|
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.
|
Stylix — is baked into the generation; nothing is patched at runtime.
|
||||||
|
|
||||||
The one runtime exception is the wallpaper: swww is imperative by nature,
|
The one runtime exception is the wallpaper: swww is imperative by nature,
|
||||||
@@ -39,7 +41,24 @@ from pathlib import Path
|
|||||||
# ─── Paths ────────────────────────────────────────────────────────────────
|
# ─── Paths ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
FLAKE_DIR = Path(os.environ.get("NOMARCHY_PATH", Path.home() / ".nomarchy")).expanduser()
|
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),
|
# Preset search path: the user's flake first (their custom themes win),
|
||||||
# then the presets baked into this package by the Nix build.
|
# then the presets baked into this package by the Nix build.
|
||||||
@@ -51,21 +70,24 @@ WALLPAPER_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
|
|||||||
|
|
||||||
QUIET = False
|
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:
|
def log(msg: str) -> None:
|
||||||
if not QUIET:
|
if not QUIET:
|
||||||
print(f"nomarchy-theme-sync: {msg}")
|
print(f"{_TOOL}: {msg}")
|
||||||
|
|
||||||
|
|
||||||
def die(msg: str) -> "None":
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
# All Nomarchy theme notifications share this synchronous tag, so the
|
# All Nomarchy theme notifications share this synchronous tag, so the
|
||||||
# notification daemon (swaync) REPLACES the previous one in place instead
|
# notification daemon (swaync) REPLACES the previous one in place instead
|
||||||
# of stacking — the "rebuilding…" toast becomes the "applied ✓" toast.
|
# 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", *,
|
def notify(body: str, summary: str = "Nomarchy", *,
|
||||||
@@ -87,13 +109,14 @@ def notify(body: str, summary: str = "Nomarchy", *,
|
|||||||
|
|
||||||
# ─── State management ─────────────────────────────────────────────────────
|
# ─── 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:
|
try:
|
||||||
return json.loads(path.read_text())
|
return json.loads(path.read_text())
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)")
|
die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)")
|
||||||
except json.JSONDecodeError as e:
|
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"column {e.colno}: {e.msg}\n"
|
||||||
f" file: {path}\n"
|
f" file: {path}\n"
|
||||||
f" fix: correct the syntax by hand (a trailing comma is the "
|
f" fix: correct the syntax by hand (a trailing comma is the "
|
||||||
@@ -103,32 +126,48 @@ def load_state(path: Path = STATE_FILE) -> dict:
|
|||||||
def write_state(state: dict) -> None:
|
def write_state(state: dict) -> None:
|
||||||
"""Atomic write: render to a temp file in the same dir, then rename.
|
"""Atomic write: render to a temp file in the same dir, then rename.
|
||||||
Validates first — an invalid state never reaches disk (the old file
|
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)
|
errors, warnings = validate_state(state)
|
||||||
for w in warnings:
|
for w in warnings:
|
||||||
log(f"warning: {w}")
|
log(f"warning: {w}")
|
||||||
if errors:
|
if errors:
|
||||||
die("refusing to write an invalid state (nothing changed):\n ✖ "
|
die("refusing to write an invalid state (nothing changed):\n ✖ "
|
||||||
+ "\n ✖ ".join(errors))
|
+ "\n ✖ ".join(errors))
|
||||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
out = FLAKE_DIR / STATE_NAME
|
||||||
fd, tmp = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".theme-state.", suffix=".json")
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=out.parent, prefix=".state.", suffix=".json")
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "w") as f:
|
with os.fdopen(fd, "w") as f:
|
||||||
json.dump(state, f, indent=2)
|
json.dump(state, f, indent=2)
|
||||||
f.write("\n")
|
f.write("\n")
|
||||||
os.replace(tmp, STATE_FILE)
|
os.replace(tmp, out)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
os.unlink(tmp)
|
os.unlink(tmp)
|
||||||
raise
|
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.
|
# but make sure a fresh checkout / reset can't silently hide it.
|
||||||
if (FLAKE_DIR / ".git").exists() and shutil.which("git"):
|
if (FLAKE_DIR / ".git").exists() and shutil.which("git"):
|
||||||
subprocess.run(
|
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,
|
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:
|
def auto_commit_enabled(state: dict) -> bool:
|
||||||
@@ -136,27 +175,41 @@ def auto_commit_enabled(state: dict) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def auto_commit(message: str) -> None:
|
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
|
else — after a mutation, so settings history is `git log`. The pathspec
|
||||||
keeps unrelated dirty files out of the commit; a missing git identity
|
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
|
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
|
fire this when the flag is on before OR after the write, so the
|
||||||
disable-toggle itself lands in history instead of staying dirty.
|
disable-toggle itself lands in history instead of staying dirty.
|
||||||
`bg` is deliberately excluded (runtime wallpaper churn); the wallpaper
|
`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:
|
if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None:
|
||||||
return
|
return
|
||||||
git = ["git", "-C", str(FLAKE_DIR)]
|
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).
|
# 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.
|
# 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:
|
capture_output=True).returncode == 0:
|
||||||
return
|
return
|
||||||
if not subprocess.run(git + ["config", "user.email"],
|
if not subprocess.run(git + ["config", "user.email"],
|
||||||
capture_output=True, text=True).stdout.strip():
|
capture_output=True, text=True).stdout.strip():
|
||||||
git += ["-c", "user.name=Nomarchy", "-c", "user.email=nomarchy@localhost"]
|
git += ["-c", "user.name=Nomarchy", "-c", "user.email=nomarchy@localhost"]
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
git + ["commit", "--quiet", "-m", message, "--", "theme-state.json"],
|
git + ["commit", "--quiet", "-m", message, "--"] + paths,
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
log(f"auto-committed: {message}")
|
log(f"auto-committed: {message}")
|
||||||
@@ -209,7 +262,7 @@ def validate_state(state) -> tuple:
|
|||||||
|
|
||||||
if not isinstance(state, dict):
|
if not isinstance(state, dict):
|
||||||
err("top level", "must be a JSON object",
|
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
|
return errors, warnings
|
||||||
|
|
||||||
for k in state:
|
for k in state:
|
||||||
@@ -312,7 +365,7 @@ def cmd_validate(_args) -> None:
|
|||||||
for w in warnings:
|
for w in warnings:
|
||||||
print(f" ● {w}")
|
print(f" ● {w}")
|
||||||
if errors:
|
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)
|
f"{len(errors)} problem(s):", file=sys.stderr)
|
||||||
for e in errors:
|
for e in errors:
|
||||||
print(f" ✖ {e}", file=sys.stderr)
|
print(f" ✖ {e}", file=sys.stderr)
|
||||||
@@ -337,7 +390,7 @@ def check_fonts(state: dict) -> None:
|
|||||||
families = [f.strip().lower() for f in result.stdout.split(",")]
|
families = [f.strip().lower() for f in result.stdout.split(",")]
|
||||||
if result.returncode != 0 or name.strip().lower() not in families:
|
if result.returncode != 0 or name.strip().lower() not in families:
|
||||||
fallback = result.stdout.strip() or "unknown"
|
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}'",
|
f"installed — fontconfig will substitute '{fallback}'",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
notify(f"Font '{name}' is not installed — using '{fallback}'")
|
notify(f"Font '{name}' is not installed — using '{fallback}'")
|
||||||
@@ -367,9 +420,23 @@ def run_switch() -> None:
|
|||||||
"Rebuild FAILED — scroll up for last lines; run nomarchy-doctor",
|
"Rebuild FAILED — scroll up for last lines; run nomarchy-doctor",
|
||||||
urgency="critical",
|
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(
|
die(
|
||||||
"rebuild failed (state already written; fix and re-run). "
|
"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 ✓")
|
notify("Changes applied ✓")
|
||||||
# Waybar runs from Hyprland's exec-once (not a systemd unit HM would
|
# Waybar runs from Hyprland's exec-once (not a systemd unit HM would
|
||||||
@@ -380,14 +447,23 @@ def run_switch() -> None:
|
|||||||
# reload_style_on_change while the symlinks flip), so prefer the
|
# reload_style_on_change while the symlinks flip), so prefer the
|
||||||
# restart whenever the supervisor is there to catch it; fall back to
|
# restart whenever the supervisor is there to catch it; fall back to
|
||||||
# SIGUSR2 for unsupervised/custom bars. No-ops if nothing is running.
|
# 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"):
|
if shutil.which("pkill"):
|
||||||
supervised = subprocess.run(
|
supervised = subprocess.run(
|
||||||
["pgrep", "-f", "nomarchy-waybar"], capture_output=True
|
["pgrep", "-f", "nomarchy-waybar"], capture_output=True
|
||||||
).returncode == 0
|
).returncode == 0
|
||||||
if supervised:
|
if supervised:
|
||||||
subprocess.run(["pkill", "-x", "waybar"], check=False)
|
subprocess.run(["pkill", "-x", r"waybar|\.waybar-wrapped"], check=False)
|
||||||
else:
|
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) ────────────────────────────────────────────
|
# ─── Theme assets (wallpapers) ────────────────────────────────────────────
|
||||||
@@ -473,7 +549,7 @@ def apply_named(theme: str, no_switch: bool = False) -> None:
|
|||||||
candidate = Path(theme).expanduser()
|
candidate = Path(theme).expanduser()
|
||||||
preset_path = candidate if candidate.is_file() else find_preset(theme)
|
preset_path = candidate if candidate.is_file() else find_preset(theme)
|
||||||
if preset_path is None:
|
if preset_path is None:
|
||||||
die(f"unknown theme '{theme}' (try `nomarchy-theme-sync list`)")
|
die(f"unknown theme '{theme}' (try `nomarchy-state-sync list`)")
|
||||||
|
|
||||||
preset = json.loads(preset_path.read_text())
|
preset = json.loads(preset_path.read_text())
|
||||||
# Merge over current state. Presets carry a full appearance block —
|
# Merge over current state. Presets carry a full appearance block —
|
||||||
@@ -586,16 +662,19 @@ def cmd_get(args) -> None:
|
|||||||
node = node[key]
|
node = node[key]
|
||||||
except (KeyError, TypeError):
|
except (KeyError, TypeError):
|
||||||
die(f"no such key: {args.path}")
|
die(f"no such key: {args.path}")
|
||||||
# Booleans print JSON-style (true/false, not Python's True/False) so
|
# Booleans and null print JSON-style (true/false/null, not Python's
|
||||||
# shell consumers can compare against the same literal they `set`.
|
# 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)
|
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:
|
else:
|
||||||
print(json.dumps(state, indent=2))
|
print(json.dumps(state, indent=2))
|
||||||
|
|
||||||
|
|
||||||
def cmd_wallpaper(_args) -> None:
|
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)
|
apply_wallpaper(load_state(), wait=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -622,7 +701,7 @@ def main() -> None:
|
|||||||
global QUIET
|
global QUIET
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="nomarchy-theme-sync",
|
prog="nomarchy-state-sync",
|
||||||
description="Nomarchy theming — state writer + Home Manager rebuild dispatcher.",
|
description="Nomarchy theming — state writer + Home Manager rebuild dispatcher.",
|
||||||
)
|
)
|
||||||
parser.add_argument("--quiet", action="store_true", help="suppress progress output")
|
parser.add_argument("--quiet", action="store_true", help="suppress progress output")
|
||||||
@@ -651,7 +730,7 @@ def main() -> None:
|
|||||||
p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild")
|
p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild")
|
||||||
p.set_defaults(func=cmd_auto)
|
p.set_defaults(func=cmd_auto)
|
||||||
|
|
||||||
sub.add_parser("validate", help="check theme-state.json against the schema (read-only)").set_defaults(func=cmd_validate)
|
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)
|
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
|
services — the login user is created from `username` automatically) and
|
||||||
`home.nix` (your packages).
|
`home.nix` (your packages).
|
||||||
3. `git init && git add -A` — flakes only see tracked files, including
|
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`
|
4. System: `sudo nixos-rebuild switch --flake .#default`
|
||||||
5. Desktop: `nix run home-manager -- switch --flake .#me`
|
5. Desktop: `nix run home-manager -- switch --flake .#me`
|
||||||
(afterwards just `home-manager switch --flake .#me` — it's installed)
|
(afterwards just `home-manager switch --flake .#me` — it's installed)
|
||||||
6. Clone/symlink this directory to `~/.nomarchy` (or export
|
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:
|
Day-to-day:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nomarchy-theme-sync list # 24 shipped presets
|
nomarchy-state-sync list # 24 shipped presets
|
||||||
nomarchy-theme-sync apply gruvbox # writes state + home-manager switch
|
nomarchy-state-sync apply gruvbox # writes state + home-manager switch
|
||||||
nomarchy-theme-sync bg next # cycle wallpapers (instant, no rebuild)
|
nomarchy-state-sync bg next # cycle wallpapers (instant, no rebuild)
|
||||||
```
|
```
|
||||||
|
|
||||||
The system layer only needs `nixos-rebuild` when you change `system.nix`.
|
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 —
|
# The only input. nixpkgs, home-manager etc. come pinned through it —
|
||||||
# tested together upstream. This file is written once (by you or the
|
# tested together upstream. This file is written once (by you or the
|
||||||
# installer) and never hand-edited afterwards; your machine lives in
|
# installer) and never hand-edited afterwards; your machine lives in
|
||||||
# system.nix and home.nix. v1 is the release branch.
|
# system.nix and home.nix.
|
||||||
# Installer copies this template and patches username + hardwareProfile.
|
# Development tip is `main`. Release consumers pin `?ref=v1` once that
|
||||||
inputs.nomarchy.url = "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1";
|
# 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, ... }:
|
outputs = { nomarchy, ... }:
|
||||||
nomarchy.lib.mkFlake {
|
nomarchy.lib.mkFlake {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Your user environment. The Nomarchy desktop (Hyprland, Waybar,
|
# Your user environment. The Nomarchy desktop (Hyprland, Waybar,
|
||||||
# Ghostty, theming engine, Stylix) comes from homeModules.nomarchy;
|
# Kitty, theming engine, Stylix) comes from homeModules.nomarchy;
|
||||||
# tune it via the nomarchy.* options, add your own packages and
|
# tune it via the nomarchy.* options, add your own packages and
|
||||||
# programs below.
|
# programs below.
|
||||||
#
|
#
|
||||||
@@ -14,16 +14,25 @@
|
|||||||
nomarchy.keyboard.variant = "";
|
nomarchy.keyboard.variant = "";
|
||||||
|
|
||||||
# ── Overrides (the desktop is on by default; tweak or opt out) ──────
|
# ── Overrides (the desktop is on by default; tweak or opt out) ──────
|
||||||
# nomarchy.terminal = "kitty"; # swap the default terminal
|
# nomarchy.terminal = "kitty"; # default; only Kitty is shipped/themed
|
||||||
|
# nomarchy.kitty.enable = false; # keep the package, drop our config
|
||||||
# nomarchy.waybar.enable = false; # bring your own bar
|
# nomarchy.waybar.enable = false; # bring your own bar
|
||||||
# nomarchy.stylix.enable = false; # opt out of GTK/Qt theming
|
# nomarchy.stylix.enable = false; # opt out of GTK/Qt theming
|
||||||
|
# nomarchy.dockAudio.enable = false; # keep audio put when docking
|
||||||
|
# nomarchy.idle.fingerprint = true; # pin lock-screen finger unlock on/off. Normally
|
||||||
|
# # you want System › Fingerprint instead: it moves
|
||||||
|
# # this AND login/sudo together off one state key,
|
||||||
|
# # and this line would override it. Separate options
|
||||||
|
# # only because hyprlock is configured here (it can't
|
||||||
|
# # read the NixOS side) and doesn't take a finger
|
||||||
|
# # through PAM at all — it has its own fprintd backend.
|
||||||
#
|
#
|
||||||
# Icon pack: Papirus ships by default (auto Dark/Light by theme mode) and
|
# Icon pack: Papirus ships by default (auto Dark/Light by theme mode) and
|
||||||
# is the ONLY icon pack in your closure. To use another, set the `icons`
|
# is the ONLY icon pack in your closure. To use another, set the `icons`
|
||||||
# field in your theme-state.json to a theme name from a known pack — only
|
# field in your state.json to a theme name from a known pack — only
|
||||||
# that pack is then added (no bloat otherwise). Known packs: Papirus,
|
# that pack is then added (no bloat otherwise). Known packs: Papirus,
|
||||||
# Tela, Qogir, Reversal, Numix (see modules/home/theme.nix `iconPacks`).
|
# Tela, Qogir, Reversal, Numix (see modules/home/theme.nix `iconPacks`).
|
||||||
# e.g. theme-state.json: "icons": "Tela-dark"
|
# e.g. state.json: "icons": "Tela-dark"
|
||||||
|
|
||||||
# ── Opt-in features — uncomment and tweak to enable ─────────────────
|
# ── Opt-in features — uncomment and tweak to enable ─────────────────
|
||||||
# nomarchy.nightlight = { # scheduled blue-light filter (hyprsunset)
|
# nomarchy.nightlight = { # scheduled blue-light filter (hyprsunset)
|
||||||
@@ -72,9 +81,15 @@
|
|||||||
# "keychron-keychron-k2" = { layout = "de"; };
|
# "keychron-keychron-k2" = { layout = "de"; };
|
||||||
# };
|
# };
|
||||||
#
|
#
|
||||||
# nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a new
|
# nomarchy.keyboard.layouts = [ "de" "fr" ]; # optional: put these first in the
|
||||||
# # keyboard connects + remember it in the
|
# # new-keyboard picker (all installed XKB
|
||||||
# # flake state (graduates to .devices above)
|
# # layouts are searchable without this)
|
||||||
|
#
|
||||||
|
# services.nextcloud-client = { # Nextcloud sync client (upstream Home Manager
|
||||||
|
# enable = true; # module — installs the client and starts it
|
||||||
|
# startInBackground = true; # with the graphical session; tray icon only,
|
||||||
|
# }; # no window). Re-comment to remove the app
|
||||||
|
# # AND its background service.
|
||||||
|
|
||||||
# ── Application suite ───────────────────────────────────────────────
|
# ── Application suite ───────────────────────────────────────────────
|
||||||
# A starter complete-workstation set, installed for your user — yours to
|
# A starter complete-workstation set, installed for your user — yours to
|
||||||
@@ -91,7 +106,9 @@
|
|||||||
home.packages = with pkgs; [
|
home.packages = with pkgs; [
|
||||||
libreoffice-fresh # office suite (documents, sheets, slides)
|
libreoffice-fresh # office suite (documents, sheets, slides)
|
||||||
vscode # code editor (unfree; allowUnfree is on)
|
vscode # code editor (unfree; allowUnfree is on)
|
||||||
chromium # web browser (mime defaults → chromium-browser.desktop)
|
(chromium.override { enableWideVine = true; })
|
||||||
|
# web browser with Widevine DRM (Netflix, Spotify web…);
|
||||||
|
# unfree CDM — mime defaults → chromium-browser.desktop
|
||||||
gimp # raster image editor
|
gimp # raster image editor
|
||||||
inkscape # vector graphics
|
inkscape # vector graphics
|
||||||
mpv # media player
|
mpv # media player
|
||||||
@@ -167,7 +184,8 @@
|
|||||||
# mangohud # in-game overlay
|
# mangohud # in-game overlay
|
||||||
|
|
||||||
# Files, sync & torrents
|
# Files, sync & torrents
|
||||||
# nextcloud-client
|
# nextcloud-client # bare app — services.nextcloud-client above
|
||||||
|
# # also autostarts it with the session
|
||||||
# localsend # AirDrop-like transfer
|
# localsend # AirDrop-like transfer
|
||||||
# qbittorrent
|
# qbittorrent
|
||||||
|
|
||||||
@@ -183,6 +201,8 @@
|
|||||||
|
|
||||||
# Local AI (Ollama is a service toggle in system.nix)
|
# Local AI (Ollama is a service toggle in system.nix)
|
||||||
# lmstudio # (unfree; large closure)
|
# lmstudio # (unfree; large closure)
|
||||||
|
# unstable.lmstudio # newer lmstudio from the unstable channel —
|
||||||
|
# # you own this combination (see README)
|
||||||
# alpaca # GTK Ollama UI
|
# alpaca # GTK Ollama UI
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
# backend = "ppd"; # "ppd" (default) or "tlp"
|
# backend = "ppd"; # "ppd" (default) or "tlp"
|
||||||
# laptop = true; # required by batteryChargeLimit
|
# laptop = true; # required by batteryChargeLimit
|
||||||
# batteryChargeLimit = 80; # cap charging for longevity
|
# batteryChargeLimit = 80; # cap charging for longevity
|
||||||
|
# # suspendThenHibernate = true; # 1h on battery → hibernate (#115);
|
||||||
|
# # menu: Preferences › Suspend then hibernate
|
||||||
# thermal.enable = true; # thermald (Intel CPUs)
|
# thermal.enable = true; # thermald (Intel CPUs)
|
||||||
# };
|
# };
|
||||||
#
|
#
|
||||||
@@ -62,7 +64,10 @@
|
|||||||
# amd.rocm.enable = true; # ROCm GPU compute (multi-GB, opt-in)
|
# amd.rocm.enable = true; # ROCm GPU compute (multi-GB, opt-in)
|
||||||
# amd.rocm.gfxOverride = "11.0.0"; # HSA override for an unlisted iGPU
|
# amd.rocm.gfxOverride = "11.0.0"; # HSA override for an unlisted iGPU
|
||||||
# fingerprint.enable = true; # fprintd; installer-set when a reader is detected
|
# fingerprint.enable = true; # fprintd; installer-set when a reader is detected
|
||||||
# fingerprint.pam = true; # use the fingerprint for login + sudo
|
# fingerprint.pam = true; # pin the fingerprint on/off instead of letting the
|
||||||
|
# # System › Fingerprint toggle own it (this line WINS
|
||||||
|
# # over the menu — leave it commented to use the menu)
|
||||||
|
# fingerprint.parallel = false; # sequential prompt instead of password-or-finger
|
||||||
# npu.enable = true; # on-die NPU driver (experimental; userspace runtime BYO)
|
# npu.enable = true; # on-die NPU driver (experimental; userspace runtime BYO)
|
||||||
# latestKernel = true; # newest kernel for very-new hardware (drivers not yet in the default)
|
# latestKernel = true; # newest kernel for very-new hardware (drivers not yet in the default)
|
||||||
# camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node so apps get the color cam
|
# camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node so apps get the color cam
|
||||||
|
|||||||
60
themes/boreal-dawn.json
Normal file
60
themes/boreal-dawn.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"name": "Boreal Dawn",
|
||||||
|
"slug": "boreal-dawn",
|
||||||
|
"mode": "light",
|
||||||
|
"wallpaper": "",
|
||||||
|
"colors": {
|
||||||
|
"base": "#EDF2F6",
|
||||||
|
"mantle": "#CBD6DF",
|
||||||
|
"surface": "#DFE7EE",
|
||||||
|
"overlay": "#A9B8C6",
|
||||||
|
"text": "#333E4E",
|
||||||
|
"subtext": "#51617A",
|
||||||
|
"muted": "#7C8B9D",
|
||||||
|
"accent": "#7458C8",
|
||||||
|
"accentAlt": "#22766E",
|
||||||
|
"good": "#48752C",
|
||||||
|
"warn": "#8F5E0A",
|
||||||
|
"bad": "#B03A47"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"active": "accent",
|
||||||
|
"inactive": "overlay"
|
||||||
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "GeistMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 6,
|
||||||
|
"gapsOut": 14,
|
||||||
|
"borderSize": 3,
|
||||||
|
"rounding": 12,
|
||||||
|
"iconSize": 44,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.94,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
|
"ansi": [
|
||||||
|
"#333E4E",
|
||||||
|
"#B03A47",
|
||||||
|
"#48752C",
|
||||||
|
"#8F5E0A",
|
||||||
|
"#3565BE",
|
||||||
|
"#7458C8",
|
||||||
|
"#22766E",
|
||||||
|
"#EDF2F6",
|
||||||
|
"#7C8B9D",
|
||||||
|
"#C94E5C",
|
||||||
|
"#5A8A3C",
|
||||||
|
"#A9731A",
|
||||||
|
"#4678D0",
|
||||||
|
"#8568D8",
|
||||||
|
"#2E8A80",
|
||||||
|
"#F8FAFC"
|
||||||
|
]
|
||||||
|
}
|
||||||
25
themes/boreal-dawn/btop.theme
Normal file
25
themes/boreal-dawn/btop.theme
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
theme[main_bg]="#EDF2F6"
|
||||||
|
theme[main_fg]="#333E4E"
|
||||||
|
theme[title]="#333E4E"
|
||||||
|
theme[hi_fg]="#7458C8"
|
||||||
|
theme[selected_bg]="#A9B8C6"
|
||||||
|
theme[selected_fg]="#333E4E"
|
||||||
|
theme[inactive_fg]="#7C8B9D"
|
||||||
|
theme[graph_text]="#51617A"
|
||||||
|
theme[meter_bg]="#DFE7EE"
|
||||||
|
theme[proc_misc]="#22766E"
|
||||||
|
theme[cpu_box]="#7458C8"
|
||||||
|
theme[mem_box]="#48752C"
|
||||||
|
theme[net_box]="#22766E"
|
||||||
|
theme[proc_box]="#DFE7EE"
|
||||||
|
theme[div_line]="#A9B8C6"
|
||||||
|
theme[temp_start]="#48752C"
|
||||||
|
theme[temp_mid]="#8F5E0A"
|
||||||
|
theme[temp_end]="#B03A47"
|
||||||
|
theme[cpu_start]="#48752C"
|
||||||
|
theme[cpu_mid]="#8F5E0A"
|
||||||
|
theme[cpu_end]="#B03A47"
|
||||||
|
theme[free_start]="#48752C"
|
||||||
|
theme[used_start]="#8F5E0A"
|
||||||
|
theme[download_start]="#7458C8"
|
||||||
|
theme[upload_start]="#22766E"
|
||||||
42
themes/boreal-dawn/launcher.rasi
Normal file
42
themes/boreal-dawn/launcher.rasi
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Boreal Dawn — app-launcher override (themes/<slug>/launcher.rasi).
|
||||||
|
* Installed to ~/.local/share/rofi/themes/launcher.rasi and used only by
|
||||||
|
* `rofi -show drun` (the SUPER+Space / SUPER+D launcher). It inherits the
|
||||||
|
* frosted dawn panel from the sibling rofi.rasi (this whole-swap theme,
|
||||||
|
* which HM deploys under its source basename — rofi.rasi — not custom.rasi),
|
||||||
|
* then reshapes the list into a 4×3 grid of large application icons.
|
||||||
|
* Text menus keep the list — they don't use this theme.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import "rofi.rasi"
|
||||||
|
|
||||||
|
window { width: 780px; }
|
||||||
|
|
||||||
|
listview {
|
||||||
|
columns: 4;
|
||||||
|
lines: 3;
|
||||||
|
fixed-columns: true;
|
||||||
|
spacing: 10px;
|
||||||
|
padding: 6px 0px 0px 0px;
|
||||||
|
/* Fill row-by-row so Down at a column's foot pages to the next screen
|
||||||
|
instead of jumping to the top of the next column (rofi's default
|
||||||
|
Vertical flow). Mirrors the theme-picker grid in rofi.nix. */
|
||||||
|
flow: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
element {
|
||||||
|
orientation: vertical;
|
||||||
|
padding: 16px 8px;
|
||||||
|
spacing: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-icon {
|
||||||
|
size: 56px;
|
||||||
|
horizontal-align: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-text {
|
||||||
|
horizontal-align: 0.5;
|
||||||
|
vertical-align: 0.5;
|
||||||
|
}
|
||||||
BIN
themes/boreal-dawn/preview.png
Normal file
BIN
themes/boreal-dawn/preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
122
themes/boreal-dawn/rofi.rasi
Normal file
122
themes/boreal-dawn/rofi.rasi
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Boreal Dawn — rofi whole-swap (themes/<slug>/rofi.rasi).
|
||||||
|
* The base theme: a frosted dawn panel used by every menu (and, as a
|
||||||
|
* list, the fallback launcher). The app launcher reshapes this into a
|
||||||
|
* grid via themes/boreal-dawn/launcher.rasi, which @imports this file. The
|
||||||
|
* `configuration {}` block is omitted on purpose — it comes from
|
||||||
|
* modules/home/rofi.nix. Element structure mirrors the generated theme
|
||||||
|
* so the theme-grid picker's per-invocation -theme-str still lays out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
base: #EDF2F6;
|
||||||
|
glassBg: #EDF2F6E6;
|
||||||
|
surface: #DFE7EE;
|
||||||
|
overlay: #A9B8C6;
|
||||||
|
text: #333E4E;
|
||||||
|
subtext: #51617A;
|
||||||
|
muted: #7C8B9D;
|
||||||
|
accent: #7458C8;
|
||||||
|
accentAlt: #22766E;
|
||||||
|
accentSoft: #7458C83B;
|
||||||
|
frostEdge: #22766E5C;
|
||||||
|
|
||||||
|
font: "GeistMono Nerd Font 11";
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
window {
|
||||||
|
background-color: @glassBg;
|
||||||
|
border: 1px;
|
||||||
|
border-color: @frostEdge;
|
||||||
|
border-radius: 14px;
|
||||||
|
width: calc( 84ch min 65% );
|
||||||
|
location: center;
|
||||||
|
anchor: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainbox {
|
||||||
|
background-color: transparent;
|
||||||
|
children: [ inputbar, message, listview ];
|
||||||
|
spacing: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputbar {
|
||||||
|
children: [ prompt, entry ];
|
||||||
|
background-color: @surface;
|
||||||
|
text-color: @text;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
spacing: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt { text-color: @accent; }
|
||||||
|
|
||||||
|
entry {
|
||||||
|
text-color: @text;
|
||||||
|
cursor: text;
|
||||||
|
placeholder: "Search…";
|
||||||
|
placeholder-color: @muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
listview {
|
||||||
|
background-color: transparent;
|
||||||
|
columns: 1;
|
||||||
|
lines: 8;
|
||||||
|
dynamic: true;
|
||||||
|
scrollbar: true;
|
||||||
|
spacing: 6px;
|
||||||
|
padding: 4px 0px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element {
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @text;
|
||||||
|
orientation: horizontal;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
spacing: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element normal.normal,
|
||||||
|
element alternate.normal {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected row lifts on a soft aurora-violet wash. */
|
||||||
|
element selected.normal {
|
||||||
|
background-color: @accentSoft;
|
||||||
|
text-color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-icon {
|
||||||
|
background-color: transparent;
|
||||||
|
size: 30px;
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-text {
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: inherit;
|
||||||
|
highlight: bold;
|
||||||
|
vertical-align: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message { padding: 0px; }
|
||||||
|
|
||||||
|
textbox {
|
||||||
|
background-color: @surface;
|
||||||
|
text-color: @text;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
handle-width: 4px;
|
||||||
|
handle-color: @accent;
|
||||||
|
background-color: @surface;
|
||||||
|
border: 0px;
|
||||||
|
}
|
||||||
195
themes/boreal-dawn/waybar.css
Normal file
195
themes/boreal-dawn/waybar.css
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* Boreal Dawn — Waybar whole-swap (themes/<slug>/waybar.css).
|
||||||
|
*
|
||||||
|
* A theme waybar.css replaces the ENTIRE generated stylesheet and is read
|
||||||
|
* raw (no palette is prepended), so this file MUST define its own colors.
|
||||||
|
* Identity: a floating "frost dawn" bar — three frosted-glass pills
|
||||||
|
* (launcher+clock · workspaces · status) over the wallpaper's blur. The
|
||||||
|
* whole right side is ONE continuous pill: the system tray shares its
|
||||||
|
* glass with the status modules, no separators, no boxes. Companion:
|
||||||
|
* waybar.jsonc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@define-color base #EDF2F6;
|
||||||
|
@define-color mantle #CBD6DF;
|
||||||
|
@define-color surface #DFE7EE;
|
||||||
|
@define-color overlay #A9B8C6;
|
||||||
|
@define-color text #333E4E;
|
||||||
|
@define-color subtext #51617A;
|
||||||
|
@define-color muted #7C8B9D;
|
||||||
|
@define-color accent #7458C8;
|
||||||
|
@define-color accentAlt #22766E;
|
||||||
|
@define-color good #48752C;
|
||||||
|
@define-color warn #8F5E0A;
|
||||||
|
@define-color bad #B03A47;
|
||||||
|
|
||||||
|
/* 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 (pills) and wreck the
|
||||||
|
bar. */
|
||||||
|
* {
|
||||||
|
/* Mono Nerd face for status glyphs (tight right bearings on the
|
||||||
|
proportional Symbols face make icon+percentage collide). */
|
||||||
|
font-family: "GeistMono Nerd Font", "Symbols Nerd Font Mono";
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tray menus (nm-applet & co live in Waybar's process) ─────────────
|
||||||
|
Undo the `*` reset for them: same stylesheet, higher specificity.
|
||||||
|
Arrows/checks are CSS-sized nodes — min-height 0 makes them invisible;
|
||||||
|
separators die the same way; the bar's mono face reads wrong in text
|
||||||
|
menus. */
|
||||||
|
menu, menu * { font-family: "Inter", sans-serif; }
|
||||||
|
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.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The bar itself is invisible — the pills float over the wallpaper. */
|
||||||
|
window#waybar {
|
||||||
|
background: transparent;
|
||||||
|
color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The three frosted-glass pills ────────────────────────────────────
|
||||||
|
Global blur turns the translucent @base into frosted glass; a faint
|
||||||
|
frost edge lifts each pill off the wallpaper. Light frost needs more
|
||||||
|
alpha than the dark theme's to stay visible on pale wallpapers. */
|
||||||
|
.modules-left,
|
||||||
|
.modules-center,
|
||||||
|
.modules-right {
|
||||||
|
background: alpha(@base, 0.78);
|
||||||
|
border: 1px solid alpha(@accentAlt, 0.35);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0 6px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every module sits ON its pill: transparent, so nothing boxes the tray
|
||||||
|
off from the status icons — the seamless-cluster requirement. */
|
||||||
|
#custom-nomarchy,
|
||||||
|
#clock,
|
||||||
|
#custom-recording,
|
||||||
|
#custom-updates,
|
||||||
|
#custom-doctor,
|
||||||
|
#idle_inhibitor,
|
||||||
|
#custom-nightlight,
|
||||||
|
#custom-airplane,
|
||||||
|
#language,
|
||||||
|
#custom-vpn,
|
||||||
|
#pulseaudio,
|
||||||
|
#battery,
|
||||||
|
#custom-powerprofile,
|
||||||
|
#tray,
|
||||||
|
#custom-notification,
|
||||||
|
#custom-powermenu {
|
||||||
|
background: transparent;
|
||||||
|
color: alpha(@text, 0.95);
|
||||||
|
padding: 0 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Launcher — the aurora-violet Nomarchy mark opens the pill ────────
|
||||||
|
The mark lives at U+F000 in the dedicated "Nomarchy" font; pin the
|
||||||
|
family or the Nerd Font's glass glyph at the same codepoint wins. */
|
||||||
|
#custom-nomarchy {
|
||||||
|
color: @accent;
|
||||||
|
font-family: Nomarchy;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 0 12px 0 14px;
|
||||||
|
}
|
||||||
|
#custom-nomarchy:hover { color: @accentAlt; }
|
||||||
|
|
||||||
|
/* ── Clock — unified time · date ─────────────────────────────────────*/
|
||||||
|
#clock {
|
||||||
|
color: @text;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Workspaces — dots that bloom to an aurora pill when active ───────*/
|
||||||
|
#workspaces { padding: 0 2px; }
|
||||||
|
|
||||||
|
#workspaces button {
|
||||||
|
/* @subtext, not @muted: on a light pill the 2.0-floor grey washes out
|
||||||
|
at number size — inactive workspaces must stay readable (6.4:1). */
|
||||||
|
color: @subtext;
|
||||||
|
padding: 0 9px;
|
||||||
|
margin: 3px 1px;
|
||||||
|
border-radius: 9px;
|
||||||
|
transition: color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
#workspaces button:hover {
|
||||||
|
color: @text;
|
||||||
|
background: alpha(@overlay, 0.5);
|
||||||
|
}
|
||||||
|
#workspaces button.active {
|
||||||
|
color: @base;
|
||||||
|
background: @accent;
|
||||||
|
}
|
||||||
|
#workspaces button.urgent {
|
||||||
|
color: @base;
|
||||||
|
background: @bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status semantics (neutral until a state fires) ──────────────────*/
|
||||||
|
#custom-recording.recording { color: @bad; }
|
||||||
|
#custom-updates.available { color: @accent; }
|
||||||
|
#custom-doctor { color: @bad; }
|
||||||
|
#idle_inhibitor.activated { color: @warn; }
|
||||||
|
#custom-nightlight.on { color: @warn; }
|
||||||
|
#custom-airplane.on { color: @warn; }
|
||||||
|
#custom-vpn.on { color: @good; }
|
||||||
|
#pulseaudio.muted { color: @muted; }
|
||||||
|
|
||||||
|
#battery.charging { color: @good; }
|
||||||
|
#battery.warning:not(.charging) { color: @warn; }
|
||||||
|
#battery.critical:not(.charging) { color: @bad; }
|
||||||
|
|
||||||
|
/* Icon-only status modules carry no text, so they don't get the 13pt
|
||||||
|
Pango icon span the icon+text modules (volume/battery/language) use —
|
||||||
|
bump their font-size to match, or these glyphs read noticeably smaller
|
||||||
|
than their neighbours. */
|
||||||
|
#custom-recording,
|
||||||
|
#custom-updates,
|
||||||
|
#custom-doctor,
|
||||||
|
#custom-nightlight,
|
||||||
|
#custom-airplane,
|
||||||
|
#custom-vpn,
|
||||||
|
#custom-notification { font-size: 17px; }
|
||||||
|
/* Speedometer + caffeine cup render small in their em box — a touch more. */
|
||||||
|
#custom-powerprofile,
|
||||||
|
#idle_inhibitor { font-size: 18px; }
|
||||||
|
|
||||||
|
#custom-notification.notification { color: @accent; }
|
||||||
|
#custom-notification.dnd-none,
|
||||||
|
#custom-notification.dnd-notification,
|
||||||
|
#custom-notification.inhibited-none,
|
||||||
|
#custom-notification.inhibited-notification { color: @muted; }
|
||||||
|
|
||||||
|
/* Tray shares the pill's glass — same transparent background, its own
|
||||||
|
modest padding so nm-applet & friends breathe with the status icons. */
|
||||||
|
#tray { padding: 0 6px; }
|
||||||
|
#tray > .passive { -gtk-icon-effect: dim; }
|
||||||
|
#tray > .needs-attention { -gtk-icon-effect: highlight; }
|
||||||
|
|
||||||
|
/* Power button closes the pill on the right; warms to alert on hover. */
|
||||||
|
#custom-powermenu { color: @subtext; padding: 0 12px 0 8px; }
|
||||||
|
#custom-powermenu:hover { color: @bad; }
|
||||||
|
|
||||||
|
/* ── Tooltips — frosted, frost edge ───────────────────────────────────*/
|
||||||
|
tooltip {
|
||||||
|
background: alpha(@base, 0.97);
|
||||||
|
border: 1px solid alpha(@accentAlt, 0.45);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
tooltip label { color: @text; }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user