diff --git a/users/ryan/linux.nix b/users/ryan/linux.nix
index 39b275c..241ed3f 100644
--- a/users/ryan/linux.nix
+++ b/users/ryan/linux.nix
@@ -120,8 +120,5 @@
inputs.clipboard-sync.packages.${pkgs.stdenv.hostPlatform.system}.default
- # hyprlock itself is provided by the host-specific module (e.g.
- # linux/guix.nix wraps it with an LD_PRELOAD shim; a plain NixOS host
- # would just add pkgs.hyprlock here instead).
];
}
diff --git a/users/ryan/linux/guix.nix b/users/ryan/linux/guix.nix
index 21fea52..db305af 100644
--- a/users/ryan/linux/guix.nix
+++ b/users/ryan/linux/guix.nix
@@ -1,72 +1,33 @@
{ config, pkgs, lib, inputs, ... }:
+# Host glue for the Guix System laptop. Everything generic about running
+# standalone home-manager on Guix lives in ../modules/targets/guix; this file
+# only holds what's specific to this machine.
{
- systemd.user.startServices = false;
+ imports = [ ../modules/targets/guix ];
- programs.zsh.profileExtra = ''
- if [ -f "$HOME/.guix-home/setup-environment" ]; then
- HOME_ENVIRONMENT="$HOME/.guix-home"
- . "$HOME_ENVIRONMENT/setup-environment"
- "$HOME_ENVIRONMENT/on-first-login"
- unset HOME_ENVIRONMENT
- fi
+ targets.guix.enable = true;
- # /etc/profile normally puts ~/.config/guix/current (the guix pull
- # profile with our actual channels: nonguix, rosenthal, ...) ahead of
- # /run/current-system/profile on PATH, but that only happens for bash
- # login shells -- zsh never sources /etc/profile. Without this, `guix`
- # resolves to the bare system profile's guix, which doesn't know about
- # our channels.
- if [ -d "$HOME/.config/guix/current" ]; then
- export PATH="$HOME/.config/guix/current/bin:$PATH"
- export INFOPATH="$HOME/.config/guix/current/share/info''${INFOPATH:+:}$INFOPATH"
- fi
- '';
+ # Guix's mesa doesn't match nixpkgs', so GL apps built by nix go through
+ # nixGL. Setting packages also makes config.lib.nixGL.wrap real (it's a
+ # no-op otherwise) and supersedes HM's systemd-tmpfiles-based GPU setup.
+ targets.genericLinux.nixGL = {
+ packages = inputs.nixgl.packages;
+ defaultWrapper = "mesa"; # nixGLIntel under the hood
+ };
+ home.packages = [
+ # Kept under its old name for anything that calls it directly.
+ inputs.nixgl.packages.${pkgs.stdenv.hostPlatform.system}.nixGLIntel
+ ];
- dconf.enable = false;
+ targets.guix.pamWrapped = [ pkgs.hyprlock ];
+ # gpg-agent is socket-activated through Shepherd now (translated from HM's
+ # gpg-agent.socket units), so no launch here; Hyprland's own environment
+ # still needs the socket path for apps it spawns.
wayland.windowManager.hyprland.extraConfig = ''
- exec-once = sh -c 'gpgconf --launch gpg-agent; hyprctl setenv SSH_AUTH_SOCK "$(gpgconf --list-dirs agent-ssh-socket)"'
+ exec-once = sh -c 'hyprctl setenv SSH_AUTH_SOCK "$(gpgconf --list-dirs agent-ssh-socket)"'
'';
programs.gpg.scdaemonSettings.disable-ccid = true;
-
- # Guix needs a special fonts.conf file in the user env since it doesn't
- # have one in /etc/fonts/fonts.conf from nixos
- xdg.configFile."fontconfig-nix/fonts.conf".text = ''
- <?xml version='1.0'?>
- <!DOCTYPE fontconfig SYSTEM 'fonts.dtd'>
- <fontconfig>
- <include ignore_missing="yes">${pkgs.fontconfig.out}/etc/fonts/conf.d</include>
- <include ignore_missing="yes">${config.home.homeDirectory}/.config/fontconfig/conf.d</include>
- <dir>${config.home.homeDirectory}/.guix-home/profile/share/fonts</dir>
- <cachedir>${config.home.homeDirectory}/.cache/fontconfig</cachedir>
- </fontconfig>
- '';
-
- home.sessionVariables.FONTCONFIG_FILE = "${config.home.homeDirectory}/.config/fontconfig-nix/fonts.conf";
-
- home.packages = [
- (pkgs.writeScriptBin "hyprlock" ''
- #! ${pkgs.bash}/bin/bash
- export LD_PRELOAD="/run/current-system/profile/lib/libpam.so.0:$LD_PRELOAD"
- exec ${pkgs.hyprlock}/bin/hyprlock "$@"
- '')
-
- # Guix's mesa doesn't match nixpkgs', so GL apps built by nix need nixGL.
- inputs.nixgl.packages.${pkgs.stdenv.hostPlatform.system}.nixGLIntel
- ];
-
- # We need to correct the ssh config file's permissions on guix
- # Remove the symlink and just install the store file directly
- home.activation = {
- fixSshPermissions = lib.hm.dag.entryAfter [ "linkGeneration" ] ''
- run install -d -m 0700 "$HOME/.ssh"
- if [ -L "$HOME/.ssh/config" ]; then
- src="$(readlink -f "$HOME/.ssh/config")"
- run rm -f "$HOME/.ssh/config"
- run install -m 0600 "$src" "$HOME/.ssh/config"
- fi
- '';
- };
}
diff --git a/users/ryan/modules/targets/guix/default.nix b/users/ryan/modules/targets/guix/default.nix
new file mode 100644
index 0000000..8433bb6
--- /dev/null
+++ b/users/ryan/modules/targets/guix/default.nix
@@ -0,0 +1,254 @@
+# targets.guix: make standalone home-manager behave on Guix System.
+#
+# The point is that other modules stay written against upstream
+# home-manager options (systemd.user.services, services.*, programs.*) and
+# this target adapts them, instead of each module growing a Guix branch.
+{ config, lib, pkgs, osConfig ? null, ... }:
+let
+ cfg = config.targets.guix;
+ sh = import ./shepherd.nix { inherit lib; setpriv = pkgs.util-linux; };
+
+ result = sh.translate {
+ systemdUser = config.systemd.user;
+ inherit (cfg.shepherd) unenforced ignoreUnits;
+ };
+
+ servicesDir = pkgs.runCommandLocal "hm-shepherd-services" { } (''
+ mkdir -p $out
+ '' + lib.concatStrings (lib.mapAttrsToList (file: text: ''
+ cp ${pkgs.writeText file text} $out/${file}
+ '') result.files));
+
+ stateDir = "${config.xdg.stateHome}/home-manager/shepherd";
+
+ findHerd = ''
+ herd="$HOME/.guix-home/profile/bin/herd"
+ [ -x "$herd" ] || herd="$(command -v herd || true)"
+ '';
+
+ # Compositor -> Shepherd environment bridge (replaces
+ # `dbus-update-activation-environment --systemd` + hyprland-session.target).
+ sessionBridge = pkgs.writeShellScript "hm-shepherd-session" ''
+ ${findHerd}
+ [ -n "$herd" ] || { echo "hm-shepherd-session: herd not found" >&2; exit 1; }
+ # Stop first: dependents go down with it and come back with the new env.
+ "$herd" stop ${sh.graphicalSym} >/dev/null 2>&1 || true
+ expr="(begin"
+ for v in ${lib.escapeShellArgs cfg.shepherd.sessionVariables}; do
+ if [ -n "''${!v+x}" ]; then
+ val="''${!v}"; val="''${val//\\/\\\\}"; val="''${val//\"/\\\"}"
+ expr+=" (setenv \"$v\" \"$val\")"
+ else
+ expr+=" (unsetenv \"$v\")"
+ fi
+ done
+ expr+=" #t)"
+ "$herd" eval root "$expr"
+ "$herd" start ${sh.graphicalSym}
+ ${lib.concatMapStrings (s: ''
+ "$herd" start ${lib.escapeShellArg s}
+ '') result.graphical}
+ '';
+in
+{
+ options.targets.guix = {
+ enable = lib.mkEnableOption "Guix System integration for standalone home-manager";
+
+ pamWrapped = lib.mkOption {
+ type = lib.types.listOf lib.types.package;
+ default = [ ];
+ example = lib.literalExpression "[ pkgs.hyprlock ]";
+ description = ''
+ Packages that authenticate through PAM. Nix's libpam can't drive Guix's
+ PAM stack, so their binaries are re-exported with Guix's libpam
+ preloaded. Each must set meta.mainProgram.
+ '';
+ };
+
+ shepherd = {
+ unenforced = lib.mkOption {
+ type = with lib.types; attrsOf (listOf str);
+ default = { };
+ example = { "foo.service" = [ "Service.ProtectSystem" "Service.PrivateTmp" ]; };
+ description = ''
+ Per-unit acknowledgement that the listed `Section.Key`s (or
+ `Section.*`) are dropped when translating to Shepherd. Without an
+ entry, any untranslatable key is an evaluation error. Each dropped
+ key is reported as a build warning on every switch.
+ '';
+ };
+ ignoreUnits = lib.mkOption {
+ type = with lib.types; listOf str;
+ default = [ ];
+ example = [ "tray.target" ];
+ description = "Full unit names to skip entirely (not translated, no error).";
+ };
+ sessionBridge = lib.mkOption {
+ type = lib.types.package;
+ readOnly = true;
+ default = sessionBridge;
+ description = "Script the compositor runs (exec-once) to start graphical services; also usable by hand.";
+ };
+ sessionVariables = lib.mkOption {
+ type = with lib.types; listOf str;
+ default = config.wayland.windowManager.hyprland.systemd.variables
+ ++ [ "DBUS_SESSION_BUS_ADDRESS" "SSH_AUTH_SOCK" ];
+ defaultText = lib.literalExpression ''hyprland.systemd.variables ++ [ "DBUS_SESSION_BUS_ADDRESS" "SSH_AUTH_SOCK" ]'';
+ description = "Variables the compositor pushes into Shepherd before starting graphical services.";
+ };
+ };
+ };
+
+ config = lib.mkIf cfg.enable (lib.mkMerge [
+ {
+ assertions = [
+ {
+ # osConfig is only passed when home-manager runs as a NixOS or
+ # nix-darwin module, i.e. never on a Guix host.
+ assertion = osConfig == null;
+ message = "targets.guix is enabled inside a NixOS/nix-darwin home-manager module; it only applies to standalone home-manager on Guix System.";
+ }
+ {
+ assertion = config.targets.genericLinux.enable;
+ message = "targets.guix builds on targets.genericLinux; enable it too.";
+ }
+ ] ++ map (e: { assertion = false; message = "targets.guix: ${e}"; }) result.errors;
+
+ warnings = result.warnings;
+
+ # HM's ssh-auth-sock module (pulled in by gpg-agent's SSH support) adds
+ # a oneshot that runs `dbus-update-activation-environment --systemd
+ # SSH_AUTH_SOCK`; its whole job is systemd/D-Bus environment plumbing.
+ # The shell integration from the same module still sets the variable,
+ # and the session bridge pushes it into Shepherd (sessionVariables).
+ targets.guix.shepherd.ignoreUnits = [ "set-SSH_AUTH_SOCK.service" ];
+
+ # No systemd user manager: units are translated to Shepherd instead.
+ systemd.user.startServices = false;
+
+ # HM's GPU setup installs /etc/tmpfiles.d + runs systemd-tmpfiles as
+ # root; neither exists on Guix, so it only produces a warning per switch.
+ targets.genericLinux.gpu.enable = lib.mkDefault false;
+
+ # Guix's SSH patches openssh to not allow files outside of the GNU store
+ # so we need to copy the SSH config from the store into the userr's
+ # home directory to not get a permission error
+ home.activation.removeSshConfigSymlink = lib.mkIf config.programs.ssh.enable (
+ lib.hm.dag.entryBefore [ "checkLinkTargets" ] ''
+ if [ -n "''${HOME:-}" ] && [ -e "$HOME/.ssh/config" ]; then
+ rm -f "$HOME/.ssh/config"
+ fi
+ ''
+ );
+
+ home.activation.fixSshPermissions = lib.mkIf config.programs.ssh.enable (
+ lib.hm.dag.entryAfter [ "linkGeneration" ] ''
+ run install -d -m 0700 "$HOME/.ssh"
+ if [ -L "$HOME/.ssh/config" ]; then
+ src="$(readlink -f "$HOME/.ssh/config")"
+ run rm -f "$HOME/.ssh/config"
+ run install -m 0600 "$src" "$HOME/.ssh/config"
+ fi
+ ''
+ );
+
+ # Its exec-once runs `dbus-update-activation-environment --systemd &&
+ # systemctl --user ...`, which fails at the first step on Guix. The
+ # bridge below does the Shepherd equivalent.
+ wayland.windowManager.hyprland.systemd.enable = lib.mkDefault false;
+ wayland.windowManager.hyprland.extraConfig = lib.mkIf config.wayland.windowManager.hyprland.enable ''
+ exec-once = ${sessionBridge}
+ '';
+
+ dconf.enable = lib.mkDefault false;
+
+ # Guix Home owns ~/.zprofile's job of loading its environment (and
+ # starting the user Shepherd via on-first-login); keep that working
+ # under an HM-managed zsh.
+ programs.zsh.profileExtra = lib.mkBefore ''
+ if [ -f "$HOME/.guix-home/setup-environment" ]; then
+ HOME_ENVIRONMENT="$HOME/.guix-home"
+ . "$HOME_ENVIRONMENT/setup-environment"
+ "$HOME_ENVIRONMENT/on-first-login"
+ unset HOME_ENVIRONMENT
+ fi
+
+ # /etc/profile puts ~/.config/guix/current (guix pull profile with
+ # our channels) ahead of the system profile, but only bash login
+ # shells source it.
+ if [ -d "$HOME/.config/guix/current" ]; then
+ export PATH="$HOME/.config/guix/current/bin:$PATH"
+ export INFOPATH="$HOME/.config/guix/current/share/info''${INFOPATH:+:}$INFOPATH"
+ fi
+ '';
+
+ # No /etc/fonts/fonts.conf from Nix's point of view on Guix.
+ xdg.configFile."fontconfig-nix/fonts.conf".text = ''
+ <?xml version='1.0'?>
+ <!DOCTYPE fontconfig SYSTEM 'fonts.dtd'>
+ <fontconfig>
+ <include ignore_missing="yes">${pkgs.fontconfig.out}/etc/fonts/conf.d</include>
+ <include ignore_missing="yes">${config.xdg.configHome}/fontconfig/conf.d</include>
+ <dir>${config.home.homeDirectory}/.guix-home/profile/share/fonts</dir>
+ <cachedir>${config.xdg.cacheHome}/fontconfig</cachedir>
+ </fontconfig>
+ '';
+ home.sessionVariables.FONTCONFIG_FILE = "${config.xdg.configHome}/fontconfig-nix/fonts.conf";
+
+ home.packages = map
+ (p: lib.hiPrio (pkgs.writeShellScriptBin p.meta.mainProgram ''
+ export LD_PRELOAD="/run/current-system/profile/lib/libpam.so.0''${LD_PRELOAD:+:$LD_PRELOAD}"
+ exec ${lib.getExe p} "$@"
+ ''))
+ cfg.pamWrapped;
+
+ # Sync generated Shepherd services; reload only what changed.
+ home.activation.shepherdServices = lib.hm.dag.entryAfter [ "linkGeneration" "reloadSystemd" ] ''
+ if [ ! -x /run/current-system/profile/bin/guix ]; then
+ errorEcho "targets.guix: this host isn't Guix System; refusing to manage Shepherd services."
+ exit 1
+ fi
+
+ ${findHerd}
+ live=
+ if [ -n "$herd" ] && "$herd" status root >/dev/null 2>&1; then live=1; fi
+
+ dst=${lib.escapeShellArg stateDir}
+ run mkdir -p "$dst"
+
+ for f in "$dst"/*.scm; do
+ [ -e "$f" ] || continue
+ n="$(basename "$f" .scm)"
+ if [ ! -e ${servicesDir}/"$n".scm ]; then
+ [ -n "$live" ] && run "$herd" unload root "$n" || true
+ run rm -f "$f"
+ fi
+ done
+
+ for f in ${servicesDir}/*.scm; do
+ n="$(basename "$f" .scm)"
+ if ! cmp -s "$f" "$dst/$n.scm"; then
+ run install -m 0644 "$f" "$dst/$n.scm"
+ if [ -n "$live" ]; then
+ run "$herd" unload root "$n" >/dev/null 2>&1 || true
+ run "$herd" load root "$dst/$n.scm"
+ # Autostart services restart themselves from the loaded file;
+ # graphical ones only if a session is up (else they'd start
+ # with no compositor environment).
+ case " ${lib.concatStringsSep " " result.graphical} " in
+ *" $n "*)
+ if "$herd" status ${sh.graphicalSym} 2>/dev/null | grep -q 'It is running'; then
+ run "$herd" start "$n"
+ fi ;;
+ esac
+ fi
+ fi
+ done
+
+ if [ -z "$live" ]; then
+ warnEcho "targets.guix: user Shepherd not reachable; services register at next login via the Guix Home loader."
+ fi
+ '';
+ }
+ ]);
+}
diff --git a/users/ryan/modules/targets/guix/shepherd.nix b/users/ryan/modules/targets/guix/shepherd.nix
new file mode 100644
index 0000000..55750ba
--- /dev/null
+++ b/users/ryan/modules/targets/guix/shepherd.nix
@@ -0,0 +1,408 @@
+# systemd user units (as home-manager models them) -> GNU Shepherd services.
+#
+# Fail-closed by design: every [Section] Key must be either translated with
+# the same semantics, or on the explicit `ignoredKeys` list below (keys that
+# don't change what runs or with what privileges). Anything else is an
+# eval-time error, unless the user acknowledges it per unit via
+# `targets.guix.shepherd.unenforced`, in which case it's dropped *with a
+# build warning*. Sandboxing (Protect*, Private*, SystemCallFilter, ...) is
+# deliberately never translated: a service that declares it is relying on it.
+{ lib, setpriv }:
+let
+ inherit (lib)
+ concatStringsSep concatMapStringsSep concatMapStrings concatMap mapAttrs mapAttrsToList
+ filterAttrs attrNames hasSuffix removeSuffix optional optionals
+ optionalString elem toList last unique;
+
+ # ---------------------------------------------------------------- helpers
+
+ # JSON string escapes are a subset of Guile's (\" \\ \n \uXXXX).
+ q = builtins.toJSON;
+ sym = s: "(string->symbol ${q s})";
+
+ # HM's unit types leave null / [] placeholders for typed-but-unset keys
+ # (Description, Documentation, X-*-Triggers, Environment, ExecStart); HM's
+ # own INI renderer drops them, so do the same.
+ clean = unit:
+ filterAttrs (_: s: s != { })
+ (mapAttrs (_: filterAttrs (_: v: v != null && v != [ ])) unit);
+
+ str = v: if builtins.isBool v then (if v then "true" else "false") else toString v;
+ # Scalar keys: systemd's last assignment wins.
+ scalar = v: str (last (toList v));
+ isTrue = v: elem (scalar v) [ "true" "yes" "on" "1" ];
+
+ # ------------------------------------------------------- key allowlists
+
+ # Keys with no effect on what runs, how, or with what privileges.
+ ignoredKeys = {
+ Unit = [
+ "Description" # consumed as #:documentation
+ "Documentation"
+ "After" # pure ordering; dependencies below imply ordering in Shepherd
+ "Before"
+ "RefuseManualStart"
+ "RefuseManualStop"
+ "X-SwitchMethod"
+ ];
+ Service = [ "ExecReload" ]; # only reachable via `systemctl reload`
+ Socket = [ ];
+ Install = [ ];
+ };
+
+ limitMap = {
+ LimitCPU = "cpu"; LimitFSIZE = "fsize"; LimitDATA = "data";
+ LimitSTACK = "stack"; LimitCORE = "core"; LimitRSS = "rss";
+ LimitNOFILE = "nofile"; LimitAS = "as"; LimitNPROC = "nproc";
+ LimitMEMLOCK = "memlock";
+ };
+
+ translatedKeys = {
+ # Triggers are embedded in the generated file, so a trigger change is a
+ # content change and activation reloads the service (reload -> restart,
+ # the conservative direction).
+ Unit = [ "Wants" "Requires" "BindsTo" "PartOf" "X-Restart-Triggers" "X-Reload-Triggers" ];
+ Service = [
+ "Type" "ExecStart" "Environment" "WorkingDirectory" "Restart"
+ "RestartSec" "UMask" "NoNewPrivileges"
+ ] ++ attrNames limitMap;
+ Socket = [
+ "ListenStream" "FileDescriptorName" "Service" "Accept"
+ "SocketMode" "DirectoryMode"
+ ];
+ Install = [ "WantedBy" ];
+ };
+
+ sectionsFor = kind: [ "Unit" "Install" (if kind == "service" then "Service" else "Socket") ];
+
+ # ----------------------------------------------------- systemd specifiers
+
+ specExprs = {
+ t = ''(getenv "XDG_RUNTIME_DIR")'';
+ h = ''(getenv "HOME")'';
+ U = "(number->string (getuid))";
+ u = "(passwd:name (getpwuid (getuid)))";
+ "%" = q "%";
+ };
+ specParts = s: builtins.split "%(.)" s;
+ badSpecs = s:
+ concatMap
+ (p: optional (builtins.isList p && !(specExprs ? ${builtins.head p})) "%${builtins.head p}")
+ (specParts s);
+ # -> Scheme expression, evaluated at service start (not at load).
+ expand = s:
+ let
+ exprs = concatMap
+ (p: if builtins.isList p then [ specExprs.${builtins.head p} ] else optional (p != "") (q p))
+ (specParts s);
+ in
+ if exprs == [ ] then q ""
+ else if builtins.length exprs == 1 then builtins.head exprs
+ else "(string-append ${concatStringsSep " " exprs})";
+
+ # ------------------------------------------------- command-line splitting
+
+ # systemd quoting, minus the parts we refuse: backslash escapes (C-style in
+ # systemd, so not a literal-char escape) and $VAR expansion are rejected by
+ # cmdErrors rather than approximated.
+ tokenize = s:
+ let
+ ws = c: c == " " || c == "\t" || c == "\n";
+ step = st: c:
+ if st.q != null then
+ (if c == st.q then st // { q = null; } else st // { cur = st.cur + c; })
+ else if c == "\"" || c == "'" then
+ st // { q = c; cur = if st.cur == null then "" else st.cur; }
+ else if ws c then
+ (if st.cur == null then st else st // { out = st.out ++ [ st.cur ]; cur = null; })
+ else
+ st // { cur = (if st.cur == null then "" else st.cur) + c; };
+ end = builtins.foldl' step { out = [ ]; cur = null; q = null; }
+ (lib.stringToCharacters s);
+ in
+ {
+ tokens = end.out ++ optional (end.cur != null) end.cur;
+ unterminated = end.q != null;
+ };
+
+ cmdErrors = what: s:
+ let t = tokenize s; first = if t.tokens == [ ] then "" else builtins.head t.tokens;
+ in
+ optional (t.tokens == [ ]) "${what} is empty"
+ ++ optional t.unterminated "${what} has an unterminated quote"
+ ++ optional (lib.hasInfix "\\" s) "${what} uses backslash escapes (not translated)"
+ ++ optional (lib.hasInfix "$" s) "${what} uses $VAR expansion (not translated)"
+ ++ optional (builtins.match "[-@:+!|].*" first != null)
+ "${what} uses an executable prefix (${builtins.substring 0 1 first}) with no Shepherd equivalent"
+ ++ map (sp: "${what} uses unsupported specifier ${sp}") (badSpecs s);
+
+ # ----------------------------------------------------------- validation
+
+ octal = s: builtins.match "0?[0-7]{3,4}" s != null;
+ limitVal = s: builtins.match "(infinity|[0-9]+)(:(infinity|[0-9]+))?" s != null;
+ seconds = s: builtins.match "([0-9]+)s?" s;
+
+ keyErrors = { kind, name, unit, unenforced }:
+ let
+ full = "${name}.${kind}";
+ allowed = sectionsFor kind;
+ in
+ concatMap
+ (sec:
+ if !(elem sec allowed) then
+ optional (!(elem "${sec}.*" unenforced)) "${full}: section [${sec}] has no Shepherd equivalent"
+ else
+ concatMap
+ (key: optional
+ (!(elem key (translatedKeys.${sec} ++ ignoredKeys.${sec}))
+ && !(elem "${sec}.${key}" unenforced))
+ "${full}: ${sec}.${key} has no Shepherd equivalent")
+ (attrNames unit.${sec}))
+ (attrNames unit);
+
+ # Names Shepherd knows about, used to resolve dependency edges.
+ graphicalSym = "hm-graphical-session";
+
+ # Which service a socket unit activates (systemd default: same name).
+ socketTarget = sname: sock:
+ removeSuffix ".service" (scalar (sock.Socket.Service or "${sname}.service"));
+
+ resolveDep = { self, services, sockets }: dep:
+ if dep == "graphical-session.target" then { ok = graphicalSym; }
+ else if hasSuffix ".service" dep && services ? ${removeSuffix ".service" dep} then
+ { ok = removeSuffix ".service" dep; }
+ else if hasSuffix ".socket" dep && sockets ? ${removeSuffix ".socket" dep} then
+ let target = socketTarget (removeSuffix ".socket" dep) sockets.${removeSuffix ".socket" dep};
+ in if target == self then { skip = true; } else { ok = target; }
+ else { err = "dependency ${dep} is not a translated unit"; };
+
+ serviceErrors = { name, unit, services, sockets, unenforced }:
+ let
+ full = "${name}.service";
+ svc = unit.Service or { };
+ exec = toList (svc.ExecStart or [ ]);
+ deps = concatMap (k: toList (unit.Unit.${k} or [ ])) [ "Wants" "Requires" "BindsTo" "PartOf" ];
+ envTokens = concatMap (e: (tokenize (str e)).tokens) (toList (svc.Environment or [ ]));
+ limits = filterAttrs (k: _: limitMap ? ${k}) svc;
+ in
+ keyErrors { kind = "service"; inherit name unit unenforced; }
+ ++ optional (!(elem (scalar (svc.Type or "simple")) [ "simple" "exec" ]))
+ "${full}: Type=${scalar svc.Type} is not translated (only simple/exec)"
+ ++ optional (builtins.length exec != 1)
+ "${full}: needs exactly one ExecStart (has ${toString (builtins.length exec)})"
+ ++ concatMap (cmdErrors "${full}: ExecStart") (map str exec)
+ ++ concatMap (e: optional (!(lib.hasInfix "=" e)) "${full}: Environment entry '${e}' is not K=V") envTokens
+ ++ concatMap (e: map (sp: "${full}: Environment uses unsupported specifier ${sp}") (badSpecs e)) envTokens
+ ++ optional (lib.any (e: lib.hasInfix "$" e || lib.hasInfix "\\" e) (map str (toList (svc.Environment or [ ]))))
+ "${full}: Environment uses escapes or $VAR (not translated)"
+ ++ optionals (svc ? WorkingDirectory) (
+ let d = scalar svc.WorkingDirectory; in
+ optional (lib.hasPrefix "-" d) "${full}: WorkingDirectory=-... (ignore-missing) is not translated"
+ ++ map (sp: "${full}: WorkingDirectory uses unsupported specifier ${sp}") (badSpecs d))
+ ++ optional (svc ? Restart && !(elem (scalar svc.Restart) [ "no" "always" "on-failure" "on-abnormal" ]))
+ "${full}: Restart=${scalar svc.Restart} is not translated"
+ ++ optional (svc ? RestartSec && seconds (scalar svc.RestartSec) == null)
+ "${full}: RestartSec must be whole seconds"
+ ++ optional (svc ? UMask && !(octal (scalar svc.UMask))) "${full}: UMask must be octal"
+ ++ mapAttrsToList (k: v: "${full}: ${k}=${scalar v} is not a plain integer/infinity limit")
+ (filterAttrs (_: v: !(limitVal (scalar v))) limits)
+ ++ concatMap (d: let r = resolveDep { self = name; inherit services sockets; } d;
+ in optional (r ? err) "${full}: ${r.err}") deps
+ ++ concatMap (t: optional (!(elem t [ "default.target" "graphical-session.target" ]))
+ "${full}: WantedBy=${t} is not translated")
+ (toList (unit.Install.WantedBy or [ ]));
+
+ socketErrors = { name, unit, services, unenforced }:
+ let
+ full = "${name}.socket";
+ sock = unit.Socket or { };
+ target = socketTarget name unit;
+ paths = map str (toList (sock.ListenStream or [ ]));
+ dirMode = if sock ? DirectoryMode then scalar sock.DirectoryMode else null;
+ in
+ keyErrors { kind = "socket"; inherit name unit unenforced; }
+ ++ optional (!(services ? ${target})) "${full}: activates ${target}.service, which is not translated"
+ ++ optional (paths == [ ]) "${full}: no ListenStream"
+ ++ concatMap (p: optional (builtins.match "(/|%t|%h).*" p == null)
+ "${full}: ListenStream=${p} is not a unix socket path (TCP/UDP not translated)")
+ paths
+ ++ concatMap (p: map (sp: "${full}: ListenStream uses unsupported specifier ${sp}") (badSpecs p)) paths
+ ++ optional (sock ? Accept && isTrue sock.Accept) "${full}: Accept=yes (inetd-style) is not translated"
+ ++ optional (dirMode != null && !(octal dirMode)) "${full}: DirectoryMode must be octal"
+ # Shepherd can set the parent directory's mode but not the socket's. A
+ # 0700 parent makes the socket's own mode moot; anything looser doesn't.
+ ++ optional (sock ? SocketMode && !(elem dirMode [ "0700" "700" ]))
+ "${full}: SocketMode is only honoured with DirectoryMode=0700 (Shepherd can't chmod the socket)"
+ ++ concatMap (t: optional (t != "sockets.target") "${full}: WantedBy=${t} is not translated")
+ (toList (unit.Install.WantedBy or [ ]));
+
+ # ------------------------------------------------------------- emission
+
+ prelude = ''
+ (use-modules (shepherd service))
+
+ ;; Unit Environment= first, then Shepherd's *current* environment (which
+ ;; the compositor bridge updates via `herd eval root (setenv ...)`).
+ (define (hm-env overrides)
+ (let ((keys (map (lambda (kv) (substring kv 0 (string-index kv #\=)))
+ overrides)))
+ (append overrides
+ (filter (lambda (kv)
+ (let ((i (string-index kv #\=)))
+ (not (and i (member (substring kv 0 i) keys)))))
+ (environ)))))
+ '';
+
+ limitExpr = v:
+ let
+ parts = lib.splitString ":" (scalar v);
+ one = x: if x == "infinity" then "#f" else x;
+ in
+ if builtins.length parts == 1 then "${one (builtins.head parts)} ${one (builtins.head parts)}"
+ else "${one (builtins.elemAt parts 0)} ${one (builtins.elemAt parts 1)}";
+
+ octalExpr = s: "#o${lib.removePrefix "0" s}";
+
+ emitService = { name, unit, services, sockets }:
+ let
+ svc = unit.Service or { };
+ mySockets = filterAttrs (sn: s: socketTarget sn s == name) sockets;
+ socketActivated = mySockets != { };
+
+ argv0 = (tokenize (str (builtins.head (toList svc.ExecStart)))).tokens;
+ argv = optionals (svc ? NoNewPrivileges && isTrue svc.NoNewPrivileges)
+ [ "${setpriv}/bin/setpriv" "--no-new-privs" ]
+ ++ argv0;
+ cmd = "(list ${concatMapStringsSep " " expand argv})";
+
+ env = concatMap (e: (tokenize (str e)).tokens) (toList (svc.Environment or [ ]));
+ envExpr = "(hm-env (list ${concatMapStringsSep " " (kv:
+ let i = lib.stringLength (builtins.head (lib.splitString "=" kv)); in
+ "(string-append ${q (builtins.substring 0 (i + 1) kv)} ${expand (builtins.substring (i + 1) (-1) kv)})")
+ env}))";
+
+ deps = unique (concatMap
+ (d: let r = resolveDep { self = name; inherit services sockets; } d;
+ in optional (r ? ok) r.ok)
+ (concatMap (k: toList (unit.Unit.${k} or [ ])) [ "Wants" "Requires" "BindsTo" "PartOf" ]));
+ wantedBy = toList (unit.Install.WantedBy or [ ]);
+ graphical = elem "graphical-session.target" wantedBy || elem graphicalSym deps;
+ requirement = unique (deps ++ optional graphical graphicalSym);
+ autostart = elem "default.target" wantedBy
+ || lib.any (s: elem "sockets.target" (toList (s.Install.WantedBy or [ ]))) (lib.attrValues mySockets);
+
+ limits = filterAttrs (k: _: limitMap ? ${k}) svc;
+ opts = concatStringsSep "\n "
+ ([ "#:environment-variables ${envExpr}" ]
+ ++ optional (svc ? WorkingDirectory)
+ "#:directory ${let d = scalar svc.WorkingDirectory; in if d == "~" then specExprs.h else expand d}"
+ ++ optional (svc ? UMask) "#:file-creation-mask ${octalExpr (scalar svc.UMask)}"
+ ++ optional (limits != { }) "#:resource-limits (list ${concatStringsSep " "
+ (mapAttrsToList (k: v: "(list '${limitMap.${k}} ${limitExpr v})") limits)})");
+
+ endpoints = concatStringsSep "\n " (concatMap
+ (sn:
+ let
+ s = mySockets.${sn}.Socket;
+ fdName = scalar (s.FileDescriptorName or sn);
+ dirMode = if s ? DirectoryMode then octalExpr (scalar s.DirectoryMode) else "#o755";
+ in
+ map (p: "(endpoint (make-socket-address AF_UNIX ${expand (str p)}) #:name ${q fdName} #:socket-directory-permissions ${dirMode})")
+ (toList s.ListenStream))
+ (attrNames mySockets));
+
+ # Socket units keep listening after the daemon exits regardless of
+ # Restart= (that's socket-activation semantics), hence respawn? here.
+ respawn = socketActivated || elem (scalar (svc.Restart or "no")) [ "always" "on-failure" "on-abnormal" ];
+ restartSec = if svc ? RestartSec then builtins.head (seconds (scalar svc.RestartSec)) else null;
+
+ constructor =
+ if socketActivated then ''
+ (make-systemd-constructor ${cmd}
+ (list ${endpoints})
+ #:lazy-start? #t
+ ${opts})''
+ else ''
+ (make-forkexec-constructor ${cmd}
+ ${opts})'';
+ in
+ {
+ inherit graphical autostart;
+ text = ''
+ ;; Generated by home-manager (targets.guix) from ${name}.service${
+ optionalString socketActivated " + ${concatMapStringsSep ", " (s: "${s}.socket") (attrNames mySockets)}"
+ }.
+ ;; Do not edit; regenerated on every activation.${
+ concatMapStrings (t: "\n;; trigger: ${str t}")
+ (concatMap (k: toList (unit.Unit.${k} or [ ])) [ "X-Restart-Triggers" "X-Reload-Triggers" ])}
+ ${prelude}
+ (register-services
+ (list
+ (service (list ${sym name})
+ #:documentation ${q (scalar (unit.Unit.Description or "${name} (from home-manager)"))}
+ #:requirement (list ${concatMapStringsSep " " sym requirement})
+ #:respawn? ${if respawn then "#t" else "#f"}${
+ optionalString (restartSec != null) "\n #:respawn-delay ${restartSec}"}
+ ;; Constructed at start time so specifiers and the inherited
+ ;; environment reflect the session at that moment.
+ #:start (lambda args
+ (apply ${constructor}
+ args))
+ #:stop ${if socketActivated then "(make-systemd-destructor)" else "(make-kill-destructor)"})))
+ ${optionalString (autostart && !graphical) "\n(start-in-the-background (list ${sym name}))"}
+ '';
+ };
+
+ graphicalSessionFile = ''
+ ;; Generated by home-manager (targets.guix). Marker for graphical-session.target:
+ ;; started by the compositor bridge after it pushes its environment into
+ ;; Shepherd; stopping it stops every graphical service.
+ (use-modules (shepherd service))
+ (register-services
+ (list (service (list ${sym graphicalSym})
+ #:documentation "Graphical session (home-manager graphical-session.target)"
+ #:start (const #t)
+ #:stop (const #f))))
+ '';
+in
+{
+ inherit graphicalSym;
+
+ # -> { errors, warnings, files = { "<name>.scm" = text; }, graphical = [ names ] }
+ translate = { systemdUser, unenforced, ignoreUnits }:
+ let
+ keep = kind: units: filterAttrs (n: _: !(elem "${n}.${kind}" ignoreUnits)) (mapAttrs (_: clean) units);
+ services = keep "service" (systemdUser.services or { });
+ sockets = keep "socket" (systemdUser.sockets or { });
+ unen = full: unenforced.${full} or [ ];
+
+ # Units that make things happen on their own. Targets and slices are
+ # inert unless something references them, and any such reference
+ # (Wants=/WantedBy=/PartOf=/Slice=) is already an error above.
+ otherKinds = [ "timers" "paths" "mounts" "automounts" ];
+ otherErrors = concatMap
+ (k: map (n: "${n}.${removeSuffix "s" k}: ${removeSuffix "s" k} units are not translated (add to targets.guix.shepherd.ignoreUnits to skip)")
+ (attrNames (keep (removeSuffix "s" k) (systemdUser.${k} or { }))))
+ otherKinds;
+
+ errors =
+ concatMap (n: serviceErrors { name = n; unit = services.${n}; inherit services sockets; unenforced = unen "${n}.service"; }) (attrNames services)
+ ++ concatMap (n: socketErrors { name = n; unit = sockets.${n}; inherit services; unenforced = unen "${n}.socket"; }) (attrNames sockets)
+ ++ otherErrors
+ ++ concatMap (full: optional (!(services ? ${removeSuffix ".service" full}) && !(sockets ? ${removeSuffix ".socket" full}))
+ "targets.guix.shepherd.unenforced: ${full} is not a defined service/socket")
+ (attrNames unenforced);
+
+ warnings = concatMap
+ (full: map (k: "targets.guix: ${full}: ${k} dropped (not enforced under Shepherd)") unenforced.${full})
+ (attrNames unenforced);
+
+ emitted = mapAttrs (n: u: emitService { name = n; unit = u; inherit services sockets; }) services;
+ in
+ {
+ inherit errors warnings;
+ graphical = attrNames (filterAttrs (_: e: e.graphical) emitted);
+ files = { "${graphicalSym}.scm" = graphicalSessionFile; }
+ // lib.mapAttrs' (n: e: lib.nameValuePair "${n}.scm" e.text) emitted;
+ };
+}