From 8be372e0762e7a00f6e4491f3187ae046738320f Mon Sep 17 00:00:00 2001 From: "RingOfStorms (Joshua Bell)" Date: Tue, 13 Jan 2026 14:08:42 -0600 Subject: [PATCH] services.automatic-timezoned: Add persistDir and timezone restore --- flakes/common/nix_modules/timezone_auto.nix | 239 ++++++++++++++++---- flakes/de_plasma/de_plasma.nix | 10 - flakes/de_plasma/home_manager/default.nix | 6 +- hosts/juni/flake.lock | 36 +-- hosts/juni/flake.nix | 13 +- hosts/juni/impermanence.nix | 2 - 6 files changed, 215 insertions(+), 91 deletions(-) diff --git a/flakes/common/nix_modules/timezone_auto.nix b/flakes/common/nix_modules/timezone_auto.nix index 3d2ee7f7..dac85767 100644 --- a/flakes/common/nix_modules/timezone_auto.nix +++ b/flakes/common/nix_modules/timezone_auto.nix @@ -1,64 +1,207 @@ -{ lib, pkgs, ... }: +{ config, lib, pkgs, ... }: +let + cfg = config.services.automatic-timezoned; + persistFile = if cfg.persistDir == null then null else "${cfg.persistDir}/timezone"; + tzdata = pkgs.tzdata; +in { - services.dbus.enable = lib.mkDefault true; - services.geoclue2.enable = true; + options.services.automatic-timezoned.persistDir = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Absolute runtime directory used to persist the timezone for impermanence setups. - time.timeZone = null; - services.automatic-timezoned.enable = true; + Important: this must be a normal filesystem path (a string like + "/persist/var/lib/timezone-persist"), not a Nix `path` value, otherwise it + can be coerced into a `/nix/store/...` path and become unwritable at runtime. - systemd.services.automatic-timezoned = { - after = [ "dbus.socket" "systemd-timedated.service" "geoclue.service" ]; - wants = [ "dbus.socket" "systemd-timedated.service" "geoclue.service" ]; - serviceConfig = { - ExecStartPre = "${lib.getExe' pkgs.coreutils "sleep"} 5"; - Restart = "on-failure"; - RestartSec = "10s"; - }; + When set, the timezone is saved to this directory and restored on boot, + allowing offline boots to use the last known timezone. + Set to null to disable persistence (default). + ''; }; - systemd.services.automatic-timezoned-geoclue-agent = { - after = [ "dbus.socket" ]; - wants = [ "dbus.socket" ]; - }; + config = { + assertions = [ + { + assertion = cfg.persistDir == null || lib.hasPrefix "/" cfg.persistDir; + message = "services.automatic-timezoned.persistDir must be an absolute path"; + } + ]; - systemd.services.fix-localtime-symlink = { - description = "Fix /etc/localtime symlink to be absolute"; - wantedBy = [ "multi-user.target" ]; - after = [ "automatic-timezoned.service" ]; - wants = [ "automatic-timezoned.service" ]; + services.dbus.enable = lib.mkDefault true; + services.geoclue2.enable = true; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - ExecStart = pkgs.writeShellScript "fix-localtime-symlink" '' - target=$(${pkgs.coreutils}/bin/readlink /etc/localtime 2>/dev/null || true) - if [ -z "$target" ]; then - exit 0 - fi + time.timeZone = null; + services.automatic-timezoned.enable = true; - if [[ "$target" == /* ]]; then - exit 0 - fi - - abs_target="/etc/$target" - if [ -e "$abs_target" ]; then - ${pkgs.coreutils}/bin/ln -sf "$abs_target" /etc/localtime - fi - ''; + systemd.services.automatic-timezoned = { + after = [ "dbus.socket" "systemd-timedated.service" "geoclue.service" ] + ++ lib.optional (cfg.persistDir != null) "timezone-restore.service"; + wants = [ "dbus.socket" "systemd-timedated.service" "geoclue.service" ] + ++ lib.optional (cfg.persistDir != null) "timezone-restore.service"; + serviceConfig = { + ExecStartPre = "${lib.getExe' pkgs.coreutils "sleep"} 5"; + Restart = "on-failure"; + RestartSec = "10s"; + }; }; - unitConfig = { - ConditionPathIsSymbolicLink = "/etc/localtime"; + systemd.services.automatic-timezoned-geoclue-agent = { + after = [ "dbus.socket" ]; + wants = [ "dbus.socket" ]; }; - }; - systemd.paths.fix-localtime-symlink = { - description = "Watch /etc/localtime for changes"; - wantedBy = [ "multi-user.target" ]; + # Ensure anything using timedate1 sees restored timezone first. + systemd.services.systemd-timedated = lib.mkIf (cfg.persistDir != null) { + after = [ "timezone-restore.service" ]; + wants = [ "timezone-restore.service" ]; + requires = [ "timezone-restore.service" ]; + }; - pathConfig = { - PathChanged = "/etc/localtime"; - Unit = "fix-localtime-symlink.service"; + # Restore timezone from persistent storage on boot (fallback for offline boots) + systemd.services.timezone-restore = lib.mkIf (cfg.persistDir != null) { + description = "Restore timezone from persistent storage"; + wantedBy = [ "sysinit.target" ]; + + # NixOS activation may recreate /etc/localtime based on config. + # Run after activation so the restored timezone "wins" on offline boots. + after = [ + "local-fs.target" + "systemd-remount-fs.service" + "nixos-activation.service" + ]; + wants = [ "nixos-activation.service" ]; + + before = [ + "time-sync.target" + "automatic-timezoned.service" + "systemd-timedated.service" + ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + RequiresMountsFor = [ cfg.persistDir ]; + ExecStart = pkgs.writeShellScript "timezone-restore" '' + set -euo pipefail + persist_file="${persistFile}" + + if [ ! -f "$persist_file" ]; then + echo "No persisted timezone found, skipping restore" + exit 0 + fi + + tz=$(${pkgs.coreutils}/bin/cat "$persist_file") + if [ -z "$tz" ]; then + echo "Persisted timezone file is empty, skipping restore" + exit 0 + fi + + tzfile="${tzdata}/share/zoneinfo/$tz" + if [ ! -f "$tzfile" ]; then + echo "Invalid timezone '$tz' in persist file, skipping restore" + exit 0 + fi + + echo "Restoring timezone: $tz" + ${pkgs.coreutils}/bin/ln -sf "$tzfile" /etc/localtime + + # Some NixOS setups may generate /etc/timezone as a symlink into the store. + # Replace it so we don't fail the whole restore. + ${pkgs.coreutils}/bin/rm -f /etc/timezone + ${pkgs.coreutils}/bin/printf '%s\n' "$tz" > /etc/timezone + ''; + }; + }; + + # Save timezone whenever it changes + systemd.services.timezone-persist = lib.mkIf (cfg.persistDir != null) { + description = "Persist timezone to storage"; + + serviceConfig = { + Type = "oneshot"; + RequiresMountsFor = [ cfg.persistDir ]; + ExecStart = pkgs.writeShellScript "timezone-persist" '' + set -euo pipefail + ${pkgs.coreutils}/bin/mkdir -p "${cfg.persistDir}" + + # Try to read timezone from /etc/timezone first, fall back to parsing symlink + if [ -f /etc/timezone ]; then + tz=$(${pkgs.coreutils}/bin/cat /etc/timezone | ${pkgs.coreutils}/bin/tr -d '[:space:]') + else + target=$(${pkgs.coreutils}/bin/readlink /etc/localtime 2>/dev/null || true) + if [ -z "$target" ]; then + echo "Cannot determine timezone, skipping persist" + exit 0 + fi + # Extract timezone name from path like /nix/store/.../share/zoneinfo/America/Chicago + tz=$(echo "$target" | ${pkgs.gnused}/bin/sed -n 's|.*/zoneinfo/||p') + fi + + if [ -z "$tz" ]; then + echo "Cannot determine timezone, skipping persist" + exit 0 + fi + + persist_file="${persistFile}" + + echo "Persisting timezone: $tz" + echo "$tz" > "$persist_file" + ''; + }; + }; + + # Watch /etc/localtime and /etc/timezone for changes and trigger persist + systemd.paths.timezone-persist = lib.mkIf (cfg.persistDir != null) { + description = "Watch timezone changes to persist"; + wantedBy = [ "multi-user.target" ]; + + pathConfig = { + PathChanged = [ "/etc/localtime" "/etc/timezone" ]; + Unit = "timezone-persist.service"; + }; + }; + + systemd.services.fix-localtime-symlink = { + description = "Fix /etc/localtime symlink to be absolute"; + wantedBy = [ "multi-user.target" ]; + after = [ "automatic-timezoned.service" ]; + wants = [ "automatic-timezoned.service" ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = pkgs.writeShellScript "fix-localtime-symlink" '' + target=$(${pkgs.coreutils}/bin/readlink /etc/localtime 2>/dev/null || true) + if [ -z "$target" ]; then + exit 0 + fi + + if [[ "$target" == /* ]]; then + exit 0 + fi + + abs_target="/etc/$target" + if [ -e "$abs_target" ]; then + ${pkgs.coreutils}/bin/ln -sf "$abs_target" /etc/localtime + fi + ''; + }; + + unitConfig = { + ConditionPathIsSymbolicLink = "/etc/localtime"; + }; + }; + + systemd.paths.fix-localtime-symlink = { + description = "Watch /etc/localtime for changes"; + wantedBy = [ "multi-user.target" ]; + + pathConfig = { + PathChanged = "/etc/localtime"; + Unit = "fix-localtime-symlink.service"; + }; }; }; } diff --git a/flakes/de_plasma/de_plasma.nix b/flakes/de_plasma/de_plasma.nix index 01fd3b99..e001843e 100644 --- a/flakes/de_plasma/de_plasma.nix +++ b/flakes/de_plasma/de_plasma.nix @@ -206,16 +206,6 @@ in ''; }) - (mkIf ((length cfg.wallpapers) > 0) { - environment.etc."xdg/plasma-org.kde.plasma.desktop-appletsrc".text = - let - wallpaperPath = builtins.head cfg.wallpapers; - in - '' - [Containments][1][Wallpaper][org.kde.image][General] - Image=file://${wallpaperPath} - ''; - }) # GPU blocks (mkIf cfg.gpu.amd.enable { diff --git a/flakes/de_plasma/home_manager/default.nix b/flakes/de_plasma/home_manager/default.nix index e1d674d2..4153b9da 100644 --- a/flakes/de_plasma/home_manager/default.nix +++ b/flakes/de_plasma/home_manager/default.nix @@ -5,7 +5,9 @@ }: let cfg = osConfig.ringofstorms.dePlasma; - inherit (lib) mkIf; + inherit (lib) mkIf optionalAttrs; + # Get the first wallpaper from the list if available + wallpaper = if (builtins.length cfg.wallpapers) > 0 then builtins.head cfg.wallpapers else null; in { imports = [ @@ -294,6 +296,8 @@ in lookAndFeel = "org.kde.breezedark.desktop"; theme = "breeze-dark"; cursor.theme = "breeze_cursors"; + } // optionalAttrs (wallpaper != null) { + wallpaper = wallpaper; }; configFile = { diff --git a/hosts/juni/flake.lock b/hosts/juni/flake.lock index 1d9d13d8..8843bd3f 100644 --- a/hosts/juni/flake.lock +++ b/hosts/juni/flake.lock @@ -38,40 +38,28 @@ }, "common": { "locked": { - "dir": "flakes/common", - "lastModified": 1768255305, - "narHash": "sha256-XcXl5M0WNYhCCqE9qc9Aj2/2Jb/T0NHZnu2ZuVBvlHw=", - "ref": "refs/heads/master", - "rev": "15769eda748f6fcc6fdab04f79f14ed9b1ffc548", - "revCount": 1125, - "type": "git", - "url": "https://git.joshuabell.xyz/ringofstorms/dotfiles" + "path": "../../flakes/common", + "type": "path" }, "original": { - "dir": "flakes/common", - "type": "git", - "url": "https://git.joshuabell.xyz/ringofstorms/dotfiles" - } + "path": "../../flakes/common", + "type": "path" + }, + "parent": [] }, "de_plasma": { "inputs": { "plasma-manager": "plasma-manager" }, "locked": { - "dir": "flakes/de_plasma", - "lastModified": 1768255305, - "narHash": "sha256-XcXl5M0WNYhCCqE9qc9Aj2/2Jb/T0NHZnu2ZuVBvlHw=", - "ref": "refs/heads/master", - "rev": "15769eda748f6fcc6fdab04f79f14ed9b1ffc548", - "revCount": 1125, - "type": "git", - "url": "https://git.joshuabell.xyz/ringofstorms/dotfiles" + "path": "../../flakes/de_plasma", + "type": "path" }, "original": { - "dir": "flakes/de_plasma", - "type": "git", - "url": "https://git.joshuabell.xyz/ringofstorms/dotfiles" - } + "path": "../../flakes/de_plasma", + "type": "path" + }, + "parent": [] }, "flatpaks": { "inputs": { diff --git a/hosts/juni/flake.nix b/hosts/juni/flake.nix index 52bbb47b..751ac7d8 100644 --- a/hosts/juni/flake.nix +++ b/hosts/juni/flake.nix @@ -9,16 +9,16 @@ impermanence.url = "github:nix-community/impermanence"; # Use relative to get current version for testin - # common.url = "path:../../flakes/common"; - common.url = "git+https://git.joshuabell.xyz/ringofstorms/dotfiles?dir=flakes/common"; + common.url = "path:../../flakes/common"; + # common.url = "git+https://git.joshuabell.xyz/ringofstorms/dotfiles?dir=flakes/common"; # secrets-bao.url = "path:../../flakes/secrets-bao"; secrets-bao.url = "path:../../flakes/secrets-bao"; # flatpaks.url = "path:../../flakes/flatpaks"; flatpaks.url = "git+https://git.joshuabell.xyz/ringofstorms/dotfiles?dir=flakes/flatpaks"; # beszel.url = "path:../../flakes/beszel"; beszel.url = "git+https://git.joshuabell.xyz/ringofstorms/dotfiles?dir=flakes/beszel"; - # de_plasma.url = "path:../../flakes/de_plasma"; - de_plasma.url = "git+https://git.joshuabell.xyz/ringofstorms/dotfiles?dir=flakes/de_plasma"; + de_plasma.url = "path:../../flakes/de_plasma"; + # de_plasma.url = "git+https://git.joshuabell.xyz/ringofstorms/dotfiles?dir=flakes/de_plasma"; opencode.url = "github:sst/opencode"; ros_neovim.url = "git+https://git.joshuabell.xyz/ringofstorms/nvim"; @@ -63,8 +63,8 @@ gpu.intel.enable = true; sddm.autologinUser = "josh"; wallpapers = [ - ../../_shared_assets/wallpapers/pixel_neon.png - ../../_shared_assets/wallpapers/pixel_neon_v.png + ../../hosts/_shared_assets/wallpapers/pixel_neon.png + ../../hosts/_shared_assets/wallpapers/pixel_neon_v.png ]; }; }) @@ -84,6 +84,7 @@ inputs.common.nixosModules.hardening inputs.common.nixosModules.nix_options inputs.common.nixosModules.timezone_auto + ({ services.automatic-timezoned.persistDir = "/persist/var/lib/timezone-persist"; }) inputs.common.nixosModules.tty_caps_esc inputs.common.nixosModules.zsh inputs.common.nixosModules.tailnet diff --git a/hosts/juni/impermanence.nix b/hosts/juni/impermanence.nix index 91536cad..c267211b 100644 --- a/hosts/juni/impermanence.nix +++ b/hosts/juni/impermanence.nix @@ -32,8 +32,6 @@ files = [ "/machine-key.json" "/etc/machine-id" - "/etc/localtime" - "/etc/timezone" "/etc/adjtime" # NOTE: if you want mutable passwords across reboots, persist these, # but you must do a one-time migration (see notes in chat).