feat(timezone): opt-in automatic, location-following timezone

nomarchy.system.autoTimezone (off by default): geoclue + automatic-
timezoned drive /etc/localtime from your location, so travelling to
another zone updates the Waybar clock on its own.

Menu-driven, in-flake state (the night-light / keyboard philosophy):
the flag is settings.autoTimezone in theme-state.json (git-tracked); a
System-menu entry toggles it. Because it's a SYSTEM service — not a user
unit it can start/stop instantly like night-light — the toggle
(nomarchy-autotimezone, run in a terminal) writes the flag --no-switch
then drives `sudo nixos-rebuild` (bakes the service + the time.timeZone
override) plus `home-manager switch` (the Waybar-refresh watcher); both
sides read the one flag.

- modules/nixos/timezone.nix: parses settings.autoTimezone from
  nomarchy.system.stateFile (like Plymouth), gates geoclue2 +
  automatic-timezoned, and mkForce-nulls time.timeZone — automatic-
  timezoned sets it null at normal priority, which collides with the
  installer's static value (a hard eval error), so force wins and
  reverts cleanly when disabled. Ships the nomarchy-autotimezone toggle.
- modules/home/timezone.nix: a tiny user service watching timedate1's
  change signal that reloads Waybar (SIGUSR2) on a real zone change, so
  the clock follows live (Waybar captures the zone at construction).
  Gated on the same flag via nomarchy.settings.autoTimezone.
- rofi menu: "Auto timezone (on/off)" in the System submenu.
- option + template example + ROADMAP entry; theme.nix seeds the flag.

Eval-verified on (geoclue+daemon on, tz forced null over a static
installer value, watcher present) and off (static tz intact, nothing
extra); home + system both build green. Needs an on-hardware check
(geoclue detection + the SIGUSR2 clock refresh aren't CI-testable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-06-23 10:34:45 +01:00
parent aed41793f8
commit f3325385c1
9 changed files with 190 additions and 2 deletions

View File

@@ -492,6 +492,30 @@ how to override it. Items marked ✓ are shipped.
sunset/sunrise (would mean wlsunset, which schedules by location). Pending an
on-machine check (first enable rebuilds + comes on; later toggles instant; off
persists across reboot via ExecCondition; stopping hyprsunset restores gamma).
-**Automatic timezone (location-following clock):** `nomarchy.system.autoTimezone`
(opt-in, off by default) — geoclue + `services.automatic-timezoned` drive
`/etc/localtime` from your location, so travelling to another zone updates the
Waybar clock on its own. **Menu-driven, in-flake state** (the night-light /
keyboard philosophy): the flag is `settings.autoTimezone` in theme-state.json,
git-tracked; the System-menu entry (`nomarchy-menu autotimezone`
`nomarchy-autotimezone`) writes it and rebuilds. **Not instant like
night-light** — it's a *system* service (not a user unit you can start/stop),
so the toggle drives a `sudo nixos-rebuild` (bakes the service + the
`time.timeZone` override) plus a `home-manager switch` (the Waybar-refresh
watcher), both off the one flag. **time.timeZone handling:** a runtime zone
needs `/etc/localtime` writable; automatic-timezoned sets `time.timeZone = null`
itself, but the installer's static value would collide (a hard eval error), so
the module forces it null (`mkForce`) over the installer's value when enabled,
and reverts to it when disabled. **Live clock refresh:** Waybar's clock module
captures the zone at construction, so `modules/home/timezone.nix` runs a tiny
user service that watches timedate1's change signal and reloads Waybar
(SIGUSR2) on a real zone change (also catches a manual `timedatectl
set-timezone`). Eval-verified both ways (on: geoclue+daemon on, tz forced null
over a static installer value, watcher present; off: static tz intact, no
service/watcher). **Pending an on-hardware check** — geoclue detection and the
SIGUSR2 clock refresh aren't testable in CI (if SIGUSR2 proves insufficient,
fall back to restarting waybar). Remaining (optional): a Waybar tooltip line
showing the detected zone.
- **Keyboard layouts (per-device + switching):**
-**Per-device declarative layout:** `nomarchy.keyboard.devices`
(`{ "<hyprctl-device-name>" = { layout; variant; }; }`) generates Hyprland

View File

@@ -18,6 +18,7 @@
./yazi.nix # flagship TUI file manager, themed + plugins
./osd.nix # swayosd volume/brightness OSD, themed
./nightlight.nix # scheduled blue-light filter (hyprsunset), opt-in
./timezone.nix # keep the Waybar clock in step with auto-timezone changes
./updates.nix # passive update-awareness indicator + notification, opt-in
./shell.nix # zsh + starship + bat/eza/zoxide, themed
./keys.nix # gpg-agent (fronts SSH) + pinentry-qt

