{ config, lib, options, sane-lib, utils, ... }: let inherit (builtins) attrValues; inherit (lib) count mapAttrs' mapAttrsToList mkIf mkMerge mkOption types; sane-user-cfg = config.sane.user; cfg = config.sane.users; path-lib = sane-lib.path; userOptions = { options = { fs = mkOption { # map to listOf attrs so that we can allow multiple assigners to the same path # w/o worrying about merging at this layer, and defer merging to modules/fs instead. type = types.attrsOf (types.coercedTo types.attrs (a: [ a ]) (types.listOf types.attrs)); default = {}; description = '' entries to pass onto `sane.fs` after prepending the user's home-dir to the path and marking them as wanted. e.g. `sane.users.colin.fs."/.config/aerc" = X` => `sane.fs."/home/colin/.config/aerc" = { wantedBy = [ "multi-user.target"]; } // X; conventions are similar as to toplevel `sane.fs`. so `sane.users.foo.fs."/"` represents the home directory, whereas every other entry is expected to *not* have a trailing slash. option merging happens inside `sane.fs`, so `sane.users.colin.fs."foo" = A` and `sane.fs."/home/colin/foo" = B` behaves identically to `sane.fs."/home/colin/foo" = lib.mkMerge [ A B ]; (the unusual signature for this type is how we delay option merging) ''; }; persist = mkOption { type = options.sane.persist.sys.type; default = {}; description = '' entries to pass onto `sane.persist.sys` after prepending the user's home-dir to the path. ''; }; environment = mkOption { type = types.attrsOf types.str; default = {}; description = '' environment variables to place in user's shell profile. these end up in ~/.profile ''; }; services = mkOption { # see: # type = utils.systemdUtils.types.services; # `utils.systemdUtils.types.services` is nearly what we want, but remove `stage2ServiceConfig`, # as we don't want to force a PATH for every service. type = types.attrsOf (types.submodule [ utils.systemdUtils.unitOptions.stage2ServiceOptions utils.systemdUtils.lib.unitConfig ]); default = {}; description = '' systemd user-services to define for this user. populates files in ~/.config/systemd. ''; }; }; }; defaultUserOptions = { options = userOptions.options // { services = mkOption { # type = utils.systemdUtils.types.services; # map to listOf attrs so that we can pass through # w/o worrying about merging at this layer type = types.attrsOf (types.coercedTo types.attrs (a: [ a ]) (types.listOf types.attrs)); default = {}; inherit (userOptions.options.services) description; }; }; }; userModule = let nixConfig = config; in types.submodule ({ name, config, ... }: { options = userOptions.options // { default = mkOption { type = types.bool; default = false; description = '' only one default user may exist. this option determines what the `sane.user` shorthand evaluates to. ''; }; home = mkOption { type = types.str; # XXX: we'd prefer to set this to `config.users.users.home`, but that causes infinite recursion... # TODO: maybe assert that this matches the actual home? default = "/home/${name}"; }; }; config = lib.mkMerge [ # if we're the default user, inherit whatever settings were routed to the default user (mkIf config.default { inherit (sane-user-cfg) fs persist environment; services = lib.mapAttrs (_: lib.mkMerge) sane-user-cfg.services; }) { fs."/".dir.acl = { user = lib.mkDefault name; group = lib.mkDefault nixConfig.users.users."${name}".group; # homeMode defaults to 700; notice: no leading 0 mode = "0" + nixConfig.users.users."${name}".homeMode; }; # ~/.config/environment.d/*.conf is added to systemd user units. # - format: lines of: `key=value` # ~/.profile is added by *some* login shells. # - format: lines of: `export key="value"` # see: `man environment.d` fs.".config/environment.d/10-sane-nixos-users.conf".symlink.text = let env = lib.mapAttrsToList (key: value: ''${key}=${value}'') config.environment ; in lib.concatStringsSep "\n" env + "\n"; fs.".profile".symlink.text = '' # source env vars and the like, as systemd would. `man environment.d` for env in ~/.config/environment.d/*.conf; do # surround with `set -o allexport` since environment.d doesn't explicitly `export` their vars set -a source "$env" set +a done ''; } { fs = lib.mkMerge (mapAttrsToList (serviceName: value: let # see: # see: cleanName = utils.systemdUtils.lib.mkPathSafeName serviceName; generatedUnit = utils.systemdUtils.lib.serviceToUnit serviceName (value // { environment = lib.throwIf (value.path != []) "user service ${serviceName} specifies unsupported 'path' attribute (${builtins.toString value.path})" { # clear PATH to allow inheriting it from environment. # otherwise, nixos would force it to `systemd.globalEnvironment.PATH`, which is mostly tools like sed/find/etc. # clearing PATH here allows user services to inherit whatever PATH the graphical session sets # (see `dbus-update-activation-environment` call in ~/.config/sway/config), # which is critical to making it so user services can see user *programs*/packages. # # note that systemd provides no way to *append* to the PATH, only to override it (or not). # nor do they intend to ever support that: # - PATH = null; } // (value.environment or {}); }); #^ generatedUnit contains keys: # - text # - aliases (IGNORED) # - wantedBy # - requiredBy # - enabled (IGNORED) # - overrideStrategy (IGNORED) # TODO: error if one of the above ignored fields are set symlinkData = { text = generatedUnit.text; targetName = "${cleanName}.service"; # systemd derives unit name from symlink target }; serviceEntry = { ".config/systemd/user/${serviceName}.service".symlink = symlinkData; }; wants = builtins.map (wantedBy: { ".config/systemd/user/${wantedBy}.wants/${serviceName}.service".symlink = symlinkData; }) generatedUnit.wantedBy; requires = builtins.map (requiredBy: { ".config/systemd/user/${requiredBy}.requires/${serviceName}.service".symlink = symlinkData; }) generatedUnit.requiredBy; in lib.mkMerge ([ serviceEntry ] ++ wants ++ requires) ) config.services); } ]; }); processUser = user: defn: let prefixWithHome = mapAttrs' (path: value: { name = path-lib.concat [ defn.home path ]; inherit value; }); makeWanted = lib.mapAttrs (_path: values: lib.mkMerge (values ++ [{ # default if not otherwise provided wantedBeforeBy = lib.mkDefault [ "multi-user.target" ]; }])); in { sane.fs = makeWanted (prefixWithHome defn.fs); sane.defaultUser = lib.mkIf defn.default user; # `byPath` is the actual output here, computed from the other keys. sane.persist.sys.byPath = prefixWithHome defn.persist.byPath; }; in { options = { sane.users = mkOption { type = types.attrsOf userModule; default = {}; description = '' options to apply to the given user. the user is expected to be created externally. configs applied at this level are simply transformed and then merged into the toplevel `sane` options. it's merely a shorthand. ''; }; sane.user = mkOption { type = types.nullOr (types.submodule defaultUserOptions); default = null; description = '' options to pass down to the default user ''; }; sane.defaultUser = mkOption { type = types.nullOr types.str; default = null; description = '' the name of the default user. other attributes of the default user may be retrieved via `config.sane.users."''${config.sane.defaultUser}".`. ''; }; }; config = let configs = mapAttrsToList processUser cfg; num-default-users = count (u: u.default) (attrValues cfg); take = f: { sane.fs = f.sane.fs; sane.persist.sys.byPath = f.sane.persist.sys.byPath; sane.defaultUser = f.sane.defaultUser; }; in mkMerge [ (take (sane-lib.mkTypedMerge take configs)) { assertions = [ { assertion = sane-user-cfg == null || num-default-users != 0; message = "cannot set `sane.user` without first setting `sane.users..default = true` for some user"; } { assertion = num-default-users <= 1; message = "cannot set more than one default user"; } ]; } ]; }