Files
home-manager/modules/launchd/default.nix
Austin Horstman dfe33da05d launchd: document agent lifecycle and config
The option read only "Define LaunchAgents." and gave no hint about
where the plist files go, how activation loads and unloads them, or
what the default job label is.
2026-09-22 20:37:38 -05:00

626 lines
21 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
inherit (pkgs.stdenv.hostPlatform) isDarwin;
inherit (lib.generators) toPlist;
cfg = config.launchd;
labelPrefix = "org.nix-community.home.";
dstDir = "${config.home.homeDirectory}/Library/LaunchAgents";
launchdConfig =
{ name, config, ... }:
{
options = {
enable = lib.mkEnableOption name;
domain = lib.mkOption {
type = lib.types.enum [
"gui"
"user"
];
default = "gui";
example = "user";
description = ''
The launchd domain to bootstrap this agent into.
The `gui` domain is appropriate for agents that need the user's
Aqua session, such as window managers, hotkey daemons, and other
graphical tools. The `user` domain is appropriate for background
services that do not require a graphical login session.
'';
};
waitForNixStore = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = ''
Whether to start the agent through a `/bin/sh` wrapper that waits
for the Nix store to be mounted before running the agent's
command. This guards against launchd starting the agent before
the Nix store volume is available, for example early during login
when the store volume is encrypted.
When disabled, the agent's command is run through a launcher
script named after the agent instead (see
{option}`launchd.agents.<name>.launcher`). This makes the agent
appear under its own name, rather than as "sh", in System
Settings > Login Items & Extensions, but the agent will fail to
start if launchd runs it before the Nix store is mounted.
'';
};
launcher = {
name = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "nix-my-agent";
description = ''
Base name of the launcher script the agent is started through
when {option}`launchd.agents.<name>.waitForNixStore` is
disabled. This is the name macOS shows for the agent in
System Settings > Login Items & Extensions ("Allow in the
Background"), so a common prefix makes every Home Manager
agent recognisable at a glance.
Defaults to the agent's attribute name.
'';
};
shell = lib.mkOption {
type = lib.types.str;
default = "/bin/sh";
defaultText = lib.literalExpression ''"/bin/sh"'';
example = lib.literalExpression "lib.getExe pkgs.bash";
description = ''
Interpreter for the launcher script used when
{option}`launchd.agents.<name>.waitForNixStore` is disabled.
A launcher script lives in the Nix store either way, so a
store-resident interpreter adds no new failure mode. It does
change which binary macOS attributes the agent's process to
(for example for the privacy prompts that gate access to
`~/Desktop` and `~/Downloads`): with `/bin/sh` the
attribution follows Apple's shell, with a store-resident shell
it follows the launcher itself.
'';
};
};
config = lib.mkOption {
type = lib.types.submodule (import ./launchd.nix);
default = { };
example = {
ProgramArguments = [
"/usr/bin/say"
"Good afternoon"
];
StartCalendarInterval = [
{
Hour = 12;
Minute = 0;
}
];
};
description = ''
Define a launchd job. See {manpage}`launchd.plist(5)` for details.
'';
};
};
config = lib.mkMerge [
{
config.Label = lib.mkDefault "${labelPrefix}${name}";
}
(lib.mkIf (config.domain == "user") {
config.LimitLoadToSessionType = lib.mkDefault "Background";
})
];
};
# mutateConfig replaces the original Program and ProgramArguments. By
# default it calls /bin/sh with /bin/wait4path to wait for /nix/store
# before running the original command. This is intentional to fix the
# issue where launchd starts the agent before /nix/store is ready (before
# the Nix store is mounted.) When waitForNixStore is disabled, the command
# is run through a launcher script instead — named after the agent unless
# `launcher.name` says otherwise, interpreted by `launcher.shell` — so
# that the agent is displayed under its own name (rather than "sh") in
# System Settings > Login Items & Extensions.
mutateConfig =
name: v:
let
cnf = v.config;
args =
lib.optional (cnf.Program != null) cnf.Program
++ lib.optionals (cnf.ProgramArguments != null) cnf.ProgramArguments;
launcherName =
if v.launcher.name != null then v.launcher.name else lib.strings.sanitizeDerivationName name;
launcher = pkgs.writeScriptBin launcherName ''
#!${v.launcher.shell}
exec ${lib.escapeShellArgs args}
'';
in
(removeAttrs cnf [
"Program"
"ProgramArguments"
])
// {
ProgramArguments =
if v.waitForNixStore then
[
"/bin/sh"
"-c"
"/bin/wait4path /nix/store && exec ${lib.escapeShellArgs args}"
]
else
[ "${launcher}/bin/${launcherName}" ];
};
toAgent =
name: v:
pkgs.writeText "${v.config.Label}.plist" (toPlist { escape = true; } (mutateConfig name v));
agentPlists = lib.mapAttrs' (n: v: lib.nameValuePair "${v.config.Label}.plist" (toAgent n v)) (
lib.filterAttrs (_n: v: v.enable) cfg.agents
);
agentDomains = lib.mapAttrs' (
_n: v:
lib.nameValuePair "${v.config.Label}.domain" (
pkgs.writeText "${v.config.Label}.domain" "${v.domain}\n"
)
) (lib.filterAttrs (_n: v: v.enable) cfg.agents);
agentsDrv = pkgs.runCommand "home-manager-agents" { } ''
mkdir -p "$out"
declare -A plists
plists=(${
lib.concatStringsSep " " (lib.mapAttrsToList (name: value: "['${name}']='${value}'") agentPlists)
})
for dest in "''${!plists[@]}"; do
src="''${plists[$dest]}"
ln -s "$src" "$out/$dest"
done
'';
agentDomainsDrv = pkgs.runCommand "home-manager-agent-domains" { } ''
mkdir -p "$out"
declare -A domains
domains=(${
lib.concatStringsSep " " (lib.mapAttrsToList (name: value: "['${name}']='${value}'") agentDomains)
})
for dest in "''${!domains[@]}"; do
src="''${domains[$dest]}"
ln -s "$src" "$out/$dest"
done
'';
in
{
meta.maintainers = with lib.maintainers; [
khaneliman
midchildan
];
options.launchd = {
enable = lib.mkOption {
type = lib.types.bool;
default = isDarwin;
defaultText = lib.literalExpression "pkgs.stdenv.hostPlatform.isDarwin";
description = ''
Whether to enable Home Manager to define per-user daemons by making use
of launchd's LaunchAgents.
'';
};
agents = lib.mkOption {
type = with lib.types; attrsOf (submodule launchdConfig);
default = { };
example = {
greeting = {
enable = true;
config = {
ProgramArguments = [
"/usr/bin/say"
"Good afternoon"
];
StartCalendarInterval = [
{
Hour = 12;
Minute = 0;
}
];
};
};
};
description = ''
Per-user launchd agents on Darwin. Set each agent's `enable` option
to `true` to manage it with Home Manager.
During activation, Home Manager installs enabled agents as
user-owned plist files in {file}`~/Library/LaunchAgents` and loads
them into their configured [](#opt-launchd.agents._name_.domain).
Changed agents are unloaded and loaded again; agents removed from
the configuration are unloaded and their plist files are removed.
Home Manager skips removal if a plist file was modified outside
Home Manager.
Each agent's `config` contains the job properties described in
{manpage}`launchd.plist(5)`. Unless overridden with `config.Label`,
the job label is `org.nix-community.home.<name>`, where `<name>` is
the attribute name in this option.
'';
};
};
config = lib.mkMerge [
{
assertions = [
{
assertion = (cfg.enable && agentPlists != { }) -> isDarwin;
message =
let
names = lib.concatStringsSep ", " (lib.attrNames agentPlists);
in
"Must use Darwin for modules that require Launchd: " + names;
}
];
}
(lib.mkIf isDarwin {
home.extraBuilderCommands = ''
ln -s "${agentsDrv}" $out/LaunchAgents
ln -s "${agentDomainsDrv}" $out/LaunchAgentDomains
'';
# NOTE: Launch Agent configurations can't be symlinked from the Nix store
# because it needs to be owned by the user running it.
home.activation.setupLaunchAgents =
lib.hm.dag.entryAfter [ "writeBoundary" ] # Bash
''
# Get the OS version to see if we can use --wait
version="$(/usr/bin/sw_vers --productVersion | cut -d. -f 1)"
# Disable errexit to ensure we process all agents even if some fail
set +e
readAgentDomain() {
local domainsDir="$1"
local agentName="$2"
local domainFile="$domainsDir/$agentName.domain"
if [[ -n "$domainsDir" && -f "$domainFile" ]]; then
local domainName
domainName="$(<"$domainFile")"
case "$domainName" in
gui|user)
printf '%s\n' "$domainName"
;;
*)
printf 'gui\n'
;;
esac
else
printf 'gui\n'
fi
}
resolveDomain() {
local domainName="$1"
case "$domainName" in
gui)
printf 'gui/%s\n' "$UID"
;;
user)
printf 'user/%s\n' "$UID"
;;
esac
}
agentIsLoaded() {
local domain="$1"
local agentName="$2"
run /bin/launchctl print "$domain/$agentName" >/dev/null 2>&1
}
# Stop an agent if it's running
bootoutAgent() {
local domain="$1"
local agentName="$2"
verboseEcho "Stopping agent '$domain/$agentName'..."
local bootout_output
if [[ "$version" -ge "26" ]]; then
if bootout_output=$(run /bin/launchctl bootout --wait "$domain/$agentName" 2>&1); then
# --wait already makes sure it was unloaded (BUT we can only use it in >=26)
return 0
fi
else
if bootout_output=$(run /bin/launchctl bootout "$domain/$agentName" 2>&1); then
# Otherwise on <26 give the system a moment to fully unload the agent
run sleep 1
return 0
fi
fi
# At this point we know exit code is not 0 because otherwise we
# would have returned by now
# Only show warning if it's not a common harmless error
if [[ "$bootout_output" != *"No such process"* && "$bootout_output" != *"Domain does not support specified action"* ]]; then
warnEcho "Failed to stop agent '$domain/$agentName': $bootout_output"
return 1
else
verboseEcho "Agent '$domain/$agentName' was not running"
return 2
fi
}
installAgentFile() {
local srcPath="$1"
local dstPath="$2"
local agentName="$3"
verboseEcho "Installing agent file to $dstPath"
if ! run install -Dm444 -T "$srcPath" "$dstPath"; then
errorEcho "Failed to install agent file for '$agentName'"
return 1
fi
return 0
}
bootstrapAgent() {
local domain="$1"
local dstPath="$2"
local agentName="$3"
verboseEcho "Starting agent '$domain/$agentName'"
local bootstrap_output
bootstrap_output=$(run /bin/launchctl bootstrap "$domain" "$dstPath" 2>&1) || {
if [[ "$bootstrap_output" == *"Bootstrap failed: 5: Input/output error"* ]]; then
errorEcho "Failed to start agent '$domain/$agentName' with I/O error (code 5)"
errorEcho "This typically happens when the agent wasn't unloaded before attempting to bootstrap the new agent."
else
errorEcho "Failed to start agent '$domain/$agentName' with error: $bootstrap_output"
fi
return 1
}
verboseEcho "Successfully started agent '$domain/$agentName'"
return 0
}
restoreAgent() {
local oldSrcPath="$1"
local dstPath="$2"
local oldDomain="$3"
local agentName="$4"
if [[ ! -f "$oldSrcPath" ]]; then
warnEcho "Cannot restore previous agent '$oldDomain/$agentName', old file '$oldSrcPath' is missing"
return 1
fi
warnEcho "Restoring previous agent '$oldDomain/$agentName'"
installAgentFile "$oldSrcPath" "$dstPath" "$agentName" \
&& bootstrapAgent "$oldDomain" "$dstPath" "$agentName"
}
processAgent() {
local srcPath="$1"
local dstDir="$2"
local oldDir="$3"
local oldDomainsDir="$4"
local newDomainsDir="$5"
local agentFile="''${srcPath##*/}"
local agentName="''${agentFile%.plist}"
local dstPath="$dstDir/$agentFile"
local oldSrcPath=""
local oldDomainName
local newDomainName
local oldDomain
local newDomain
local oldAgentBootedOut=0
oldDomainName="$(readAgentDomain "$oldDomainsDir" "$agentName")"
newDomainName="$(readAgentDomain "$newDomainsDir" "$agentName")"
oldDomain="$(resolveDomain "$oldDomainName")"
newDomain="$(resolveDomain "$newDomainName")"
if [[ -n "$oldDir" ]]; then
oldSrcPath="$oldDir/$agentFile"
fi
# Skip if unchanged
if cmp -s "$srcPath" "$dstPath" && [[ "$oldDomainName" == "$newDomainName" ]]; then
if agentIsLoaded "$newDomain" "$agentName"; then
verboseEcho "Agent '$newDomain/$agentName' is already up-to-date"
return 0
else
verboseEcho "Agent '$newDomain/$agentName' is up-to-date but not loaded"
fi
fi
verboseEcho "Processing agent '$newDomain/$agentName'"
# Stop/Unload agent if it's already running
if [[ -f "$dstPath" ]]; then
bootoutAgent "$oldDomain" "$agentName"
case "$?" in
0)
oldAgentBootedOut=1
;;
2)
;;
*)
return 1
;;
esac
fi
if [[ "$oldDomainName" != "$newDomainName" ]]; then
bootoutAgent "$newDomain" "$agentName"
case "$?" in
0|2)
;;
*)
if [[ "$oldAgentBootedOut" -eq 1 ]]; then
restoreAgent "$oldSrcPath" "$dstPath" "$oldDomain" "$agentName"
fi
return 1
;;
esac
fi
if ! installAgentFile "$srcPath" "$dstPath" "$agentName"; then
if [[ "$oldAgentBootedOut" -eq 1 ]]; then
restoreAgent "$oldSrcPath" "$dstPath" "$oldDomain" "$agentName"
fi
return 1
fi
if bootstrapAgent "$newDomain" "$dstPath" "$agentName"; then
return 0
fi
if [[ "$oldAgentBootedOut" -eq 1 ]]; then
restoreAgent "$oldSrcPath" "$dstPath" "$oldDomain" "$agentName"
fi
return 1
}
removeAgent() {
local srcPath="$1"
local dstDir="$2"
local newDir="$3"
local oldDomainsDir="$4"
local agentFile="''${srcPath##*/}"
local agentName="''${agentFile%.plist}"
local dstPath="$dstDir/$agentFile"
local domainName
local domain
domainName="$(readAgentDomain "$oldDomainsDir" "$agentName")"
domain="$(resolveDomain "$domainName")"
if [[ -e "$newDir/$agentFile" ]]; then
verboseEcho "Agent '$agentName' still exists in new generation, skipping cleanup"
return 0
fi
if [[ ! -e "$dstPath" ]]; then
verboseEcho "Agent file '$dstPath' already removed"
return 0
fi
if ! cmp -s "$srcPath" "$dstPath"; then
warnEcho "Skipping deletion of '$dstPath', since its contents have diverged"
return 0
fi
# Stop and remove the agent
bootoutAgent "$domain" "$agentName"
case "$?" in
0|2)
;;
*)
return 1
;;
esac
verboseEcho "Removing agent file '$dstPath'"
if run rm -f $VERBOSE_ARG "$dstPath"; then
verboseEcho "Successfully removed agent file for '$agentName'"
else
warnEcho "Failed to remove agent file '$dstPath'"
return 1
fi
return 0
}
setupLaunchAgents() {
local oldDir newDir oldDomainsDir newDomainsDir dstDir launchdStatus
newDir="$(readlink -m "$newGenPath/LaunchAgents")"
newDomainsDir="$(readlink -m "$newGenPath/LaunchAgentDomains")"
dstDir=${lib.escapeShellArg dstDir}
launchdStatus=0
if [[ -n "''${oldGenPath:-}" ]]; then
oldDir="$(readlink -m "$oldGenPath/LaunchAgents")"
if [[ ! -d "$oldDir" ]]; then
verboseEcho "No previous LaunchAgents directory found"
oldDir=""
fi
oldDomainsDir="$(readlink -m "$oldGenPath/LaunchAgentDomains")"
if [[ ! -d "$oldDomainsDir" ]]; then
oldDomainsDir=""
fi
else
oldDir=""
oldDomainsDir=""
fi
verboseEcho "Setting up LaunchAgents in $dstDir"
if [[ ! -d "$dstDir" ]] && ! run mkdir -p "$dstDir"; then
errorEcho "Failed to create LaunchAgents directory '$dstDir'"
return 1
fi
verboseEcho "Processing new/updated LaunchAgents..."
while IFS= read -r srcPath; do
processAgent "$srcPath" "$dstDir" "$oldDir" "$oldDomainsDir" "$newDomainsDir" \
|| launchdStatus=1
done < <(find -L "$newDir" -maxdepth 1 -name '*.plist' -type f)
# Skip cleanup if there's no previous generation
if [[ -z "$oldDir" || ! -d "$oldDir" ]]; then
if [[ "$launchdStatus" -ne 0 ]]; then
errorEcho "Failed to activate one or more LaunchAgents"
fi
verboseEcho "LaunchAgents setup complete"
return "$launchdStatus"
fi
verboseEcho "Cleaning up removed LaunchAgents..."
while IFS= read -r srcPath; do
removeAgent "$srcPath" "$dstDir" "$newDir" "$oldDomainsDir" \
|| launchdStatus=1
done < <(find -L "$oldDir" -maxdepth 1 -name '*.plist' -type f)
if [[ "$launchdStatus" -ne 0 ]]; then
errorEcho "Failed to activate one or more LaunchAgents"
fi
return "$launchdStatus"
}
launchdStatus=0
setupLaunchAgents || launchdStatus=$?
# Restore errexit
set -e
if [[ "$launchdStatus" -ne 0 ]]; then
exit "$launchdStatus"
fi
'';
})
];
}