From bc2562685c8dd84f5bacbc5c359545e79ab320b8 Mon Sep 17 00:00:00 2001 From: Kajus Naujokaitis Date: Thu, 26 Mar 2026 14:45:04 +0200 Subject: [PATCH] nixos/i18n: allow imperative locale management via localectl Signed-off-by: Kajus Naujokaitis --- nixos/modules/config/console.nix | 24 +++++++--- nixos/modules/config/i18n.nix | 66 ++++++++++++++++++--------- nixos/modules/config/locale.nix | 4 -- nixos/modules/system/boot/systemd.nix | 10 ++-- 4 files changed, 69 insertions(+), 35 deletions(-) diff --git a/nixos/modules/config/console.nix b/nixos/modules/config/console.nix index 571f723a823f..5a7a4f9f52ec 100644 --- a/nixos/modules/config/console.nix +++ b/nixos/modules/config/console.nix @@ -6,6 +6,7 @@ }: let cfg = config.console; + i18nCfg = config.i18n; makeColor = i: lib.concatMapStringsSep "," (x: "0x" + lib.substring (2 * i) 2 x); @@ -162,8 +163,10 @@ in environment.systemPackages = [ pkgs.kbd ]; # Let systemd-vconsole-setup.service do the work of setting up the - # virtual consoles. - environment.etc."vconsole.conf".source = vconsoleConf; + # virtual consoles. Skip when imperative so localectl can manage it. + environment.etc."vconsole.conf" = lib.mkIf (!i18nCfg.imperativeLocale) { + source = vconsoleConf; + }; # Provide kbd with additional packages. environment.etc.kbd.source = "${consoleEnv pkgs.kbd}/share"; @@ -209,13 +212,22 @@ in "systemd-vconsole-setup.service" ]; + # When imperative, seed /etc/vconsole.conf on first boot from declared + # defaults so the keymap isn't lost before localectl is ever used + systemd.tmpfiles.rules = lib.mkIf i18nCfg.imperativeLocale [ + "C /etc/vconsole.conf - - - - ${vconsoleConf}" + ]; + systemd.services.reload-systemd-vconsole-setup = { description = "Reset console on configuration changes"; wantedBy = [ "multi-user.target" ]; - restartTriggers = [ - vconsoleConf - (consoleEnv pkgs.kbd) - ]; + restartTriggers = + lib.optionals (!i18nCfg.imperativeLocale) [ + vconsoleConf + ] + ++ [ + (consoleEnv pkgs.kbd) + ]; reloadIfChanged = true; serviceConfig = { RemainAfterExit = true; diff --git a/nixos/modules/config/i18n.nix b/nixos/modules/config/i18n.nix index aa8f06d1c7f2..3d043b330fc7 100644 --- a/nixos/modules/config/i18n.nix +++ b/nixos/modules/config/i18n.nix @@ -5,24 +5,29 @@ ... }: let + cfg = config.i18n; + localeConf = pkgs.writeText "locale.conf" '' + LANG=${cfg.defaultLocale} + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: v: "${n}=${v}") cfg.extraLocaleSettings)} + ''; sanitizeUTF8Capitalization = lang: (lib.replaceStrings [ "utf8" "utf-8" "UTF8" ] [ "UTF-8" "UTF-8" "UTF-8" ] lang); aggregatedLocales = - lib.optionals (config.i18n.defaultLocale != "C") [ - "${config.i18n.defaultLocale}/${config.i18n.defaultCharset}" + lib.optionals (cfg.defaultLocale != "C") [ + "${cfg.defaultLocale}/${cfg.defaultCharset}" ] - ++ lib.pipe config.i18n.extraLocaleSettings [ + ++ lib.pipe cfg.extraLocaleSettings [ # See description of extraLocaleSettings for why is this ignored here. (x: lib.removeAttrs x [ "LANGUAGE" ]) (lib.mapAttrs (n: v: (sanitizeUTF8Capitalization v))) # C locales are always installed (lib.filterAttrs (n: v: v != "C")) - (lib.mapAttrsToList (LCRole: lang: lang + "/" + (config.i18n.localeCharsets.${LCRole} or "UTF-8"))) + (lib.mapAttrsToList (LCRole: lang: lang + "/" + (cfg.localeCharsets.${LCRole} or "UTF-8"))) ] ++ (map sanitizeUTF8Capitalization ( - lib.optionals (builtins.isList config.i18n.extraLocales) config.i18n.extraLocales + lib.optionals (builtins.isList cfg.extraLocales) cfg.extraLocales )) - ++ (lib.optional (builtins.isString config.i18n.extraLocales) config.i18n.extraLocales); + ++ (lib.optional (builtins.isString cfg.extraLocales) cfg.extraLocales); in { ###### interface @@ -33,8 +38,8 @@ in glibcLocales = lib.mkOption { type = lib.types.path; default = pkgs.glibcLocales.override { - allLocales = lib.elem "all" config.i18n.supportedLocales; - locales = config.i18n.supportedLocales; + allLocales = lib.elem "all" cfg.supportedLocales; + locales = cfg.supportedLocales; }; defaultText = lib.literalExpression '' pkgs.glibcLocales.override { @@ -143,6 +148,16 @@ in ''; }; + imperativeLocale = lib.mkEnableOption '' + imperative locale and keyboard management via localectl. + + When enabled, locale and keyboard settings can be changed at runtime + using `localectl set-locale` and `localectl set-keymap`. + When disabled (the default), these settings are managed declaratively + through {option}`i18n.defaultLocale`, {option}`i18n.extraLocaleSettings`, + and {option}`console.keyMap`. + ''; + }; }; @@ -154,8 +169,8 @@ in lib.optional ( !( - (lib.subtractLists config.i18n.supportedLocales aggregatedLocales) == [ ] - || lib.elem "all" config.i18n.supportedLocales + (lib.subtractLists cfg.supportedLocales aggregatedLocales) == [ ] + || lib.elem "all" cfg.supportedLocales ) ) '' @@ -171,25 +186,34 @@ in environment.systemPackages = # We increase the priority a little, so that plain glibc in systemPackages can't win. - lib.optional (config.i18n.supportedLocales != [ ]) (lib.setPrio (-1) config.i18n.glibcLocales); + lib.optional (cfg.supportedLocales != [ ]) (lib.setPrio (-1) cfg.glibcLocales); environment.sessionVariables = { - LANG = config.i18n.defaultLocale; LOCALE_ARCHIVE = "/run/current-system/sw/lib/locale/locale-archive"; } - // config.i18n.extraLocaleSettings; + # When imperative, leave LANG/LC_* to pam_systemd so /etc/set-environment + # does not override what localectl wrote to /etc/locale.conf. + // lib.optionalAttrs (!cfg.imperativeLocale) ( + { + LANG = cfg.defaultLocale; + } + // cfg.extraLocaleSettings + ); - systemd.globalEnvironment = lib.mkIf (config.i18n.supportedLocales != [ ]) { - LOCALE_ARCHIVE = "${config.i18n.glibcLocales}/lib/locale/locale-archive"; + systemd.globalEnvironment = lib.mkIf (cfg.supportedLocales != [ ]) { + LOCALE_ARCHIVE = "${cfg.glibcLocales}/lib/locale/locale-archive"; }; # ‘/etc/locale.conf’ is used by systemd. - environment.etc."locale.conf".source = pkgs.writeText "locale.conf" '' - LANG=${config.i18n.defaultLocale} - ${lib.concatStringsSep "\n" ( - lib.mapAttrsToList (n: v: "${n}=${v}") config.i18n.extraLocaleSettings - )} - ''; + # If imperative, see below + environment.etc."locale.conf" = lib.mkIf (!cfg.imperativeLocale) { + source = localeConf; + }; + # When imperative, seed /etc/locale.conf on first boot from declared defaults + # so the system doesn’t fall back to C.UTF-8 + systemd.tmpfiles.rules = lib.mkIf cfg.imperativeLocale [ + "C /etc/locale.conf - - - - ${localeConf}" + ]; }; } diff --git a/nixos/modules/config/locale.nix b/nixos/modules/config/locale.nix index 8fe61ca6cb22..a3a8dfab7b25 100644 --- a/nixos/modules/config/locale.nix +++ b/nixos/modules/config/locale.nix @@ -86,10 +86,6 @@ in # This way services are restarted when tzdata changes. systemd.globalEnvironment.TZDIR = tzdir; - systemd.services.systemd-timedated.environment = lib.optionalAttrs (config.time.timeZone != null) { - NIXOS_STATIC_TIMEZONE = "1"; - }; - environment.etc = { zoneinfo.source = tzdir; } diff --git a/nixos/modules/system/boot/systemd.nix b/nixos/modules/system/boot/systemd.nix index d1245fed30dd..eab3e428ade2 100644 --- a/nixos/modules/system/boot/systemd.nix +++ b/nixos/modules/system/boot/systemd.nix @@ -826,10 +826,12 @@ in # because either the overlay is mutable (and users can legitimately change # values without them being overridden) or it is immutable and systemd will # suggest to only make runtime changes. - systemd.services."systemd-localed".environment = lib.mkIf (!config.system.etc.overlay.enable) { - SYSTEMD_ETC_LOCALE_CONF = "/etc/static/locale.conf"; - SYSTEMD_ETC_VCONSOLE_CONF = "/etc/static/vconsole.conf"; - }; + systemd.services."systemd-localed".environment = + lib.mkIf (!config.system.etc.overlay.enable && !config.i18n.imperativeLocale) + { + SYSTEMD_ETC_LOCALE_CONF = "/etc/static/locale.conf"; + SYSTEMD_ETC_VCONSOLE_CONF = "/etc/static/vconsole.conf"; + }; systemd.services."systemd-timedated".environment = lib.mkIf (!config.system.etc.overlay.enable && config.time.timeZone != null) {