fix(theme): #66 fail-closed theme-state errors at mkFlake/read

Missing, empty, or non-object theme-state.json now throws a short
message pointing at the template and `nomarchy-theme-sync validate`
instead of a raw readFile/fromJSON stack. Shared reader:
modules/theme-state-read.nix — wired in theme.nix, plymouth/timezone/
hardware, and lib.mkFlake (seq-forced early gate). Field-level schema
in theme.nix kept; unknown border roles still fail there; resolveColor
no longer falls through on missing palette keys. Defaults added for
settings.displayProfile / displayProfileAuto.

Verified: V0 nix flake check --no-build; negative evals (missing,
empty, [], bad border role); good-path template mode + displayProfile
defaults.
This commit is contained in:
Bernardo Magri
2026-07-10 09:38:15 +01:00
parent bd6d94f973
commit a640de4fd4
7 changed files with 138 additions and 15 deletions

View File

@@ -18,7 +18,11 @@
let
cfg = config.nomarchy;
themeState = builtins.fromJSON (builtins.readFile cfg.stateFile);
# Fail-closed load: missing / empty / non-object get a short pointer at
# the template + `nomarchy-theme-sync validate`, not a raw readFile stack
# from deep inside a consumer (nightlight, hyprland, …). Field-level
# checks below still run on the merged result.
themeState = import ../theme-state-read.nix { inherit lib; } cfg.stateFile;
# Defaults guarantee evaluation succeeds on a sparse or older state
# file (e.g. one written before a schema field was added). The shipped
@@ -64,6 +68,8 @@ let
# rebuild. settings.monitors: per-output resolutions the Display menu
# remembers (output-name -> "WxH@R"), overlaid onto nomarchy.monitors by
# name in hyprland.nix — the monitor twin of the keyboard graduation.
# settings.displayProfile / displayProfileAuto: active named layout +
# auto-switch on plug events (hyprland.nix / Display menu).
# Defaulted so a sparse/older state file still evaluates; nomarchy.settings
# exposes them.
settings = {
@@ -74,6 +80,8 @@ let
# service, but the flag lives here so both sides read one source — the
# home side gates the Waybar-refresh watcher (timezone.nix) on it.
autoTimezone = false;
displayProfile = "";
displayProfileAuto = false;
};
};
@@ -135,11 +143,18 @@ let
`nomarchy-theme-sync apply boreal`.'';
# A border value is a palette key (look it up in colors) unless it's
# already a literal hex; unknown keys fall through to the raw string.
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v;
# already a literal hex. Unknown roles are rejected by the field checks
# above; resolve only runs on a validated state.
resolveColor = v:
if lib.hasPrefix "#" v then v
else parsed.colors.${v} or (throw ''
Nomarchy: border role "${v}" is not in the palette (colors.*).
Use one of: ${lib.concatStringsSep ", " colorRoles}
or a literal "#RRGGBB". Validate with: nomarchy-theme-sync validate'');
border = {
active = resolveColor parsed.border.active;
inactive = resolveColor parsed.border.inactive;
active = resolveColor checked.border.active;
inactive = resolveColor checked.border.inactive;
};
# Resolve the icon theme once and expose it on nomarchy.theme so both

View File

@@ -16,10 +16,11 @@
let
cfg = config.nomarchy.hardware;
# Fingerprint PAM can follow theme-state.json (menu toggle → next
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55).
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55). Missing or
# invalid JSON fails closed (theme-state-read.nix) instead of a raw stack.
hwState =
if config.nomarchy.system.stateFile != null
then builtins.fromJSON (builtins.readFile config.nomarchy.system.stateFile)
then import ../theme-state-read.nix { inherit lib; } config.nomarchy.system.stateFile
else { };
pamFromState = (hwState.settings or { }).fingerprint.pam or false;
in

View File

@@ -12,7 +12,7 @@ let
state =
if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile)
then import ../theme-state-read.nix { inherit lib; } cfg.stateFile
else { };
colorOf = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
# Fallbacks match the distro default theme (Boreal) when stateFile is null.

View File

@@ -15,10 +15,11 @@ let
cfg = config.nomarchy.system;
# Read the same state file the rest of the system side uses (Plymouth too),
# wired by lib.mkFlake. The flag defaults off when the file is absent/sparse.
# wired by lib.mkFlake. The flag defaults off when stateFile is null;
# a set-but-missing/invalid path fails closed via theme-state-read.nix.
state =
if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile)
then import ../theme-state-read.nix { inherit lib; } cfg.stateFile
else { };
stateEnabled = (state.settings or { }).autoTimezone or false;

View File

@@ -0,0 +1,84 @@
# Pure theme-state.json loader — fail closed with a short, actionable
# message instead of a raw `readFile` / `fromJSON` stack buried in a
# consumer. Used by modules/home/theme.nix (required path), the NixOS
# stateFile consumers, and lib.mkFlake (early gate).
#
# Field-level schema checks stay in theme.nix (post-defaults). This file
# only gates *existence* and *JSON-shape* so the first failure the user
# sees points at the state file, not at nightlight.nix.
{ lib }:
path:
let
pathStr = toString path;
tip = ''
Fix:
Missing file copy the template next to your flake.nix:
templates/downstream/theme-state.json
or regenerate from a preset:
nomarchy-theme-sync apply boreal
Bad syntax / wrong shape edit theme-state.json (trailing commas
are the usual culprit) or reset with `apply` as above.
Field-level schema (colors, ui, border, ):
nomarchy-theme-sync validate
Eval-time field checks live in modules/home/theme.nix.'';
missingMsg = ''
Nomarchy: theme-state.json is missing:
${pathStr}
This file is required (appearance + menu settings). Add it to your
flake checkout so evaluation stays pure.
${tip}'';
emptyMsg = ''
Nomarchy: theme-state.json is empty:
${pathStr}
${tip}'';
notObjectMsg = ''
Nomarchy: theme-state.json must be a JSON object `{ ... }`:
${pathStr}
${tip}'';
# lib.trim / lib.strings.trim — strip leading/trailing whitespace so an
# all-whitespace file counts as empty and a BOM-less `{` is recognized.
strip = s:
let
# Prefer lib.trim when present (nixpkgs ≥ 23.11); fall back so a
# very old pin still gates missing/non-object.
trim =
if lib ? trim then lib.trim
else if lib.strings ? trim then lib.strings.trim
else (x: x);
in trim s;
in
if !(builtins.pathExists path) then
throw missingMsg
else
let
raw = builtins.readFile path;
stripped = strip raw;
in
if stripped == "" then
throw emptyMsg
# Reject non-objects before fromJSON where we can (null / array / string
# literals). Subtle syntax errors still surface from fromJSON itself —
# those messages already include line/column; the path tip above is the
# part a raw stack used to bury.
else if builtins.match "[[:space:]]*\\{.*" stripped == null then
throw notObjectMsg
else
let
value = builtins.fromJSON raw;
in
if !(builtins.isAttrs value) then
throw notObjectMsg
else
value