From dc89b0c0bb4c019e522ee2e2fcbf2e183a3caa5f Mon Sep 17 00:00:00 2001 From: Austin Horstman Date: Wed, 17 Jun 2026 20:42:49 -0500 Subject: [PATCH] launchd: add agent domain option Allow Darwin LaunchAgents to target either the GUI or user launchd domain while preserving gui as the compatibility default. --- docs/release-notes/rl-2611.md | 6 + modules/launchd/default.nix | 115 ++++++++++++++++-- .../misc/news/2026/06/2026-06-18_02-02-00.nix | 15 +++ tests/modules/launchd/agent-domain.nix | 20 +++ tests/modules/launchd/agents.nix | 10 ++ tests/modules/launchd/default.nix | 5 +- 6 files changed, 159 insertions(+), 12 deletions(-) create mode 100644 modules/misc/news/2026/06/2026-06-18_02-02-00.nix create mode 100644 tests/modules/launchd/agent-domain.nix diff --git a/docs/release-notes/rl-2611.md b/docs/release-notes/rl-2611.md index 972377d3b8..3a25cb22f9 100644 --- a/docs/release-notes/rl-2611.md +++ b/docs/release-notes/rl-2611.md @@ -14,6 +14,12 @@ This release has the following notable changes: and `programs.uv.python.prune` / `programs.uv.tool.prune` make the managed set fully declarative by removing versions and tools that are no longer listed. +- On Darwin, Home Manager launchd agents now support + [](#opt-launchd.agents._name_.domain) to choose either the user's GUI or + background launchd domain. Background services provided by Home Manager now + use the user domain, which allows activations from contexts such as SSH + sessions without requiring an active graphical login session. + ## State Version Changes {#sec-release-26.11-state-version-changes} The state version in this release includes the changes below. These diff --git a/modules/launchd/default.nix b/modules/launchd/default.nix index 15d45cd0a9..9253626a8b 100644 --- a/modules/launchd/default.nix +++ b/modules/launchd/default.nix @@ -18,6 +18,22 @@ let { 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. + ''; + }; config = lib.mkOption { type = lib.types.submodule (import ./launchd.nix); default = { }; @@ -74,6 +90,13 @@ let _n: v: lib.nameValuePair "${v.config.Label}.plist" (toAgent v.config) ) (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" @@ -87,6 +110,20 @@ let 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; [ @@ -129,6 +166,7 @@ in (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 @@ -139,6 +177,40 @@ in # 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 + } + # Stop an agent if it's running bootoutAgent() { local domain="$1" @@ -190,26 +262,36 @@ in processAgent() { local srcPath="$1" local dstDir="$2" - local domain="$3" + local oldDomainsDir="$3" + local newDomainsDir="$4" local agentFile="''${srcPath##*/}" local agentName="''${agentFile%.plist}" local dstPath="$dstDir/$agentFile" + local oldDomainName + local newDomainName + local oldDomain + local newDomain + + oldDomainName="$(readAgentDomain "$oldDomainsDir" "$agentName")" + newDomainName="$(readAgentDomain "$newDomainsDir" "$agentName")" + oldDomain="$(resolveDomain "$oldDomainName")" + newDomain="$(resolveDomain "$newDomainName")" # Skip if unchanged - if cmp -s "$srcPath" "$dstPath"; then - verboseEcho "Agent '$agentName' is already up-to-date" + if cmp -s "$srcPath" "$dstPath" && [[ "$oldDomainName" == "$newDomainName" ]]; then + verboseEcho "Agent '$newDomain/$agentName' is already up-to-date" return 0 fi - verboseEcho "Processing agent '$agentName'" + verboseEcho "Processing agent '$newDomain/$agentName'" # Stop/Unload agent if it's already running if [[ -f "$dstPath" ]]; then - bootoutAgent "$domain" "$agentName" + bootoutAgent "$oldDomain" "$agentName" fi - installAndBootstrapAgent "$srcPath" "$dstPath" "$domain" "$agentName" + installAndBootstrapAgent "$srcPath" "$dstPath" "$newDomain" "$agentName" # Note: We continue processing even if this agent fails return 0 } @@ -218,11 +300,16 @@ in local srcPath="$1" local dstDir="$2" local newDir="$3" - local domain="$4" + 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" @@ -253,11 +340,11 @@ in } setupLaunchAgents() { - local oldDir newDir dstDir domain + local oldDir newDir oldDomainsDir newDomainsDir dstDir newDir="$(readlink -m "$newGenPath/LaunchAgents")" + newDomainsDir="$(readlink -m "$newGenPath/LaunchAgentDomains")" dstDir=${lib.escapeShellArg dstDir} - domain="gui/$UID" if [[ -n "''${oldGenPath:-}" ]]; then oldDir="$(readlink -m "$oldGenPath/LaunchAgents")" @@ -265,8 +352,14 @@ in 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" @@ -274,7 +367,7 @@ in verboseEcho "Processing new/updated LaunchAgents..." find -L "$newDir" -maxdepth 1 -name '*.plist' -type f | while read -r srcPath; do - processAgent "$srcPath" "$dstDir" "$domain" + processAgent "$srcPath" "$dstDir" "$oldDomainsDir" "$newDomainsDir" done # Skip cleanup if there's no previous generation @@ -285,7 +378,7 @@ in verboseEcho "Cleaning up removed LaunchAgents..." find -L "$oldDir" -maxdepth 1 -name '*.plist' -type f | while read -r srcPath; do - removeAgent "$srcPath" "$dstDir" "$newDir" "$domain" + removeAgent "$srcPath" "$dstDir" "$newDir" "$oldDomainsDir" done } diff --git a/modules/misc/news/2026/06/2026-06-18_02-02-00.nix b/modules/misc/news/2026/06/2026-06-18_02-02-00.nix new file mode 100644 index 0000000000..2fcab71a69 --- /dev/null +++ b/modules/misc/news/2026/06/2026-06-18_02-02-00.nix @@ -0,0 +1,15 @@ +{ config, pkgs, ... }: + +{ + time = "2026-06-18T02:02:00+00:00"; + condition = pkgs.stdenv.hostPlatform.isDarwin && config.launchd.enable; + message = '' + + Home Manager launchd agents now support the + `launchd.agents..domain` option. Background services provided by Home + Manager use the user launchd domain by default, so they can be managed from + SSH and other non-graphical sessions. Set + `launchd.agents..domain = "gui"` for agents that need the graphical + session. + ''; +} diff --git a/tests/modules/launchd/agent-domain.nix b/tests/modules/launchd/agent-domain.nix new file mode 100644 index 0000000000..5d672529bb --- /dev/null +++ b/tests/modules/launchd/agent-domain.nix @@ -0,0 +1,20 @@ +{ + config = { + launchd.agents."user-service" = { + enable = true; + domain = "user"; + config.ProgramArguments = [ + "/some/command" + ]; + }; + + nmt.script = '' + serviceFile=LaunchAgents/org.nix-community.home.user-service.plist + assertFileExists $serviceFile + + domainFile=LaunchAgentDomains/org.nix-community.home.user-service.domain + assertFileExists $domainFile + assertFileContent $domainFile ${builtins.toFile "expected-domain" "user\n"} + ''; + }; +} diff --git a/tests/modules/launchd/agents.nix b/tests/modules/launchd/agents.nix index 76e6fa65e7..f872714ca9 100644 --- a/tests/modules/launchd/agents.nix +++ b/tests/modules/launchd/agents.nix @@ -22,6 +22,16 @@ serviceFile=LaunchAgents/org.nix-community.home.test-service.plist assertFileExists $serviceFile assertFileContent $serviceFile ${./expected-agent.plist} + + domainFile=LaunchAgentDomains/org.nix-community.home.test-service.domain + assertFileExists $domainFile + assertFileContent $domainFile ${builtins.toFile "expected-domain" "gui\n"} + + assertFileExists activate + assertFileContains activate 'readAgentDomain' + assertFileContains activate 'resolveDomain' + assertFileContains activate "printf 'gui/%s" + assertFileContains activate "printf 'user/%s" ''; }; } diff --git a/tests/modules/launchd/default.nix b/tests/modules/launchd/default.nix index 09549ee7b3..ae20c148a8 100644 --- a/tests/modules/launchd/default.nix +++ b/tests/modules/launchd/default.nix @@ -1 +1,4 @@ -{ launchd-agents = ./agents.nix; } +{ + launchd-agent-domain = ./agent-domain.nix; + launchd-agents = ./agents.nix; +}