Files
Nomarchy/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
Bernardo Magri 18b854563b fix(waybar): supervisor respawn + clean-restart switches; add sys-rebuild
Recovers the crashed iteration #7 (items 21+23, Latitude QA findings):

- nomarchy-waybar supervisor (waybar.nix, exec-once'd from
  hyprland.nix): any waybar exit respawns the bar; crash-loop guard
  (5 fast exits -> critical notify + stop); TERM trapped for a real
  stop. Fixes the bar-less session after a theme-switch crash.
- nomarchy-theme-sync prefers a clean `pkill -x waybar` (supervisor
  restart with fresh config+style) over the crash-prone in-place
  SIGUSR2 reload when the supervisor is running; SIGUSR2 stays as the
  unsupervised fallback.
- sys-rebuild: snapshot-first system rebuild against the CURRENT lock
  (no `nix flake update`) — the no-update twin of sys-update;
  README §3/§5 + motd list it.

Verified: V0 re-run green after crash recovery (flake check --no-build
+ py_compile); V1/V2 per the crashed session's journal entry (HM
renders exec-once=nomarchy-waybar; headless software-GL VM:
SIGKILL -> new pid, clean kill -> respawn, SIGUSR2 -> alive).
V3 pending on the Latitude (queued in HARDWARE-QUEUE.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:07:05 +01:00

438 lines
18 KiB
Python

#!/usr/bin/env python3
"""nomarchy-theme-sync — state writer for Nomarchy's declarative theming.
Single source of truth: $NOMARCHY_PATH/theme-state.json (inside the flake,
read purely by Home Manager via the nomarchy.stateFile option).
Theme changes are applied by Home Manager: this tool writes the new state
and runs `home-manager switch` (override with $NOMARCHY_REBUILD, or skip
with --no-switch). All app theming — Hyprland, Waybar, Ghostty, btop,
Stylix — is baked into the generation; nothing is patched at runtime.
The one runtime exception is the wallpaper: swww is imperative by nature,
so `bg next` cycles instantly and `wallpaper` (re-)applies the current one
at session start and after a switch.
Commands:
list list theme presets
apply <name|file.json> merge a preset into the state + rebuild
set <dotted.path> <value> tweak one key (e.g. `set ui.gapsOut 16`) + rebuild
get [dotted.path] print the current state (or one key)
wallpaper apply the current wallpaper via swww
bg [next|auto] cycle the theme's wallpapers (instant, no rebuild)
"""
import argparse
import json
import os
import shlex
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
# ─── Paths ────────────────────────────────────────────────────────────────
FLAKE_DIR = Path(os.environ.get("NOMARCHY_PATH", Path.home() / ".nomarchy")).expanduser()
STATE_FILE = FLAKE_DIR / "theme-state.json"
# Preset search path: the user's flake first (their custom themes win),
# then the presets baked into this package by the Nix build.
THEMES_DIRS = [FLAKE_DIR / "themes"]
if os.environ.get("NOMARCHY_DEFAULT_THEMES"):
THEMES_DIRS.append(Path(os.environ["NOMARCHY_DEFAULT_THEMES"]))
WALLPAPER_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
QUIET = False
def log(msg: str) -> None:
if not QUIET:
print(f"nomarchy-theme-sync: {msg}")
def die(msg: str) -> "None":
print(f"nomarchy-theme-sync: error: {msg}", file=sys.stderr)
sys.exit(1)
# All Nomarchy theme notifications share this synchronous tag, so the
# notification daemon (swaync) REPLACES the previous one in place instead
# of stacking — the "rebuilding…" toast becomes the "applied ✓" toast.
NOTIFY_SYNC_TAG = "nomarchy-theme-sync"
def notify(body: str, summary: str = "Nomarchy", *,
persistent: bool = False, urgency: str = "normal") -> None:
"""Desktop notification. persistent (timeout 0) keeps it up for the
whole rebuild so a slow switch never reads as a failed selection;
the success/failure call replaces it via the synchronous tag."""
if shutil.which("notify-send") is None:
return
subprocess.run(
["notify-send", "-a", "Nomarchy",
"-u", urgency,
"-t", "0" if persistent else "5000",
"-h", f"string:x-canonical-private-synchronous:{NOTIFY_SYNC_TAG}",
summary, body],
capture_output=True,
)
# ─── State management ─────────────────────────────────────────────────────
def load_state(path: Path = STATE_FILE) -> dict:
try:
return json.loads(path.read_text())
except FileNotFoundError:
die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)")
except json.JSONDecodeError as e:
die(f"invalid JSON in {path}: {e}")
def write_state(state: dict) -> None:
"""Atomic write: render to a temp file in the same dir, then rename."""
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".theme-state.", suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(state, f, indent=2)
f.write("\n")
os.replace(tmp, STATE_FILE)
except BaseException:
os.unlink(tmp)
raise
log(f"state written to {STATE_FILE}")
# Flakes only see git-tracked files. theme-state.json ships tracked,
# but make sure a fresh checkout / reset can't silently hide it.
if (FLAKE_DIR / ".git").exists() and shutil.which("git"):
subprocess.run(
["git", "-C", str(FLAKE_DIR), "add", "--intent-to-add", "theme-state.json"],
capture_output=True,
)
def auto_commit_enabled(state: dict) -> bool:
return bool((state.get("settings") or {}).get("autoCommit"))
def auto_commit(message: str) -> None:
"""Opt-in (settings.autoCommit): commit theme-state.json — and nothing
else — after a mutation, so settings history is `git log`. The pathspec
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
fire this when the flag is on before OR after the write, so the
disable-toggle itself lands in history instead of staying dirty.
`bg` is deliberately excluded (runtime wallpaper churn); the wallpaper
path rides along with the next apply/set commit."""
if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None:
return
git = ["git", "-C", str(FLAKE_DIR)]
# 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.
if subprocess.run(git + ["diff", "--quiet", "HEAD", "--", "theme-state.json"],
capture_output=True).returncode == 0:
return
if not subprocess.run(git + ["config", "user.email"],
capture_output=True, text=True).stdout.strip():
git += ["-c", "user.name=Nomarchy", "-c", "user.email=nomarchy@localhost"]
result = subprocess.run(
git + ["commit", "--quiet", "-m", message, "--", "theme-state.json"],
capture_output=True, text=True)
if result.returncode == 0:
log(f"auto-committed: {message}")
else:
log(f"auto-commit skipped: {(result.stderr or result.stdout).strip()}")
def deep_merge(base: dict, override: dict) -> dict:
out = dict(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = deep_merge(out[k], v)
else:
out[k] = v
return out
# ─── Font verification ────────────────────────────────────────────────────
def check_fonts(state: dict) -> None:
"""Warn when a configured font family isn't installed. fontconfig
substitutes silently, so a typo'd or missing fonts.mono would just
render the whole desktop in some fallback with no error anywhere."""
if shutil.which("fc-match") is None:
return
for key in ("mono", "ui"):
name = (state.get("fonts") or {}).get(key)
if not name:
continue
result = subprocess.run(["fc-match", "-f", "%{family}", name],
capture_output=True, text=True)
families = [f.strip().lower() for f in result.stdout.split(",")]
if result.returncode != 0 or name.strip().lower() not in families:
fallback = result.stdout.strip() or "unknown"
print(f"nomarchy-theme-sync: warning: fonts.{key} '{name}' is not "
f"installed — fontconfig will substitute '{fallback}'",
file=sys.stderr)
notify(f"Font '{name}' is not installed — using '{fallback}'")
# ─── Rebuild dispatch ─────────────────────────────────────────────────────
def run_switch() -> None:
"""Bake the new state into a Home Manager generation."""
override = os.environ.get("NOMARCHY_REBUILD")
argv = shlex.split(override) if override else \
["home-manager", "switch", "--flake", str(FLAKE_DIR)]
if shutil.which(argv[0]) is None:
log(f"'{argv[0]}' not found — state written; rebuild manually to apply")
return
log(f"rebuilding: {' '.join(argv)}")
# Persistent (timeout 0): stays up for the whole rebuild — replaced
# in place by the success/failure notification below — so a multi-
# minute switch never looks like it silently failed.
notify("Applying changes — rebuilding the desktop…", persistent=True)
result = subprocess.run(argv) # stream output to the caller's terminal
if result.returncode != 0:
notify("Rebuild FAILED — see terminal / journal", urgency="critical")
die("rebuild failed (state file already updated; fix and re-run)")
notify("Changes applied ✓")
# Waybar runs from Hyprland's exec-once (not a systemd unit HM would
# restart on switch), so nudge the running bar onto the freshly rebuilt
# config/style. Under the nomarchy-waybar supervisor a plain kill IS a
# clean restart with the new files — waybar's in-place SIGUSR2 reload
# is what crashed the bar on hardware (double-reload race with
# reload_style_on_change while the symlinks flip), so prefer the
# restart whenever the supervisor is there to catch it; fall back to
# SIGUSR2 for unsupervised/custom bars. No-ops if nothing is running.
if shutil.which("pkill"):
supervised = subprocess.run(
["pgrep", "-f", "nomarchy-waybar"], capture_output=True
).returncode == 0
if supervised:
subprocess.run(["pkill", "-x", "waybar"], check=False)
else:
subprocess.run(["pkill", "--signal", "SIGUSR2", "-x", "waybar"], check=False)
# ─── Theme assets (wallpapers) ────────────────────────────────────────────
# Convention: a preset themes/<slug>.json may have a sibling directory
# themes/<slug>/ with assets: backgrounds/ (wallpapers), btop.theme,
# waybar.css / waybar.jsonc. All except backgrounds/ are consumed by the
# Nix modules at eval time; wallpapers are applied here via swww.
def find_asset(slug: str, name: str):
for themes_dir in THEMES_DIRS:
candidate = themes_dir / slug / name
if candidate.exists():
return candidate
return None
def backgrounds_for(slug: str) -> list:
bg_dir = find_asset(slug, "backgrounds")
if bg_dir is None or not bg_dir.is_dir():
return []
return sorted(p for p in bg_dir.iterdir() if p.suffix.lower() in WALLPAPER_EXTS)
def resolve_wallpaper(state: dict):
"""Explicit path in the state wins; empty means 'first theme background'."""
explicit = state.get("wallpaper", "")
if explicit:
path = Path(explicit).expanduser()
if path.is_file():
return path
log(f"wallpaper not found, using theme default: {explicit}")
backgrounds = backgrounds_for(state.get("slug", ""))
return backgrounds[0] if backgrounds else None
def apply_wallpaper(state: dict, wait: bool = False) -> None:
wallpaper = resolve_wallpaper(state)
if wallpaper is None:
log(f"no wallpaper for theme '{state.get('slug', '?')}', skipping")
return
# nixpkgs renamed swww to awww (CLI-compatible fork); accept either.
swww = shutil.which("awww") or shutil.which("swww")
if swww is None:
log("awww/swww not found, skipping wallpaper")
return
# At session start the daemon may still be coming up.
for _ in range(10 if wait else 1):
if subprocess.run([swww, "query"], capture_output=True).returncode == 0:
break
time.sleep(0.5)
result = subprocess.run(
[swww, "img", str(wallpaper),
"--transition-type", "grow",
"--transition-pos", "center",
"--transition-duration", "1",
"--transition-fps", "60"],
capture_output=True,
)
log(f"wallpaper: {wallpaper.name}" if result.returncode == 0
else "wallpaper: awww failed (daemon not running?)")
# ─── Commands ─────────────────────────────────────────────────────────────
def find_preset(name: str):
for themes_dir in THEMES_DIRS:
candidate = themes_dir / f"{name}.json"
if candidate.is_file():
return candidate
return None
def cmd_list(_args) -> None:
names = sorted({f.stem for d in THEMES_DIRS if d.is_dir() for f in d.glob("*.json")})
if not names:
die(f"no theme presets found (searched: {', '.join(map(str, THEMES_DIRS))})")
print("\n".join(names))
def cmd_apply(args) -> None:
candidate = Path(args.theme).expanduser()
preset_path = candidate if candidate.is_file() else find_preset(args.theme)
if preset_path is None:
die(f"unknown theme '{args.theme}' (try `nomarchy-theme-sync list`)")
preset = json.loads(preset_path.read_text())
# Merge over current state: presets define palette/name/wallpaper,
# user tweaks (gaps, fonts) outside the preset survive.
old = load_state()
state = deep_merge(old, preset)
write_state(state)
if auto_commit_enabled(old) or auto_commit_enabled(state):
auto_commit(f"nomarchy: apply theme {state.get('name', args.theme)}")
log(f"theme: {state.get('name', args.theme)}")
check_fonts(state)
if not args.no_switch:
run_switch()
apply_wallpaper(state)
def cmd_set(args) -> None:
state = load_state()
try:
value = json.loads(args.value) # numbers, bools, null, quoted strings
except json.JSONDecodeError:
value = args.value # bare string ("#7aa2f7", font names, paths)
was_on = auto_commit_enabled(state)
node = state
keys = args.path.split(".")
for key in keys[:-1]:
node = node.setdefault(key, {})
if not isinstance(node, dict):
die(f"cannot descend into non-object at '{key}' in '{args.path}'")
node[keys[-1]] = value
write_state(state)
if was_on or auto_commit_enabled(state):
auto_commit(f"nomarchy: set {args.path} = {json.dumps(value)}")
log(f"set {args.path} = {value!r}")
if args.path.startswith("fonts."):
check_fonts(state)
if not args.no_switch:
run_switch()
# Only wallpaper/theme keys change what swww shows; skip the re-apply
# (and its transition) for unrelated keys like ui.* or settings.* so a
# gaps tweak or a feature toggle doesn't flash the background.
if args.path.split(".")[0] in ("wallpaper", "slug"):
apply_wallpaper(state)
def cmd_get(args) -> None:
state = load_state()
if args.path:
node = state
for key in args.path.split("."):
try:
node = node[key]
except (KeyError, TypeError):
die(f"no such key: {args.path}")
# Booleans print JSON-style (true/false, not Python's True/False) so
# shell consumers can compare against the same literal they `set`.
print(json.dumps(node, indent=2)
if isinstance(node, (dict, list, bool)) else node)
else:
print(json.dumps(state, indent=2))
def cmd_wallpaper(_args) -> None:
"""Apply the current wallpaper (session start, post-switch hook)."""
apply_wallpaper(load_state(), wait=True)
def cmd_bg(args) -> None:
"""Cycle the theme's wallpapers — instant, no rebuild needed (the
wallpaper is runtime state for swww; nothing in Nix consumes it)."""
state = load_state()
if args.action == "auto":
state["wallpaper"] = ""
else: # next
backgrounds = backgrounds_for(state.get("slug", ""))
if not backgrounds:
die(f"theme '{state.get('slug', '?')}' has no backgrounds/ directory")
current = resolve_wallpaper(state)
idx = (backgrounds.index(current) + 1) % len(backgrounds) if current in backgrounds else 0
state["wallpaper"] = str(backgrounds[idx])
write_state(state)
apply_wallpaper(state)
# ─── Entry point ──────────────────────────────────────────────────────────
def main() -> None:
global QUIET
parser = argparse.ArgumentParser(
prog="nomarchy-theme-sync",
description="Nomarchy theming — state writer + Home Manager rebuild dispatcher.",
)
parser.add_argument("--quiet", action="store_true", help="suppress progress output")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list", help="list theme presets").set_defaults(func=cmd_list)
p = sub.add_parser("apply", help="apply a theme preset (name or JSON file) + rebuild")
p.add_argument("theme")
p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild")
p.set_defaults(func=cmd_apply)
p = sub.add_parser("set", help="set one key, e.g. `set ui.gapsOut 16` + rebuild")
p.add_argument("path")
p.add_argument("value")
p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild")
p.set_defaults(func=cmd_set)
p = sub.add_parser("get", help="print state (or one dotted key)")
p.add_argument("path", nargs="?")
p.set_defaults(func=cmd_get)
sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper)
p = sub.add_parser("bg", help="wallpaper control: `bg next` cycles, `bg auto` resets")
p.add_argument("action", choices=["next", "auto"], default="next", nargs="?")
p.set_defaults(func=cmd_bg)
args = parser.parse_args()
QUIET = args.quiet
try:
args.func(args)
except KeyboardInterrupt:
sys.exit(130)
if __name__ == "__main__":
main()