View File

@@ -324,6 +324,14 @@ ${themeRows}
&& exec nomarchy-nightlight toggle
notify-send "Night light" "Not available (nomarchy.nightlight off?)."; exit 0 ;;
autotimezone)
# Toggle automatic timezone detection (geoclue + automatic-timezoned).
# A SYSTEM service, so flipping it writes the in-flake flag and runs a
# system rebuild (sudo) + a home switch in a terminal, like Snapshots.
command -v nomarchy-autotimezone >/dev/null 2>&1 \
|| { notify-send "Auto timezone" "Unavailable on this machine."; exit 0; }
exec ${cfg.terminal} -e nomarchy-autotimezone toggle ;;
snapshot)
# btrfs-assistant 2.2 segfaults on 26.05 (libbtrfsutil ABI), so launch
# the keyboard-driven snapper browser instead: a terminal running it
@@ -374,6 +382,10 @@ ${themeRows}
then row "Night light (on)" weather-clear-night
else row "Night light (off)" weather-clear-night
fi
if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ]
then row "Auto timezone (on)" preferences-system-time
else row "Auto timezone (off)" preferences-system-time
fi
command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift
if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then
row "Power profile" preferences-system-power
@@ -388,6 +400,7 @@ ${themeRows}
*Printers*) exec "$0" printers ;;
*"Do Not Disturb"*) exec "$0" dnd ;;
*"Night light"*) exec "$0" nightlight ;;
*"Auto timezone"*) exec "$0" autotimezone ;;
*Snapshots*) exec "$0" snapshot ;;
*"Power profile"*) exec "$0" power-profile ;;
esac ;;
@@ -414,7 +427,7 @@ ${themeRows}
esac ;;
*)
echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|printers|capture|keybinds|ask|dnd|nightlight|snapshot]" >&2
echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|printers|capture|keybinds|ask|dnd|nightlight|autotimezone|snapshot]" >&2
exit 64 ;;
esac
'';

View File

@@ -66,6 +66,10 @@ let
settings = {
nightlight = { installed = false; on = true; };
keyboard.devices = { };
# Automatic timezone detection (nomarchy.system.autoTimezone): a system
# 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;
};
};

44
modules/home/timezone.nix Normal file
View File

@@ -0,0 +1,44 @@
# Auto-timezone, home side: keep the Waybar clock in step with the system
# timezone. Waybar's clock module captures the zone once at construction, so a
# runtime timezone change (automatic-timezoned, nomarchy.system.autoTimezone)
# would NOT show until a relogin. A tiny watcher subscribes to timedate1's
# change signal and reloads Waybar (SIGUSR2 = the same reload theme-sync uses),
# so the clock follows your location live. Also catches a manual
# `timedatectl set-timezone`.
#
# Gated on the same in-flake flag the system side reads (settings.autoTimezone,
# exposed via nomarchy.settings) — so it only runs when the feature is on. The
# menu toggle (nomarchy-autotimezone) rebuilds both sides off that one flag.
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy;
enabled = cfg.waybar.enable && (cfg.settings.autoTimezone or false);
tzWatch = pkgs.writeShellScript "nomarchy-tz-watch" ''
last=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
${pkgs.dbus}/bin/dbus-monitor --system \
"type='signal',interface='org.freedesktop.DBus.Properties',path='/org/freedesktop/timedate1',member='PropertiesChanged'" \
2>/dev/null |
while read -r _; do
cur=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
[ "$cur" = "$last" ] && continue
last=$cur
${pkgs.procps}/bin/pkill -SIGUSR2 -x waybar 2>/dev/null || true
done
'';
in
{
systemd.user.services.nomarchy-tz-watch = lib.mkIf enabled {
Unit = {
Description = "Reload Waybar on timezone change (auto-timezone)";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${tzWatch}";
Restart = "on-failure";
};
Install.WantedBy = [ "graphical-session.target" ];
};
}

View File

