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:
@@ -71,6 +71,12 @@ iteration would otherwise rediscover.
|
|||||||
- `theme-state.json` is git-tracked inside an 86 MB flake tree, so every
|
- `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
|
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):
|
||||||
|
`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
|
## Design invariants
|
||||||
- **Waybar status is never color-only** (item 28 sweep, iteration #69):
|
- **Waybar status is never color-only** (item 28 sweep, iteration #69):
|
||||||
|
|||||||
26
lib.nix
26
lib.nix
@@ -5,7 +5,16 @@
|
|||||||
# in flake.nix remain the escape hatch for power users.
|
# in flake.nix remain the escape hatch for power users.
|
||||||
{ nixpkgs, home-manager, nixos-hardware, nomarchy }:
|
{ 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 =
|
mkFlake =
|
||||||
{ src # the downstream flake directory (./.)
|
{ src # the downstream flake directory (./.)
|
||||||
, username # login name; also the homeConfigurations attr
|
, username # login name; also the homeConfigurations attr
|
||||||
@@ -13,8 +22,6 @@
|
|||||||
, system ? "x86_64-linux"
|
, system ? "x86_64-linux"
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
inherit (nixpkgs) lib;
|
|
||||||
|
|
||||||
# One profile or several (the installer's autodetection emits a few
|
# One profile or several (the installer's autodetection emits a few
|
||||||
# common-* modules alongside the model-specific one).
|
# common-* modules alongside the model-specific one).
|
||||||
profileNames =
|
profileNames =
|
||||||
@@ -51,8 +58,17 @@
|
|||||||
# and the standalone HM desktop see the same package set.
|
# and the standalone HM desktop see the same package set.
|
||||||
config.allowUnfree = true;
|
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
|
in
|
||||||
{
|
builtins.seq _themeState {
|
||||||
# System layer — rebuilt rarely:
|
# System layer — rebuilt rarely:
|
||||||
# sudo nixos-rebuild switch --flake .#default
|
# sudo nixos-rebuild switch --flake .#default
|
||||||
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
|
||||||
@@ -67,7 +83,7 @@
|
|||||||
{ 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)
|
||||||
# read the same JSON the desktop does.
|
# read the same JSON the desktop does.
|
||||||
{ nomarchy.system.stateFile = src + "/theme-state.json"; }
|
{ nomarchy.system.stateFile = statePath; }
|
||||||
]
|
]
|
||||||
++ hardwareModules
|
++ hardwareModules
|
||||||
++ [
|
++ [
|
||||||
@@ -87,7 +103,7 @@
|
|||||||
{
|
{
|
||||||
# Written by nomarchy-theme-sync; reading it is pure — the
|
# Written by nomarchy-theme-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 = src + "/theme-state.json";
|
nomarchy.stateFile = statePath;
|
||||||
home = {
|
home = {
|
||||||
inherit username;
|
inherit username;
|
||||||
homeDirectory = "/home/${username}";
|
homeDirectory = "/home/${username}";
|
||||||
|
|||||||
@@ -18,7 +18,11 @@
|
|||||||
let
|
let
|
||||||
cfg = config.nomarchy;
|
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
|
# 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
|
||||||
@@ -64,6 +68,8 @@ let
|
|||||||
# rebuild. settings.monitors: per-output resolutions the Display menu
|
# rebuild. settings.monitors: per-output resolutions the Display menu
|
||||||
# remembers (output-name -> "WxH@R"), overlaid onto nomarchy.monitors by
|
# remembers (output-name -> "WxH@R"), overlaid onto nomarchy.monitors by
|
||||||
# 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 +
|
||||||
|
# auto-switch on plug events (hyprland.nix / Display menu).
|
||||||
# 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 = {
|
||||||
@@ -74,6 +80,8 @@ let
|
|||||||
# service, but the flag lives here so both sides read one source — the
|
# service, but the flag lives here so both sides read one source — the
|
||||||
# home side gates the Waybar-refresh watcher (timezone.nix) on it.
|
# home side gates the Waybar-refresh watcher (timezone.nix) on it.
|
||||||
autoTimezone = false;
|
autoTimezone = false;
|
||||||
|
displayProfile = "";
|
||||||
|
displayProfileAuto = false;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,11 +143,18 @@ let
|
|||||||
`nomarchy-theme-sync apply boreal`.'';
|
`nomarchy-theme-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 keys fall through to the raw string.
|
# already a literal hex. Unknown roles are rejected by the field checks
|
||||||
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v;
|
# 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 = {
|
border = {
|
||||||
active = resolveColor parsed.border.active;
|
active = resolveColor checked.border.active;
|
||||||
inactive = resolveColor parsed.border.inactive;
|
inactive = resolveColor checked.border.inactive;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Resolve the icon theme once and expose it on nomarchy.theme so both
|
# Resolve the icon theme once and expose it on nomarchy.theme so both
|
||||||
|
|||||||
@@ -16,10 +16,11 @@
|
|||||||
let
|
let
|
||||||
cfg = config.nomarchy.hardware;
|
cfg = config.nomarchy.hardware;
|
||||||
# Fingerprint PAM can follow theme-state.json (menu toggle → next
|
# 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 =
|
hwState =
|
||||||
if config.nomarchy.system.stateFile != null
|
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 { };
|
else { };
|
||||||
pamFromState = (hwState.settings or { }).fingerprint.pam or false;
|
pamFromState = (hwState.settings or { }).fingerprint.pam or false;
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let
|
|||||||
|
|
||||||
state =
|
state =
|
||||||
if cfg.stateFile != null
|
if cfg.stateFile != null
|
||||||
then builtins.fromJSON (builtins.readFile cfg.stateFile)
|
then import ../theme-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.
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ let
|
|||||||
cfg = config.nomarchy.system;
|
cfg = config.nomarchy.system;
|
||||||
|
|
||||||
# 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 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 =
|
state =
|
||||||
if cfg.stateFile != null
|
if cfg.stateFile != null
|
||||||
then builtins.fromJSON (builtins.readFile cfg.stateFile)
|
then import ../theme-state-read.nix { inherit lib; } cfg.stateFile
|
||||||
else { };
|
else { };
|
||||||
stateEnabled = (state.settings or { }).autoTimezone or false;
|
stateEnabled = (state.settings or { }).autoTimezone or false;
|
||||||
|
|
||||||
|
|||||||
84
modules/theme-state-read.nix
Normal file
84
modules/theme-state-read.nix
Normal 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
|
||||||
Reference in New Issue
Block a user