From a640de4fd4adaf3863335e6b2ccf5977adc8e1b5 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Fri, 10 Jul 2026 09:38:15 +0100 Subject: [PATCH] fix(theme): #66 fail-closed theme-state errors at mkFlake/read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/MEMORY.md | 6 +++ lib.nix | 26 ++++++++--- modules/home/theme.nix | 25 ++++++++--- modules/nixos/hardware.nix | 5 ++- modules/nixos/plymouth.nix | 2 +- modules/nixos/timezone.nix | 5 ++- modules/theme-state-read.nix | 84 ++++++++++++++++++++++++++++++++++++ 7 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 modules/theme-state-read.nix diff --git a/agent/MEMORY.md b/agent/MEMORY.md index 7815595..e19a00c 100644 --- a/agent/MEMORY.md +++ b/agent/MEMORY.md @@ -71,6 +71,12 @@ iteration would otherwise rediscover. - `theme-state.json` is git-tracked inside an 86 MB flake tree, so every state write re-copies the source before eval — the wallpapers-artifact split (BACKLOG LATER) is the decided fix (§ Faster switches). +- **Friendly theme-state load** (`modules/theme-state-read.nix`, #66): + `builtins.tryEval` does **not** catch `readFile`/`fromJSON` failures — + gate with `pathExists` + empty/non-object checks before `fromJSON`. + Subtle JSON syntax errors still surface from nlohmann (line/col); + field schema stays in `theme.nix`. mkFlake must `builtins.seq` the + check onto the whole return set or lazy attr access skips it. ## Design invariants - **Waybar status is never color-only** (item 28 sweep, iteration #69): diff --git a/lib.nix b/lib.nix index ff9746c..ae87b3f 100644 --- a/lib.nix +++ b/lib.nix @@ -5,7 +5,16 @@ # in flake.nix remain the escape hatch for power users. { nixpkgs, home-manager, nixos-hardware, nomarchy }: +let + inherit (nixpkgs) lib; + + # Shared pure reader (modules/theme-state-read.nix) — re-exported so + # power users composing without mkFlake can call it too. + readThemeState = import ./modules/theme-state-read.nix { inherit lib; }; +in { + inherit readThemeState; + mkFlake = { src # the downstream flake directory (./.) , username # login name; also the homeConfigurations attr @@ -13,8 +22,6 @@ , system ? "x86_64-linux" }: let - inherit (nixpkgs) lib; - # One profile or several (the installer's autodetection emits a few # common-* modules alongside the model-specific one). profileNames = @@ -51,8 +58,17 @@ # and the standalone HM desktop see the same package set. config.allowUnfree = true; }; + + # Early fail-closed gate: missing/empty/non-object theme-state.json + # throws here with a template + validate pointer, before module + # evaluation buries a raw readFile/fromJSON stack. Field-level + # schema still runs in modules/home/theme.nix after defaults merge. + # Forced via seq on the whole return set — attrNames alone must not + # skip the check (Nix is lazy on unused let bindings and attr values). + statePath = src + "/theme-state.json"; + _themeState = readThemeState statePath; in - { + builtins.seq _themeState { # System layer — rebuilt rarely: # sudo nixos-rebuild switch --flake .#default nixosConfigurations.default = nixpkgs.lib.nixosSystem { @@ -67,7 +83,7 @@ { environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; } # System-side theme consumers (Plymouth splash background) # read the same JSON the desktop does. - { nomarchy.system.stateFile = src + "/theme-state.json"; } + { nomarchy.system.stateFile = statePath; } ] ++ hardwareModules ++ [ @@ -87,7 +103,7 @@ { # Written by nomarchy-theme-sync; reading it is pure — the # file is part of the downstream flake's source. - nomarchy.stateFile = src + "/theme-state.json"; + nomarchy.stateFile = statePath; home = { inherit username; homeDirectory = "/home/${username}"; diff --git a/modules/home/theme.nix b/modules/home/theme.nix index 35ee6d4..0a73ef2 100644 --- a/modules/home/theme.nix +++ b/modules/home/theme.nix @@ -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 diff --git a/modules/nixos/hardware.nix b/modules/nixos/hardware.nix index d5d9754..e001712 100644 --- a/modules/nixos/hardware.nix +++ b/modules/nixos/hardware.nix @@ -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 diff --git a/modules/nixos/plymouth.nix b/modules/nixos/plymouth.nix index 957b50b..eaddb4b 100644 --- a/modules/nixos/plymouth.nix +++ b/modules/nixos/plymouth.nix @@ -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. diff --git a/modules/nixos/timezone.nix b/modules/nixos/timezone.nix index 5aa8d6c..2790672 100644 --- a/modules/nixos/timezone.nix +++ b/modules/nixos/timezone.nix @@ -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; diff --git a/modules/theme-state-read.nix b/modules/theme-state-read.nix new file mode 100644 index 0000000..0ab8fd9 --- /dev/null +++ b/modules/theme-state-read.nix @@ -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