@@ -22,7 +22,7 @@ let
'';
in
{
imports = [ ./options.nix ./plymouth.nix ./file-manager.nix ./power.nix ./services.nix ./hardware.nix ];
imports = [ ./options.nix ./plymouth.nix ./file-manager.nix ./power.nix ./services.nix ./hardware.nix ./timezone.nix ];
config = {
# The safe half of distro branding: distroName flows into

View File

@@ -46,6 +46,16 @@
audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; };
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = true; };
autoTimezone.enable = lib.mkEnableOption ''
automatic timezone detection (geoclue + automatic-timezoned): the
system timezone and so the Waybar clock follows your location, so
travelling to another zone updates the time on its own. Off by default
(it's a location service and needs the network); toggle it from the
System menu, which lands the choice in the in-flake state file. Enabling
it unsets the static time.timeZone for you (a runtime timezone needs
/etc/localtime writable), so the menu toggle drives a system rebuild''
// { default = false; };
snapper.enable = lib.mkEnableOption ''
hourly/daily BTRFS timeline snapshots of / via snapper, plus the
`nixos-rebuild-snap` pre-rebuild-snapshot helper. No-op unless the

View File

@@ -0,0 +1,87 @@
# Automatic timezone detection (opt-in) — the system timezone, and so the
# Waybar clock, follows your location, so travelling to another zone updates
# the time on its own. Geoclue feeds `automatic-timezoned`, which drives
# /etc/localtime at runtime.
#
# In-flake state, menu-driven (the keyboard/night-light philosophy): the on/off
# flag lives in the same theme-state.json under `settings.autoTimezone`
# (git-tracked, reproducible), written by the System-menu toggle
# (nomarchy-autotimezone). Because this is a SYSTEM service — not a user unit it
# can start/stop instantly like night-light — the toggle drives a system rebuild
# (plus a home switch for the Waybar-refresh watcher in timezone.nix home-side).
{ config, lib, pkgs, ... }:
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.
state =
if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile)
else { };
stateEnabled = (state.settings or { }).autoTimezone or false;
sync = lib.getExe pkgs.nomarchy-theme-sync;
# Menu/CLI toggle. Runs as the normal user (it owns the flake checkout +
# writes the state); sudos only the system switch, like sys-update. Writes
# the in-flake flag, then rebuilds: the system rebuild bakes the service +
# the time.timeZone override, the home switch installs/removes the Waybar
# refresh watcher — both read the same flag we just wrote.
nomarchy-autotimezone = pkgs.writeShellScriptBin "nomarchy-autotimezone" ''
set -e
if [ "$(id -u)" -eq 0 ]; then
echo "nomarchy-autotimezone: run as your normal user (it sudos the rebuild itself)" >&2
exit 1
fi
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
cur=$(${sync} get settings.autoTimezone 2>/dev/null) || cur=false
case "''${1:-toggle}" in
on) new=true ;;
off) new=false ;;
toggle) case "$cur" in true|True) new=false ;; *) new=true ;; esac ;;
status) echo "$cur"; exit 0 ;;
*) echo "usage: nomarchy-autotimezone [toggle|on|off|status]" >&2; exit 64 ;;
esac
${sync} --quiet set settings.autoTimezone "$new" --no-switch
if [ "$new" = true ]; then
notify-send "Auto timezone" "Enabling rebuilding the system" 2>/dev/null || true
else
notify-send "Auto timezone" "Disabling rebuilding the system" 2>/dev/null || true
fi
sudo nixos-rebuild switch --flake "$flake#default"
home-manager switch --flake "$flake"
if [ "$new" = true ]; then
notify-send "Auto timezone on" "The clock now follows your location." 2>/dev/null || true
else
notify-send "Auto timezone off" "Back to the fixed timezone in system.nix." 2>/dev/null || true
fi
'';
in
{
config = lib.mkMerge [
{
# Shipped unconditionally so the menu can enable the feature even while
# it's off. Track the in-flake flag; mkDefault so a hand-set
# nomarchy.system.autoTimezone.enable in system.nix still wins.
environment.systemPackages = [ nomarchy-autotimezone ];
nomarchy.system.autoTimezone.enable = lib.mkDefault stateEnabled;
}
(lib.mkIf cfg.autoTimezone.enable {
services.geoclue2.enable = true;
services.automatic-timezoned.enable = true;
# A runtime timezone needs /etc/localtime writable. automatic-timezoned
# sets time.timeZone = null itself, but the installer writes a static
# value at normal priority, which would collide (a hard eval error) —
# mkForce null overrides both and resolves cleanly.
time.timeZone = lib.mkForce null;
})
];
}

View File

@@ -31,6 +31,11 @@
# thermal.enable = true; # thermald (Intel CPUs)
# };
#
# nomarchy.system.autoTimezone.enable = true; # clock follows your location
# # (geoclue); travelling updates the
# # time on its own. Unsets time.timeZone
# # above. Easier toggled from System menu.
#
# nomarchy.hardware = { # enablement above nixos-hardware (installer-detected)
# intel.enable = true; # GuC/HuC firmware; installer sets this on Intel
# intel.computeRuntime = true; # OpenCL/oneVPL GPU compute (opt-in)