From 182fb7e6839112483aabeefd0e2acb8526dfa5f4 Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Sat, 17 Oct 2026 00:57:13 -0700 Subject: [PATCH 1/2] feat(darwin): activate the homes and hjem configurations inside a machine A home or hjem configuration now takes as its machine the nearest NixOS or darwin configuration above it, found with the new `caisson.integrations.nearestOf`. A home inside a darwin machine gets `osClass = "darwin"` and `darwinConfig`, and is published as `@`. caisson's new `default` darwin module gives each home the home-manager module `darwin-parent` and each hjem configuration the hjem module `darwin-parent`, which set the options that come from the machine's account. It imports `home-manager-activation`, which activates the homes in `postActivation` as home-manager's nix-darwin module does, and `hjem-activation`, which writes the launchd agents of hjem's nix-darwin module. The agents are in `/Library/LaunchAgents`, so they run for every user and need no `system.primaryUser`, and they are restarted from `postActivation`, after nix-darwin installs them. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- configs/flake/caisson/partitions/checks.nix | 23 +- docs/development/testing-architecture.md | 2 +- docs/development/testing.md | 7 +- lib-overlays/hjem/default.nix | 19 +- lib-overlays/home-manager/default.nix | 44 ++-- lib-overlays/integrations/default.nix | 21 ++ modules/darwin/default/caisson/default.nix | 13 + .../darwin/default/caisson/forChildren.nix | 32 +++ modules/darwin/default/default.nix | 34 +++ modules/darwin/hjem-activation/default.nix | 239 ++++++++++++++++++ .../home-manager-activation/default.nix | 110 ++++++++ modules/hjem/darwin-parent/default.nix | 41 +++ modules/homeManager/darwin-parent/default.nix | 61 +++++ tests/darwin-system/default.nix | 46 +++- tests/pinned-world/default.nix | 51 +++- tests/unit/lib-overlays.nix | 48 ++++ 17 files changed, 757 insertions(+), 36 deletions(-) create mode 100644 modules/darwin/default/caisson/default.nix create mode 100644 modules/darwin/default/caisson/forChildren.nix create mode 100644 modules/darwin/default/default.nix create mode 100644 modules/darwin/hjem-activation/default.nix create mode 100644 modules/darwin/home-manager-activation/default.nix create mode 100644 modules/hjem/darwin-parent/default.nix create mode 100644 modules/homeManager/darwin-parent/default.nix diff --git a/README.md b/README.md index c22104a2..801fea49 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ source by. | `flake-parts` | `flake-parts` | flake outputs | | `nixpkgs` | `nixpkgs` | package sets and overlays | | `nixos` | `nixpkgs` | NixOS configurations | -| `darwin` | `nix-darwin` | macOS machines, evaluated with nix-darwin and published as the `darwinConfigurations` that `darwin-rebuild` reads | +| `darwin` | `nix-darwin` | macOS machines, evaluated with nix-darwin and published as the `darwinConfigurations` that `darwin-rebuild` reads; they activate the homes and hjem configurations declared inside them | | `nixos-minimal` | `nixpkgs` | the `nixos` class under the minimal evaluator (no NixOS base modules); a second integration over the class `nixos` owns | | `home-manager` | `home-manager` | Home Manager configurations | | `home-manager-minimal` | `home-manager` | the `homeManager` class with Home Manager's necessary modules alone, the configuration importing what it uses from `modulesPath`; a second integration over the class `home-manager` owns | diff --git a/configs/flake/caisson/partitions/checks.nix b/configs/flake/caisson/partitions/checks.nix index 36cc9239..0b6ac347 100644 --- a/configs/flake/caisson/partitions/checks.nix +++ b/configs/flake/caisson/partitions/checks.nix @@ -290,11 +290,26 @@ # same machine on any host; building it needs a darwin builder, # so the darwin job of the workflow builds it on a macOS runner. flake.checks.aarch64-darwin.darwin-system = - (import (self.outPath + "/tests/darwin-system") { - inputs = inputs // { - caisson = self; + let + probe = import (self.outPath + "/tests/darwin-system") { + inputs = inputs // { + caisson = self; + }; }; - }).toplevel; + in + # The bare machine, and the machine with a home and an hjem + # configuration inside it, whose system holds the activation + # of the home and the launchd agents of hjem. + probe.pkgs.linkFarm "darwin-system" [ + { + name = "probe"; + path = probe.toplevel; + } + { + name = "family"; + path = probe.family.system; + } + ]; }; }; diff --git a/docs/development/testing-architecture.md b/docs/development/testing-architecture.md index e44f741e..4f4def03 100644 --- a/docs/development/testing-architecture.md +++ b/docs/development/testing-architecture.md @@ -313,6 +313,6 @@ The checks partition merges outputs from all test and example flakes into the | `debug-disabled` | `checks.nix` (inline) | `self.debug` is not exposed in production | | `pinned-world` | `checks.nix` + `tests/pinned-world/` | caisson composed with the pinned upstream world (nixpkgs, home-manager, colmena, disko, nix-darwin, hjem, terranix, system-manager) evaluates end to end; the `drift` workflow runs the same check against advanced pins | | `hjem-system` | `checks.nix` + `tests/hjem-system/` | The manifest file of an hjem configuration inside a NixOS machine builds, which checks it against hjem's schema with `cue vet`, and so do the units that activate it, which runs shellcheck on their scripts | -| `darwin-system` (aarch64-darwin) | `checks.nix` + `tests/darwin-system/` | The macOS machine of the pinned-world suite, declared through the darwin integration, builds; the `darwin-build` job of the check workflow builds it on a macOS runner | +| `darwin-system` (aarch64-darwin) | `checks.nix` + `tests/darwin-system/` | The two macOS machines of the pinned-world suite, declared through the darwin integration, build: the bare machine, and the machine with a home and an hjem configuration inside it; the `darwin-build` job of the check workflow builds them on a macOS runner | | `argument-errors` | `checks.nix` + `tests/argument-errors/` | The argument error of every entry point comes from Nix itself, raised at the call site with no frame of caisson | | `eval-weight` | `checks.nix` + `tests/eval-weight/` | Framework evaluation cost held to committed ceilings ([guide](https://nix-caisson.github.io/docs/eval-weight.html)) | diff --git a/docs/development/testing.md b/docs/development/testing.md index 7d416c7c..6b3b3a13 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -27,14 +27,15 @@ plumbing works. terranix, system-manager) and exercised end to end: a home configuration, a NixOS system with homes declared inside it, a colmena hive, disk layouts with the machines installed onto them, a - darwin machine evaluated at aarch64-darwin, hjem configurations on + darwin machine evaluated at aarch64-darwin with and without a home + and an hjem configuration inside it, hjem configurations on their own and inside a NixOS machine, a terranix and a system-manager configuration, and the composition guarantees of the real exported overlays. The `hjem-system` check builds the manifest file of the hjem configuration inside the machine, which checks it against hjem's schema, and the units that activate it, which runs shellcheck on - their scripts. The darwin machine of the suite is also the one check at - aarch64-darwin, `checks.aarch64-darwin.darwin-system`, which the + their scripts. The two darwin machines of the suite are also the one + check at aarch64-darwin, `checks.aarch64-darwin.darwin-system`, which the `darwin-build` job of the check workflow builds on a macOS runner. Every upstream expectation caisson relies on is a probe here. - **Location:** `tests/pinned-world/default.nix`; the pins in `tests/dependencies/flake.lock`. diff --git a/lib-overlays/hjem/default.nix b/lib-overlays/hjem/default.nix index 6e936afb..5adc3221 100644 --- a/lib-overlays/hjem/default.nix +++ b/lib-overlays/hjem/default.nix @@ -130,10 +130,13 @@ null; # The machine the configuration is declared inside, when there - # is one: the childless evaluation of the nearest NixOS - # configuration above it, which hjem's modules receive as - # `osConfig`, null when there is none. - machine = manifest.nearest.nixos or null; + # is one: the childless evaluation of the nearest NixOS or + # darwin configuration above it, which hjem's modules receive + # as `osConfig`, null when there is none. + machine = selection.nearestOf [ + "nixos" + "darwin" + ] manifest; osConfig = if machine != null then machine.value.config else null; # A user hjem's NixOS module embeds also imports hjem's @@ -143,7 +146,7 @@ # a NixOS machine that has `utils` gets the same; the minimal # NixOS evaluator gives none. machineArgs = if machine != null then (machine.value._module or { }).args or { } else { }; - nixosUser = machineArgs ? utils; + nixosUser = machine != null && machine.type == "nixos" && machineArgs ? utils; # What hjem's OS modules set on a user that caisson sets too: # the user's name from the name the configuration is declared @@ -169,9 +172,13 @@ hjem-lib = hjemLib; name = if name != null then name else "hjem"; } + # hjem's OS modules hand a user the options of the machine + # as `osOptions`, and the NixOS module also hands it `utils`. + // lib.optionalAttrs (machine != null) { + osOptions = machine.value.options; + } // lib.optionalAttrs nixosUser { inherit (machineArgs) utils; - osOptions = machine.value.options; } // (if (args.specialArgs or null) != null then args.specialArgs else { }); modules = [ diff --git a/lib-overlays/home-manager/default.nix b/lib-overlays/home-manager/default.nix index 8e06eee1..ee5f8191 100644 --- a/lib-overlays/home-manager/default.nix +++ b/lib-overlays/home-manager/default.nix @@ -234,15 +234,14 @@ home.username = lib.mkDefault name; }; - # The NixOS configuration the home is declared inside, when - # there is one. It is the nearest NixOS configuration above - # the home, so a structural configuration between the two - # changes nothing. The manifest holds the childless - # evaluation of that NixOS configuration, which is the - # evaluation that leaves out the configurations declared - # inside it. The home is one of those, so the full + # The machine the home is declared inside, when there is one: + # the nearest NixOS or darwin configuration above the home, so + # a structural configuration between the two changes nothing. + # The manifest holds the childless evaluation of that machine, + # which is the evaluation that leaves out the configurations + # declared inside it. The home is one of those, so the full # evaluation would depend on the home. - machine = manifest.nearest.nixos or null; + machine = selection.nearestOf machineTypes manifest; # home-manager hands a home the configuration of its machine # as the module argument `osConfig`, and null when the home @@ -287,6 +286,8 @@ # home-manager also hands a home inside a machine the class # of the machine, as `osClass`. A home with no machine gets # no such argument from home-manager, so none is added here. + # home-manager's nix-darwin module also hands a home the + # machine as `darwinConfig`. # # home-manager's legacy argument `nixosConfig` is left at # null, the value home-manager gives it in a home with no @@ -294,7 +295,8 @@ # `nixosConfig.home-manager`, an option that only # home-manager's NixOS module declares, and caisson does not # use that module. - // (if machine != null then { osClass = "nixos"; } else { }) + // (if machine != null then { osClass = machine.type; } else { }) + // (if machine != null && machine.type == "darwin" then { darwinConfig = osConfig; } else { }) // (if (args.specialArgs or null) != null then args.specialArgs else { }); # The module tree the evaluation reads: home-manager's # module list, its module files and the `modulesPath` special @@ -469,27 +471,37 @@ }; }; + # The integrations whose configurations are machines a home can + # be declared inside. + machineTypes = [ + "nixos" + "darwin" + ]; + # A home is published as `homeConfigurations.`, the # evaluated home, which is what the home-manager CLI reads. A - # home beneath a NixOS configuration is named `@`, - # the name it is passed up under and the name that NixOS - # configuration is declared under, which is where the CLI looks - # (`$USER@$(hostname)`); a home with no NixOS configuration above - # it keeps its name. + # home beneath a NixOS or darwin configuration is named + # `@`, the name it is passed up under and the name + # that machine is declared under, which is where the CLI looks + # (`$USER@$(hostname)`); a home with no machine above it keeps + # its name. exportsTo = { attrset = "homeConfigurations"; value = manifest: manifest.value; name = { name, manifest }: let - host = (manifest.nearest.nixos or { }).name or null; + machine = final.caisson.integrations.nearestOf machineTypes manifest; + host = if machine == null then null else machine.name or null; in if host == null then { value = name; } else { value = "${name}@${host}"; - description = "named ${name}@${host} from its name and the name of the NixOS configuration it is under"; + description = "named ${name}@${host} from its name and the name of the ${ + { nixos = "NixOS"; }.${machine.type} or machine.type + } configuration it is under"; }; }; diff --git a/lib-overlays/integrations/default.nix b/lib-overlays/integrations/default.nix index 15f40754..90a3c88c 100644 --- a/lib-overlays/integrations/default.nix +++ b/lib-overlays/integrations/default.nix @@ -565,6 +565,26 @@ genAttrs declaring (name: config.caisson.${name}.configurations) ); + # The nearest configuration above `manifest` whose integration is + # one of `types`, null when there is none. The manifest records + # the nearest ancestor of each integration; of those, the nearest + # is the one with the most ancestors itself. A home reads its + # machine this way, which is a NixOS or a darwin configuration. + nearestOf = + types: manifest: + builtins.foldl' ( + found: type: + let + candidate = (manifest.nearest or { }).${type} or null; + in + if candidate == null then + found + else if found == null || builtins.length candidate.ancestors > builtins.length found.ancestors then + candidate + else + found + ) null types; + isManifest = value: builtins.isAttrs value && (value._type or null) == "caisson-manifest"; # How a configuration is published: the `exportsTo` its @@ -811,6 +831,7 @@ entriesOf configurationsModuleFor integrationsIn + nearestOf mkEvaluation publish topValue diff --git a/modules/darwin/default/caisson/default.nix b/modules/darwin/default/caisson/default.nix new file mode 100644 index 00000000..9a7c0c1c --- /dev/null +++ b/modules/darwin/default/caisson/default.nix @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MIT +# +# The `caisson.*` part of the default darwin module. +{ closure-lib, ... }: +{ ... }: +{ + imports = [ + # `forChildren.nix` sets options under `caisson.forChildren`. The + # core module declares those options. + closure-lib.caisson.modules.darwin.core + (import ./forChildren.nix { inherit closure-lib; }) + ]; +} diff --git a/modules/darwin/default/caisson/forChildren.nix b/modules/darwin/default/caisson/forChildren.nix new file mode 100644 index 00000000..f70efda5 --- /dev/null +++ b/modules/darwin/default/caisson/forChildren.nix @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: MIT +# +# What a darwin configuration gives the homes and the hjem +# configurations declared inside it. +# +# A configuration gives things to the configurations declared inside +# it through the options under `caisson.forChildren`. This file adds +# the home-manager module `darwin-parent` to the modules that a home +# declared inside this darwin configuration imports by default, and +# the hjem module `darwin-parent` to those of an hjem configuration +# declared inside it. Each sets the options that come from the machine: +# the home directory from the account of the user, and for a home the +# user ID and the Nix of the machine. +# +# A darwin configuration that passes `moduleImports` selects its +# modules itself, and then nothing inside it gets these modules from +# here. A home or hjem configuration that passes `moduleImports` +# selects its modules itself, and gets the module only if it lists it. +# +# `closure-lib` is the library of caisson, where the modules are +# registered. +{ closure-lib }: +{ ... }: +{ + caisson.forChildren.extraDefaultModuleImports.homeManager = _lib: [ + closure-lib.caisson.modules.homeManager.darwin-parent + ]; + + caisson.forChildren.extraDefaultModuleImports.hjem = _lib: [ + closure-lib.caisson.modules.hjem.darwin-parent + ]; +} diff --git a/modules/darwin/default/default.nix b/modules/darwin/default/default.nix new file mode 100644 index 00000000..2982630a --- /dev/null +++ b/modules/darwin/default/default.nix @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MIT +# +# The `default` darwin module caisson registers. A darwin configuration +# imports every registered module named `default` unless it passes +# `moduleImports`, so this is what a darwin configuration gets from +# caisson when it does not select its modules itself. +# +# It holds what a macOS machine does for the homes and the hjem +# configurations declared inside it: +# +# - `./caisson/forChildren.nix` gives each home the home-manager module +# `darwin-parent`, and each hjem configuration the hjem module +# `darwin-parent`. Both set the options that come from the machine. +# - The registered module `home-manager-activation` activates the homes +# when the machine is activated. +# - The registered module `hjem-activation` writes the launchd agents +# that apply the hjem configurations. +# +# The parts read options of caisson's core module. The core module +# leaves its options out when the library of the evaluation is not a +# caisson composition, so the parts are left out in the same case. +{ closure-lib, mkModule, ... }: +{ lib, ... }: +{ + imports = + if lib ? caisson && lib ? caisson-core then + [ + (mkModule ./caisson) + closure-lib.caisson.modules.darwin.home-manager-activation + closure-lib.caisson.modules.darwin.hjem-activation + ] + else + [ ]; +} diff --git a/modules/darwin/hjem-activation/default.nix b/modules/darwin/hjem-activation/default.nix new file mode 100644 index 00000000..00cb8a9a --- /dev/null +++ b/modules/darwin/hjem-activation/default.nix @@ -0,0 +1,239 @@ +# SPDX-License-Identifier: MIT +# +# A darwin module with which a macOS machine activates the hjem +# configurations declared inside it. +# +# Activating an hjem configuration means applying its manifest with +# hjem's CLI. hjem has a nix-darwin module that does this for the users +# it embeds, with a launchd agent, `org.hjem.activate`, that applies the +# manifest of the user it runs for and keeps the state in +# `~/Library/Application Support/Hjem`, and restarts the agent of each +# user from the machine's activation script, with `launchctl +# kickstart`. caisson does not use that nix-darwin module, and this +# module writes the same agent, script and restart. +# +# hjem's nix-darwin module declares the agent in `launchd.user.agents`, +# which nix-darwin installs for `system.primaryUser` alone. This module +# declares it in `launchd.agents`, which nix-darwin installs in +# `/Library/LaunchAgents`, where launchd loads it for every user who +# logs in, so each hjem user gets the agent, and the machine needs no +# primary user for it. The agent exits for a user no hjem configuration +# here names. +# +# The module also writes hjem's second agent, `org.nix.link-nix-apps`, +# which links the applications of the packages of the user into +# `~/Applications/Nix User Apps`, adds each user's `packages` to +# `users.users..packages`, and keeps each user's +# `extraDependencies` in the system, in `/etc/hjem/extra-dependencies`, +# as hjem's nix-darwin module does. +# +# Which configurations: the enabled hjem configurations this darwin +# configuration passes up, leaving out those declared inside a machine +# that is itself inside this one, and those evaluated for another +# system. caisson's `default` darwin module imports this module. +# +# The module reads the hjem configurations from the full evaluation of +# the darwin configuration; in the childless evaluation it defines +# nothing. +{ closure-lib, ... }: +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + + isActivatedHere = + entry: + entry.manifest.type == "hjem" + && !(builtins.any (segment: segment.type == "nixos" || segment.type == "darwin") ( + lib.lists.init entry.path + )) + && entry.manifest.system == evalManifest.system; + + users = + builtins.map + (entry: { + inherit (entry.manifest.value.config) user packages extraDependencies; + inherit (entry.manifest.outputs) manifestFile cli; + }) + ( + builtins.filter (entry: isActivatedHere entry && entry.manifest.value.config.enable) ( + config.caisson.exports.configurations + ) + ); + + newManifests = pkgs.linkFarm "hjem-manifests" ( + builtins.map (user: { + name = "manifest-${user.user}.json"; + path = user.manifestFile; + }) users + ); + + # The prefix hjem's CLI gives the backup of a file it replaces, the + # default of hjem's nix-darwin module. + prefix = ".backup-"; + + # The agent sets `hjem` to the CLI of the configuration of the user it + # runs for, and exits for any other user. + selectUser = '' + case "$USER" in + ${lib.concatMapStrings (user: '' + ${lib.escapeShellArg user.user}) hjem=${lib.getExe user.cli} ;; + '') users} + *) exit 0 ;; + esac + ''; + + activateLabel = "org.hjem.activate"; + appsLabel = "org.nix.link-nix-apps"; + + # The activation step that restarts an agent of each user who has a + # session, as hjem's nix-darwin module does. + kick = + label: + lib.concatMapStringsSep "\n" (user: '' + if uid="$(${lib.getExe' pkgs.coreutils-full "id"} -u ${lib.escapeShellArg user.user} 2>/dev/null)"; then + /bin/launchctl kickstart -k "gui/''${uid}/${label}" 2>/dev/null || true + fi + '') users; + + names = builtins.map (user: user.user) users; + repeated = lib.lists.unique ( + builtins.filter (name: lib.lists.count (other: other == name) names > 1) names + ); +in +{ + # The module reads `config.caisson.exports.configurations`. The core + # module declares that option. + imports = [ closure-lib.caisson.modules.darwin.core ]; + + config = lib.mkIf (!evalManifest.childless && users != [ ]) { + assertions = [ + { + assertion = repeated == [ ]; + message = "caisson: this darwin configuration activates more than one hjem configuration for ${lib.concatStringsSep ", " repeated}."; + } + ]; + + users.users = builtins.listToAttrs ( + builtins.map (user: { + name = user.user; + value.packages = user.packages; + }) users + ); + + # nix-darwin has no `system.extraDependencies`; hjem's nix-darwin + # module keeps the dependencies in the system this way. + environment.etc."hjem/extra-dependencies".text = lib.concatLines ( + builtins.concatMap (user: builtins.map (path: "${path}") user.extraDependencies) users + ); + + launchd.agents = { + hjem-activate.serviceConfig = { + Program = lib.getExe ( + pkgs.writeShellApplication { + name = "hjem-activate"; + runtimeInputs = [ pkgs.coreutils-full ]; + text = '' + set -euo pipefail + + USER="$(id -un)" + ${selectUser} + NEW="${newManifests}/manifest-''${USER}.json" + + if [ ! -f "$NEW" ]; then + exit 0 + fi + + STATE_DIR="$HOME/Library/Application Support/Hjem" + mkdir -p "$STATE_DIR" + CUR="$STATE_DIR/manifest.json" + + "$hjem" internal activate \ + --manifest "$NEW" \ + --state "$CUR" \ + --actions-file "$STATE_DIR/actions.json" \ + --prefix "${prefix}" + ''; + } + ); + Label = activateLabel; + RunAtLoad = true; + StandardOutPath = "/var/tmp/hjem-activate.out"; + StandardErrorPath = "/var/tmp/hjem-activate.err"; + }; + + link-nix-apps.serviceConfig = { + Program = lib.getExe ( + pkgs.writeShellApplication { + name = "link-nix-apps"; + runtimeInputs = with pkgs; [ + coreutils-full + findutils + gnugrep + nix + ]; + text = '' + set -euo pipefail + + USER="$(id -un)" + ${selectUser} + GROUP="$(id -gn)" + DEST="$HOME/Applications/Nix User Apps" + PROFILE="/etc/profiles/per-user/$USER" + + install -d -m 0755 -o "$USER" -g "$GROUP" "$DEST" + + desired="$(mktemp -t desired-apps.XXXXXX)" + trap 'rm -f "$desired"' EXIT + + nix-store -qR "$PROFILE" | while IFS= read -r p; do + apps="$p/Applications" + if [ -d "$apps" ]; then + find "$apps" -maxdepth 1 -type d -name "*.app" -print0 \ + | while IFS= read -r -d "" app; do + bname="$(basename "$app")" + echo "$bname" >> "$desired" + ln -sfn "$app" "$DEST/$bname" + done + fi + done + + sort -u "$desired" -o "$desired" + + find "$DEST" -maxdepth 1 -type l -name "*.app" -print0 \ + | while IFS= read -r -d "" link; do + name="$(basename "$link")" + if ! grep -Fxq "$name" "$desired"; then + rm -f "$link" + fi + done + + # Remove broken links + find "$DEST" -maxdepth 1 -type l -name "*.app" -print0 \ + | xargs -0 -I {} bash -c '[[ -e "{}" ]] || rm -f "{}"' + ''; + } + ); + Label = appsLabel; + RunAtLoad = true; + StandardOutPath = "/var/tmp/link-nix-apps.out"; + StandardErrorPath = "/var/tmp/link-nix-apps.err"; + }; + }; + + # The restart runs in `postActivation`, after nix-darwin's `launchd` + # step installs the agents of the new generation. nix-darwin runs a + # fixed list of activation steps; hjem's nix-darwin module restarts + # the activation agent from a step of another name, which nix-darwin + # does not run, and the applications agent from `applications`, + # which runs before the agents are installed. + system.activationScripts.postActivation.text = lib.mkAfter '' + ${kick activateLabel} + ${kick appsLabel} + ''; + }; +} diff --git a/modules/darwin/home-manager-activation/default.nix b/modules/darwin/home-manager-activation/default.nix new file mode 100644 index 00000000..ef0f7fc0 --- /dev/null +++ b/modules/darwin/home-manager-activation/default.nix @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: MIT +# +# A darwin module with which a macOS machine activates the homes +# declared inside it. +# +# Activating a home means running the `activate` script of its +# activation package as the user of the home. home-manager has a +# nix-darwin module that does this for the homes it embeds: a step of +# the machine's `postActivation` script runs `activate` for each home, +# as its user, through `launchctl asuser` and `sudo`. caisson does not +# use that nix-darwin module. A home declared inside a darwin +# configuration is a caisson configuration, and this module adds the +# same step, so that `darwin-rebuild switch` also applies the homes. +# +# caisson's `default` darwin module imports this module. A darwin +# configuration that passes `moduleImports` selects its modules +# itself, and then its homes are activated only if it lists this +# module. +# +# The module activates the home-manager configurations that this +# darwin configuration passes up, leaving out those declared inside a +# machine that is itself inside this one, and those evaluated for +# another system. It expects the machine to declare the account of the +# user of each home in `users.users`, as home-manager's nix-darwin +# module does, and refuses a home whose user has none. +# +# The step runs `activate` with no driver version, as caisson's NixOS +# activation does: the script also updates the home-manager profile of +# the user, so the user can run `home-manager switch` on the same +# profile. home-manager's nix-darwin module passes `--driver-version 1`, +# which leaves the profile alone. +# +# The module reads the homes from the full evaluation of the darwin +# configuration, so in the childless evaluation it defines nothing. +{ closure-lib, ... }: +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + + # True for an entry this machine activates. `entry.path` is the path + # from this darwin configuration to the configuration of the entry, a + # list of `{ type, name }` segments. + isActivatedHere = + entry: + let + above = lib.lists.init entry.path; + in + entry.manifest.type == "home-manager" + && !(builtins.any (segment: segment.type == "nixos" || segment.type == "darwin") above) + && entry.manifest.system == evalManifest.system; + + homes = builtins.map (entry: { + # The name the home is published under, for messages. + name = entry.manifest.name; + inherit (entry.value.config.home) username; + inherit (entry.value) activationPackage; + }) (builtins.filter isActivatedHere config.caisson.exports.configurations); + + withoutAccount = builtins.filter (home: !(config.users.users ? ${home.username})) homes; + + usernames = builtins.map (home: home.username) homes; + repeated = lib.lists.unique ( + builtins.filter (username: lib.lists.count (other: other == username) usernames > 1) usernames + ); + + # The step for one home: the step home-manager's nix-darwin module + # writes, with `DRY_RUN` passed on when the machine is activated dry. + stepOf = home: '' + echo Activating home-manager configuration for ${home.username} >&2 + hmDryRunArgs=() + hmParentArgs="$(ps -p "$PPID" -ww -o args= || true)" + if [[ -v DRY_RUN || "$hmParentArgs" == *" --dry-run"* ]]; then + hmDryRunArgs=(env DRY_RUN=1) + fi + launchctl asuser "$(id -u ${home.username})" sudo -u ${home.username} --set-home "''${hmDryRunArgs[@]}" ${pkgs.writeShellScript "activation-${home.username}" '' + exec ${home.activationPackage}/activate >&2 + ''} + ''; +in +{ + # The module reads `config.caisson.exports.configurations`. The core + # module declares that option. + imports = [ closure-lib.caisson.modules.darwin.core ]; + + config = lib.mkIf (!evalManifest.childless && homes != [ ]) { + assertions = [ + { + assertion = withoutAccount == [ ]; + message = '' + caisson: this darwin configuration has a home whose user has no account in `users.users`. + ${lib.concatMapStringsSep "\n" ( + home: " the home `${home.name}` is for the user `${home.username}`" + ) withoutAccount} + Declare the account in `users.users`, or check `home.username` of the home. + ''; + } + { + assertion = repeated == [ ]; + message = "caisson: this darwin configuration activates more than one home for ${lib.concatStringsSep ", " repeated}."; + } + ]; + + system.activationScripts.postActivation.text = lib.concatMapStringsSep "\n" stepOf homes; + }; +} diff --git a/modules/hjem/darwin-parent/default.nix b/modules/hjem/darwin-parent/default.nix new file mode 100644 index 00000000..f4306004 --- /dev/null +++ b/modules/hjem/darwin-parent/default.nix @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: MIT +# +# An hjem module for an hjem configuration declared inside a darwin +# configuration. It sets the home directory of the user from the +# account the machine declares, as hjem's nix-darwin module does for +# the users it embeds, and refuses a user the machine has no account +# for. +# +# caisson does not use hjem's nix-darwin module. A darwin configuration +# adds this module to the modules that the hjem configurations declared +# inside it import by default; caisson's `default` darwin module does +# that, through `caisson.forChildren`. An hjem configuration that passes +# `moduleImports` selects its modules itself, and gets this module only +# if it lists it. +# +# The module reads the machine through the manifest of the hjem +# configuration, so it can only be imported by an hjem configuration +# that has a darwin configuration above it. +{ ... }: +# `lib` is the library of the hjem configuration, and `config` is its +# configuration. +{ lib, config, ... }: +let + machine = lib.caisson.evalManifest.nearest.darwin.value.config; + account = machine.users.users.${config.user} or null; +in +{ + config = lib.mkMerge [ + { + assertions = [ + { + assertion = config.enable -> account != null; + message = "The hjem user `${config.user}` has no account in `users.users` of the darwin configuration it is declared inside."; + } + ]; + } + (lib.mkIf (account != null) { + directory = lib.mkDefault account.home; + }) + ]; +} diff --git a/modules/homeManager/darwin-parent/default.nix b/modules/homeManager/darwin-parent/default.nix new file mode 100644 index 00000000..64112a12 --- /dev/null +++ b/modules/homeManager/darwin-parent/default.nix @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: MIT +# +# A home-manager module for a home that is declared inside a darwin +# configuration. It sets the options of the home that come from the +# machine, as home-manager's nix-darwin module does for the homes it +# embeds: the home directory and the user ID from the account the +# machine declares, and the Nix the machine runs. caisson does not use +# that nix-darwin module. +# +# A darwin configuration adds this module to the modules that the +# homes declared inside it import by default. caisson's `default` +# darwin module does that, through `caisson.forChildren`. A home that +# passes `moduleImports` selects its modules itself, and gets this +# module only if it lists it. +# +# The module reads the machine through the manifest of the home, so it +# can only be imported by a home that has a darwin configuration above +# it. +{ ... }: +# `lib` is the library of the home, and `config` is the configuration +# of the home. +{ lib, config, ... }: +let + # The configuration of the darwin configuration the home is declared + # inside, in its childless evaluation, which leaves out the + # configurations declared inside it. + machine = lib.caisson.evalManifest.nearest.darwin.value.config; + + # The account of the user of the home, when the machine declares one. + account = machine.users.users.${config.home.username} or null; + + # nix-darwin can declare `uid` without a value, so it is read with + # `tryEval`, as home-manager's nix-darwin module reads it. + uid = if account == null then null else builtins.tryEval account.uid; +in +{ + config = lib.mkMerge [ + { + # home-manager reads this option to tell that the home is + # activated by a machine. `externalPackageInstall` stays false, + # so the activation of the home installs the packages of the home. + submoduleSupport.enable = true; + + nix.enable = machine.nix.enable; + nix.package = lib.mkIf machine.nix.enable machine.nix.package; + } + + (lib.mkIf (account != null) { + home.homeDirectory = account.home; + home.uid = lib.mkIf (uid.success && uid.value != null) uid.value; + }) + + # home-manager leaves its command-line program out of a home that + # has `submoduleSupport.enable` set. The program is how the user + # activates the home by hand, with `home-manager switch`, so it is + # put back. + (lib.mkIf config.programs.home-manager.enable { + home.packages = [ config.programs.home-manager.package ]; + }) + ]; +} diff --git a/tests/darwin-system/default.nix b/tests/darwin-system/default.nix index 88bc32ac..56372d7a 100644 --- a/tests/darwin-system/default.nix +++ b/tests/darwin-system/default.nix @@ -6,9 +6,11 @@ # tree itself. # # The composition is at aarch64-darwin, with a package config whose set -# there is nixpkgs at aarch64-darwin, and with nixpkgs and nix-darwin -# declared as its ecosystems. A structural top declares the machine -# `probe` from `machine`. Evaluating it needs no Mac: the package set +# there is nixpkgs at aarch64-darwin, and with nixpkgs, nix-darwin, +# home-manager and hjem declared as its ecosystems. A structural top declares the machine +# `probe` from `machine`, and the machine `family`, which is `machine` +# with a home and an hjem configuration for the user `probe` declared +# inside it. Evaluating them needs no Mac: the package set # and the derivations are evaluated for aarch64-darwin wherever the # evaluation runs. Building the system needs a darwin builder. { inputs }: @@ -24,7 +26,12 @@ let systems = [ system ]; projects.caisson = inputs.caisson; defaultEcosystemSrc = { - inherit (inputs) nixpkgs nix-darwin; + inherit (inputs) + nixpkgs + nix-darwin + home-manager + hjem + ; }; pkgSets = _lib: { default = @@ -62,6 +69,16 @@ let }; }; + # A home and an hjem configuration for the user `probe`. + home = { + home.stateVersion = "25.05"; + home.file.".probe".text = "home"; + }; + hjemUser = { + files.".config/probe/greeting".text = "hjem"; + packages = [ pkgs.hello ]; + }; + top = lib.caisson.structural.mkTopConfiguration { moduleImports = _modules: [ ]; configModule = @@ -70,11 +87,28 @@ let caisson.darwin.configurations.probe = lib.caisson.darwin.mkConfiguration { configModule = machine; }; + # The same machine, with the home and the hjem configuration of + # `probe` declared inside it. + caisson.darwin.configurations.family = lib.caisson.darwin.mkConfiguration { + configModule = + { lib, ... }: + { + imports = [ machine ]; + caisson.home-manager.configurations.probe = lib.caisson.home-manager.mkConfiguration { + configModule = home; + }; + caisson.hjem.configurations.probe = lib.caisson.hjem.mkConfiguration { + configModule = hjemUser; + }; + }; + }; }; }; in { inherit + hjemUser + home lib machine pkgs @@ -84,4 +118,8 @@ in # The machine as darwin-rebuild reads it, and the system it builds. configuration = top.darwinConfigurations.probe; toplevel = top.darwinConfigurations.probe.system; + # The machine with the home and the hjem configuration, and the + # manifests of the two inside it. + family = top.darwinConfigurations.family; + familyManifest = top.caisson.manifest.children.system.${system}.children.darwin.family; } diff --git a/tests/pinned-world/default.nix b/tests/pinned-world/default.nix index d0dab88f..b1d6d6b9 100644 --- a/tests/pinned-world/default.nix +++ b/tests/pinned-world/default.nix @@ -1403,11 +1403,60 @@ let ]; }; in - builtins.attrNames probe.top.darwinConfigurations == [ "probe" ] + builtins.attrNames probe.top.darwinConfigurations == [ + "family" + "probe" + ] && probe.toplevel.drvPath == upstream.system.drvPath && probe.configuration.config.nixpkgs.hostPlatform.system == "aarch64-darwin" && probe.configuration.pkgs.hello.drvPath == probe.pkgs.hello.drvPath; + # A home and an hjem configuration inside a darwin machine take the + # home directory and the user ID from the account the machine + # declares, and the machine activates both. Its `postActivation` + # step runs the activation of the home as the user, as + # home-manager's nix-darwin module does, and it installs hjem's + # launchd agent in `/Library/LaunchAgents`, which applies a manifest + # equal to the one hjem's nix-darwin module makes from the same user + # module. The home is published as `probe@family`. + darwinActivatesItsHomes = + let + probe = import ../darwin-system { inherit inputs; }; + config = probe.family.config; + homeManifest = probe.familyManifest.children.system.${probe.system}.children.home-manager.probe; + hjemManifest = probe.familyManifest.children.system.${probe.system}.children.hjem.probe; + upstream = inputs.nix-darwin.lib.darwinSystem { + inherit (probe) pkgs; + modules = [ + { nixpkgs.hostPlatform = probe.system; } + inputs.hjem.darwinModules.default + probe.machine + { hjem.users.probe = probe.hjemUser; } + ]; + }; + hjemLib = import "${inputs.hjem}/lib.nix" { + inherit (probe.pkgs) lib; + inherit (probe) pkgs; + }; + upstreamManifest = probe.lib.caisson.hjem.manifestOf hjemLib upstream.config.hjem.users.probe; + postActivation = config.system.activationScripts.postActivation.text; + hasInfix = probe.pkgs.lib.strings.hasInfix; + in + homeManifest.value.config.home.homeDirectory == "/Users/probe" + && homeManifest.value.config.home.uid == 501 + && hasInfix "-activation-probe" postActivation + && hasInfix "launchctl asuser" postActivation + && hjemManifest.value.config.directory == "/Users/probe" + && builtins.toJSON hjemManifest.outputs.manifest == builtins.toJSON upstreamManifest + && config.launchd.agents.hjem-activate.serviceConfig.Label == "org.hjem.activate" + && config.launchd.user.agents == { } + && hasInfix "gui/\${uid}/org.hjem.activate" postActivation + && builtins.elem "hello" ( + builtins.map (package: package.pname or "") config.users.users.probe.packages + ) + && builtins.attrNames probe.top.homeConfigurations == [ "probe@family" ] + && builtins.attrNames probe.top.hjemConfigurations == [ "probe" ]; + # An hjem configuration that is a top gives what hjem's standalone # CLI reads from a file: the version 3 manifest of the user's files, # with targets in the user's home directory, and the packages. The diff --git a/tests/unit/lib-overlays.nix b/tests/unit/lib-overlays.nix index f5eef058..f54bf934 100644 --- a/tests/unit/lib-overlays.nix +++ b/tests/unit/lib-overlays.nix @@ -761,6 +761,54 @@ in declared = [ "nixos" ]; }; }; + + # The machine of a home is the nearest NixOS or darwin configuration + # above it: of the nearest ancestor of each integration, the one with + # the most ancestors. + "test: nearestOf finds the nearest ancestor of the given integrations" = { + expr = + let + outer = { + name = "outer"; + ancestors = [ "lib" ]; + }; + inner = { + name = "inner"; + ancestors = [ + "lib" + "outer" + ]; + }; + nearestOf = lib.caisson.integrations.nearestOf [ + "nixos" + "darwin" + ]; + in + { + nearer = + (nearestOf { + nearest = { + nixos = outer; + darwin = inner; + }; + }).name; + nearerFirst = + (nearestOf { + nearest = { + nixos = inner; + darwin = outer; + }; + }).name; + one = (nearestOf { nearest.darwin = outer; }).name; + other = nearestOf { nearest.structural = inner; }; + }; + expected = { + nearer = "inner"; + nearerFirst = "inner"; + one = "outer"; + other = null; + }; + }; }; mkLib = { From bde541806928667061386e12d0200a7e5ab2ef5a Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Sat, 17 Oct 2026 01:46:44 -0700 Subject: [PATCH 2/2] fix(darwin): stop setting the hjem CLI in the applications agent The applications agent of `hjem-activation` started with the case that sets the CLI of the user, which that agent does not run, and shellcheck refused the unused variable when the system was built on macOS. It now starts with a case that only exits for a user no hjem configuration here names. Co-Authored-By: Claude Opus 5.5 --- modules/darwin/hjem-activation/default.nix | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/modules/darwin/hjem-activation/default.nix b/modules/darwin/hjem-activation/default.nix index 00cb8a9a..b9594e8c 100644 --- a/modules/darwin/hjem-activation/default.nix +++ b/modules/darwin/hjem-activation/default.nix @@ -87,6 +87,15 @@ let esac ''; + # The start of the applications agent, which runs no hjem CLI: it + # exits for a user no hjem configuration here names. + requireUser = '' + case "$USER" in + ${lib.concatMapStringsSep " | " (user: lib.escapeShellArg user.user) users}) ;; + *) exit 0 ;; + esac + ''; + activateLabel = "org.hjem.activate"; appsLabel = "org.nix.link-nix-apps"; @@ -180,7 +189,7 @@ in set -euo pipefail USER="$(id -un)" - ${selectUser} + ${requireUser} GROUP="$(id -gn)" DEST="$HOME/Applications/Nix User Apps" PROFILE="/etc/profiles/per-user/$USER"