Files
Nomarchy/modules/home/nightlight.nix
Bernardo Magri 05bab5576e
Some checks failed
Check / eval (push) Failing after 9m12s
fix: migration first-boot, fingerprint PAM, menus, mic LED
Post-migration annoyances from TuringMachine:

- Force fprintAuth from fingerprint.pam (NixOS defaults it on with fprintd)
- First-boot toast waits for swaync, retries, only marks after success
- Night light status/toggle handles masked units + hyprsunset/wlsunset
- VPN menu: define rofi_menu in nomarchy-vpn (was a silent no-op)
- Left arrow = ↩ Back in list menus; GTK menu contrast via Stylix
- Mic-mute LED udev + sync after wpctl; EasyEffects starts after tray
- Migration guide: HM before GUI login, fingerprint/browser/VPN notes

Verified: flake check --no-build; checks.first-boot; checks.hardware-toggles
(pam=false node). V3 session items pending after user rebuild.
2026-07-11 15:06:21 +01:00

122 lines
5.9 KiB
Nix

# Night light — a scheduled blue-light filter via hyprsunset (Hyprland's own
# gamma/temperature tool). Warm at night, identity (no shift) by day; the
# schedule (temperature/sunrise/sunset) is tuned via nomarchy.nightlight.* in
# home.nix and baked into the unit's time-based `profile`.
#
# Geo mode: when BOTH nomarchy.nightlight.latitude and .longitude are set,
# wlsunset replaces hyprsunset — it computes sunrise/sunset from the
# location itself (the fixed .sunrise/.sunset times are ignored). Same
# toggle script, same Waybar moon, same live-state ExecCondition gate;
# only the unit underneath changes.
#
# Off by default and opt-in. Two git-tracked flags in the state file, both
# menu-written (no ~/.local/state):
# settings.nightlight.installed — does the hyprsunset unit exist? Sticky; the
# option mkDefault-reads it, so the FIRST enable from the menu rebuilds (to
# create the unit) and an instant-off is never undone by a later rebuild.
# settings.nightlight.on — runtime on/off. Toggled INSTANTLY (write + systemctl,
# no rebuild); read by the unit's ExecCondition (should-start) at session
# start so the choice survives logout/reboot via the *live* state, not the
# eval-frozen store copy. A later rebuild bakes the same value (no divergence).
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy.nightlight;
s = config.nomarchy.settings.nightlight;
sync = lib.getExe config.nomarchy.package;
# Geo mode flips the backing unit; everything user-facing stays the same.
geo = cfg.latitude != null && cfg.longitude != null;
unit = if geo then "wlsunset.service" else "hyprsunset.service";
# Runtime-on default for when the `on` key hasn't been written yet (e.g. right
# after the first enable). Baked at eval; only used when the live key is absent.
onDefault = lib.boolToString s.on;
nomarchy-nightlight = pkgs.writeShellScriptBin "nomarchy-nightlight" ''
unit=${unit}
# Instant runtime on/off: write the in-flake state WITHOUT a rebuild.
write_on() { ${sync} --quiet set settings.nightlight.on "$1" --no-switch; }
# First enable: mark the feature installed and REBUILD to create the unit
# (the one rebuild we accept; every toggle after is instant).
install_feature() { ${sync} --quiet set settings.nightlight.installed true; }
# Read the LIVE working-tree on/off (~/.nomarchy via $NOMARCHY_PATH), not the
# store copy baked into this generation; fall back to the eval-time default
# when absent. Normalise Python's True/False bool rendering.
is_on() {
v=$(${sync} get settings.nightlight.on 2>/dev/null) || v=${onDefault}
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
}
# LoadState=loaded means a real unit (not the empty-file mask HM/packages
# leave behind for unused hyprsunset). `systemctl cat` alone succeeds on
# a masked unit and wrongly reported "installed" silent no-op start.
installed() {
[ "$(systemctl --user show -p LoadState --value "$unit" 2>/dev/null)" = loaded ]
}
active() { systemctl --user is-active --quiet "$unit" 2>/dev/null; }
start() {
systemctl --user daemon-reload 2>/dev/null || true
systemctl --user unmask "$unit" 2>/dev/null || true
systemctl --user start "$unit" 2>/dev/null || true
}
stop() { systemctl --user stop "$unit" 2>/dev/null || true; }
case "''${1:-toggle}" in
should-start) is_on ;; # ExecCondition gate (login/reboot)
status)
# Waybar (polls every 3s): moon while running; print nothing otherwise so
# the module self-hides enable / re-enable from Look & Feel.
# `active` alone is the truth for the moon; menu labels use the same.
active \
&& printf '{"text":"󰖔","tooltip":"Night light on ${if geo then "follows your location" else "warm on schedule"} (click to disable)","class":"on"}\n'
exit 0 ;;
# Plain on/off string for menus (hyprsunset OR wlsunset; not hard-coded).
is-active) if active; then echo on; else echo off; fi ;;
on)
if installed; then write_on true; start; else install_feature; start; fi ;;
off) write_on false; stop ;;
toggle)
if active; then
write_on false; stop # on -> off (instant)
elif installed; then
write_on true; start # installed, off -> on (instant)
else
install_feature; start # first enable (rebuilds)
fi ;;
*) echo "usage: nomarchy-nightlight [toggle|status|on|off|should-start|is-active]" >&2; exit 64 ;;
esac
'';
in
{
config = {
# Unit presence tracks the sticky `installed` flag the menu writes (first
# enable rebuilds). mkDefault so a hand-set nomarchy.nightlight.enable in
# home.nix also works as a declarative opt-in.
nomarchy.nightlight.enable = lib.mkDefault s.installed;
services.hyprsunset = lib.mkIf (cfg.enable && !geo) {
enable = true;
settings.profile = [
# Daytime: identity = no colour change.
{ time = cfg.sunrise; identity = true; }
# Night: shift to the warm temperature.
{ time = cfg.sunset; temperature = cfg.temperature; }
];
};
# Geo mode: wlsunset owns the schedule — it recomputes sunrise/sunset
# from the coordinates daily (no fixed profile to bake).
services.wlsunset = lib.mkIf (cfg.enable && geo) {
enable = true;
latitude = cfg.latitude;
longitude = cfg.longitude;
temperature.night = cfg.temperature;
};
# Gate the unit on the LIVE on/off state at start time (login/reboot), not
# at eval time — so a menu toggle (written without a rebuild) is honoured on
# the next session. A failed condition skips the unit (inactive, not failed).
systemd.user.services.${lib.removeSuffix ".service" unit}.Service.ExecCondition =
lib.mkIf cfg.enable "${nomarchy-nightlight}/bin/nomarchy-nightlight should-start";
home.packages = [ nomarchy-nightlight ];
};
}