All checks were successful
Check / eval (push) Successful in 3m7s
Bernardo, post-reboot: "Use for login" was the wrong question. Whether the
finger works is one decision, not two, and whether login prompts at all is
a different decision that was never in the menu.
System › Fingerprint is now a single Fingerprint (on/off) switch, leading
the menu with enroll/list/verify/delete as the plumbing behind it. It
writes the one settings.fingerprint.pam key, and modules/home/idle.nix now
defaults idle.fingerprint from that same key — so the lock screen and
login/sudo move together instead of drifting apart the way they did until
e2de906. nomarchy-fingerprint does the two rebuilds this needs (sudo
system for PAM, home switch for hyprlock) and refuses to turn on with no
finger enrolled.
System › Auto-login is new (nomarchy-autologin), and it is what decides
whether anything is asked at boot: auto-login on means no prompt whatever
the fingerprint switch says; off means the greeter asks, for a password or
a finger. Installer-seeded ON for LUKS machines — the passphrase already
gates the disk — and off without it, where the greeter is the only thing
between power-on and the desktop.
Both had to become state-owned to be toggleable at all, which surfaced two
real bugs:
* nomarchy.system.greeter.autoLogin defaulted from
`config.nomarchy.settings…` — an attribute that exists ONLY on the Home
Manager side. On NixOS it is absent and `or null` swallowed the error,
so the default silently evaluated to null on every machine ever built.
That is why the installer baked a Nix line: the state path never
worked. Now read via theme-state-read.nix (the hardware.nix/timezone.nix
pattern) and mkDefault'd, so the menu owns it and a hand-set line still
pins it. Two more options read the same phantom bridge — BACKLOG #116.
* `theme-sync get` printed Python's "None" for a JSON null, so every
`case … null)` a caller writes would miss. Now prints "null", as the
comment above it already promised for booleans.
The installer seeds the state instead of emitting the system.nix line,
because that line outranks the state and would strand the toggle.
V1 (V3 pending: HARDWARE-QUEUE). nix flake check --no-build, installer-
safety and option-docs all pass. Proved by eval/build, not assumed: a state
carrying autoLogin yields greetd initial_session {"user":"bernardo"}, the
template state (no autoLogin) yields none, and a hand-set null beats a state
that says otherwise; a state with only fingerprint.pam=true — nothing set by
hand — renders the hyprlock auth.fingerprint block; both new tools pass
bash -n and land in systemPackages (nomarchy-fingerprint only with a
reader); the patcher writes settings.greeter.autoLogin and no system.nix
line; and the get round trip prints null, so the menu reads "Auto-login
(off)" where it would have read "(on)".
The reader itself, the two rebuilds, and the reboot are hardware — queued.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
288 lines
10 KiB
Python
288 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""Patch a copied templates/downstream machine flake with install-time values.
|
||
|
||
The template is the single source of truth for commented opt-ins and the
|
||
starter app suite. The installer copies it, then this script only:
|
||
|
||
* replaces known placeholders (hostname, username, locale, keyboard, …)
|
||
* fills the __NOMARCHY_INSTALLER__ region with detected/active config
|
||
* sets hardwareProfile on flake.nix
|
||
|
||
Usage:
|
||
patch-template.py <flake-dir> # reads a JSON object from stdin
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
BEGIN = " # __NOMARCHY_INSTALLER_BEGIN__"
|
||
END = " # __NOMARCHY_INSTALLER_END__"
|
||
|
||
|
||
def nix_str(s: str) -> str:
|
||
"""Escape a string for a Nix double-quoted literal."""
|
||
return (
|
||
s.replace("\\", "\\\\")
|
||
.replace('"', '\\"')
|
||
.replace("${", "\\${")
|
||
.replace("\n", "\\n")
|
||
)
|
||
|
||
|
||
def keyboard_variant(v: dict) -> str:
|
||
"""Return the XKB value, never the picker's display-only sentinel."""
|
||
variant = v.get("keyboardVariant") or ""
|
||
return "" if variant == "(none)" else variant
|
||
|
||
|
||
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
||
if old not in text:
|
||
sys.exit(f"patch-template: missing placeholder for {label}: {old!r}")
|
||
return text.replace(old, new, 1)
|
||
|
||
|
||
def patch_flake(text: str, v: dict) -> str:
|
||
text = replace_once(
|
||
text,
|
||
'description = "My Nomarchy machine";',
|
||
f'description = "{nix_str(v["hostname"])} — my Nomarchy machine";',
|
||
"flake description",
|
||
)
|
||
text = replace_once(
|
||
text,
|
||
'username = "me"; # <- your login name',
|
||
f'username = "{nix_str(v["username"])}"; # <- your login name',
|
||
"flake username",
|
||
)
|
||
profiles = v.get("hardwareProfiles") or []
|
||
if profiles:
|
||
items = " ".join(f'"{nix_str(p)}"' for p in profiles)
|
||
hw_line = f" hardwareProfile = [ {items} ];"
|
||
else:
|
||
hw_line = " # hardwareProfile = null; # no nixos-hardware profiles selected"
|
||
# Replace the optional hardwareProfile comment block with the install choice.
|
||
text, n = re.subn(
|
||
r"\n # Optional: a nixos-hardware module name for your machine, e\.g\.\n"
|
||
r" # hardwareProfile = \"framework-13-7040-amd\";\n"
|
||
r" # Names: https://github.com/NixOS/nixos-hardware\n"
|
||
r" # \(the future installer fills this in automatically from DMI data\)\n",
|
||
f"\n{hw_line}\n"
|
||
f" # Names: https://github.com/NixOS/nixos-hardware\n",
|
||
text,
|
||
count=1,
|
||
)
|
||
if n != 1:
|
||
sys.exit("patch-template: could not patch hardwareProfile block in flake.nix")
|
||
return text
|
||
|
||
|
||
def patch_home(text: str, v: dict) -> str:
|
||
layout = nix_str(v["keyboardLayout"])
|
||
variant = nix_str(keyboard_variant(v))
|
||
text = replace_once(
|
||
text,
|
||
' nomarchy.keyboard.layout = "us";',
|
||
f' nomarchy.keyboard.layout = "{layout}";',
|
||
"home keyboard layout",
|
||
)
|
||
text = replace_once(
|
||
text,
|
||
' nomarchy.keyboard.variant = "";',
|
||
f' nomarchy.keyboard.variant = "{variant}";',
|
||
"home keyboard variant",
|
||
)
|
||
return text
|
||
|
||
|
||
def build_installer_region(v: dict) -> str:
|
||
lines: list[str] = [
|
||
BEGIN,
|
||
" # Written by nomarchy-install from live detection. Safe defaults are",
|
||
" # active; heavier opt-ins stay in the commented catalog below.",
|
||
]
|
||
|
||
# Auto-login is deliberately NOT emitted here — it is seeded into
|
||
# theme-state.json instead (patch_state). A line in system.nix outranks
|
||
# the state default, which would make the System › Auto-login toggle
|
||
# write JSON that nothing reads.
|
||
|
||
if v.get("laptop"):
|
||
lines += [
|
||
" # Laptop power (PPD + menu/Waybar). Uncomment to cap charge at 80%.",
|
||
" nomarchy.system.power.laptop = true;",
|
||
" # nomarchy.system.power.batteryChargeLimit = 80;",
|
||
]
|
||
if v.get("thermald"):
|
||
lines.append(
|
||
" nomarchy.system.power.thermal.enable = true; # thermald (Intel)"
|
||
)
|
||
|
||
hw = v.get("hardware") or {}
|
||
if any(
|
||
hw.get(k)
|
||
for k in ("intel", "amd", "fingerprint", "cameraIr", "npu", "nvidia")
|
||
):
|
||
lines.append(" # Hardware enablement (auto-detected).")
|
||
if hw.get("intel"):
|
||
lines.append(
|
||
" nomarchy.hardware.intel.enable = true; # GuC/HuC (i915)"
|
||
)
|
||
if hw.get("intelGucOff"):
|
||
lines.append(
|
||
" nomarchy.hardware.intel.guc = false; # xe driver → GuC default-on"
|
||
)
|
||
lines.append(
|
||
" # nomarchy.hardware.intel.computeRuntime = true; # OpenCL/oneVPL (opt-in)"
|
||
)
|
||
if hw.get("amd"):
|
||
lines += [
|
||
" nomarchy.hardware.amd.enable = true; # amd-pstate + VA-API",
|
||
" # nomarchy.hardware.amd.rocm.enable = true; # ROCm (multi-GB, opt-in)",
|
||
' # nomarchy.hardware.amd.rocm.gfxOverride = ""; # e.g. "11.0.0" for unlisted iGPU',
|
||
]
|
||
if hw.get("fingerprint"):
|
||
lines += [
|
||
" nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)",
|
||
" # nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)",
|
||
]
|
||
if hw.get("cameraIr"):
|
||
lines.append(
|
||
" nomarchy.hardware.camera.hideIrSensor = true; # dual-sensor: hide IR node"
|
||
)
|
||
if hw.get("npu"):
|
||
vendor = nix_str(hw["npu"])
|
||
lines += [
|
||
f" # nomarchy.hardware.npu.enable = true; # {vendor} NPU (experimental; userspace BYO)",
|
||
" # nomarchy.hardware.latestKernel = true; # if the NPU driver needs a newer kernel",
|
||
]
|
||
# NVIDIA: profile is in flake.nix (common-gpu-nvidia). Hybrid/PRIME
|
||
# knobs are plain NixOS — comment-only guidance, same pattern as ROCm.
|
||
if hw.get("nvidia"):
|
||
lines += [
|
||
" # NVIDIA: common-gpu-nvidia is in hardwareProfile (flake.nix).",
|
||
" # Hybrid/PRIME, power, open-module — plain NixOS; see docs/HARDWARE.md §6",
|
||
" # and https://wiki.nixos.org/wiki/Nvidia (bus IDs are machine-specific).",
|
||
" # hardware.nvidia.prime = { ... }; # offload/sync",
|
||
" # hardware.nvidia.powerManagement.enable = true; # suspend/resume",
|
||
" # hardware.nvidia.open = false; # true = open module (newer cards)",
|
||
]
|
||
|
||
if v.get("resumeOffset") is not None:
|
||
root_uuid = nix_str(v["rootUuid"])
|
||
offset = v["resumeOffset"]
|
||
lines += [
|
||
" # Swapfile (hibernation-ready: resume points into it).",
|
||
' swapDevices = [{ device = "/swap/swapfile"; }];',
|
||
f' boot.resumeDevice = "/dev/disk/by-uuid/{root_uuid}";',
|
||
f' boot.kernelParams = [ "resume_offset={offset}" ];',
|
||
]
|
||
|
||
# Always on for installer layout (BTRFS + @snapshots).
|
||
lines += [
|
||
" # Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.",
|
||
" nomarchy.system.snapper.enable = true;",
|
||
END,
|
||
]
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def patch_system(text: str, v: dict) -> str:
|
||
text = replace_once(
|
||
text,
|
||
' networking.hostName = "my-nomarchy";',
|
||
f' networking.hostName = "{nix_str(v["hostname"])}";',
|
||
"hostName",
|
||
)
|
||
text = replace_once(
|
||
text,
|
||
' time.timeZone = "UTC";',
|
||
f' time.timeZone = "{nix_str(v["timezone"])}";',
|
||
"timeZone",
|
||
)
|
||
text = replace_once(
|
||
text,
|
||
' i18n.defaultLocale = "en_US.UTF-8";',
|
||
f' i18n.defaultLocale = "{nix_str(v["locale"])}";',
|
||
"locale",
|
||
)
|
||
text = replace_once(
|
||
text,
|
||
' services.xserver.xkb.layout = "us";',
|
||
f' services.xserver.xkb.layout = "{nix_str(v["keyboardLayout"])}";',
|
||
"xkb layout",
|
||
)
|
||
text = replace_once(
|
||
text,
|
||
' services.xserver.xkb.variant = "";',
|
||
f' services.xserver.xkb.variant = "{nix_str(keyboard_variant(v))}";',
|
||
"xkb variant",
|
||
)
|
||
|
||
# Inject password into the user attrset (template has no password for flake-init).
|
||
user_block = """ users.users.${username} = {
|
||
isNormalUser = true;
|
||
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
||
};"""
|
||
# HASHED_PASSWORD is sha-512 crypt; alphabet is safe in Nix double quotes.
|
||
hashed = nix_str(v["hashedPassword"])
|
||
user_patched = f""" users.users.${{username}} = {{
|
||
isNormalUser = true;
|
||
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
||
initialHashedPassword = "{hashed}";
|
||
}};"""
|
||
text = replace_once(text, user_block, user_patched, "user password")
|
||
|
||
if BEGIN not in text or END not in text:
|
||
sys.exit("patch-template: system.nix missing __NOMARCHY_INSTALLER__ markers")
|
||
region = build_installer_region(v)
|
||
text = re.sub(
|
||
re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?",
|
||
region,
|
||
text,
|
||
count=1,
|
||
flags=re.DOTALL,
|
||
)
|
||
return text
|
||
|
||
|
||
def patch_state(text: str, v: dict) -> str:
|
||
"""Seed menu-owned settings into theme-state.json.
|
||
|
||
These live in the state rather than system.nix precisely so the menu can
|
||
change them later: a baked Nix assignment would outrank the state default
|
||
and strand the toggle. Auto-login is on when the disk is encrypted — the
|
||
LUKS passphrase already gates the machine, so a greeter password is a
|
||
second prompt for the same thing; without LUKS it stays off, where the
|
||
greeter is the only thing standing between power-on and the desktop.
|
||
"""
|
||
state = json.loads(text)
|
||
settings = state.setdefault("settings", {})
|
||
if v.get("autoLogin"):
|
||
settings.setdefault("greeter", {})["autoLogin"] = v["username"]
|
||
return json.dumps(state, indent=2) + "\n"
|
||
|
||
|
||
def main() -> None:
|
||
if len(sys.argv) != 2:
|
||
sys.exit("usage: patch-template.py <flake-dir>")
|
||
flake_dir = Path(sys.argv[1])
|
||
vals = json.load(sys.stdin)
|
||
|
||
mapping = {
|
||
"flake.nix": patch_flake,
|
||
"home.nix": patch_home,
|
||
"system.nix": patch_system,
|
||
"theme-state.json": patch_state,
|
||
}
|
||
for name, fn in mapping.items():
|
||
path = flake_dir / name
|
||
path.write_text(fn(path.read_text(), vals))
|
||
print(f"patch-template: patched {', '.join(mapping)} in {flake_dir}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|