diff --git a/users/ryan/common.nix b/users/ryan/common.nix
index aaca782..e2e5890 100644
--- a/users/ryan/common.nix
+++ b/users/ryan/common.nix
@@ -1,7 +1,7 @@
{ config, pkgs, lib, ... }:
{
- imports = [ ./modules/nvim ];
+ imports = [ ./modules/nvim ./modules/aria2 ./modules/zen ];
news.display = "silent";
@@ -11,6 +11,10 @@
# My own modules
ryan.neovim.enable = true;
+ ryan.aria2.enable = true;
+
+ ryan.zen.enable = true;
+
programs.starship = {
enable = true;
settings = {
@@ -27,14 +31,6 @@
};
};
- # Package/platform overrides (darwinDefaultsId, package = null for homebrew,
- # etc.) live in darwin.nix/linux.nix. Policies/profile are shared.
- programs.zen-browser = {
- enable = true;
- policies = import ./zen/zenPolicies.nix;
- profiles.default = import ./zen/zenProfile.nix;
- };
-
programs.eza = {
enable = true;
enableZshIntegration = true;
@@ -120,6 +116,7 @@
services.gpg-agent = {
enable = pkgs.stdenv.isLinux;
enableSshSupport = true;
+ enableExtraSocket = true;
};
programs.fzf = {
@@ -133,13 +130,16 @@
brewPath = if pkgs.stdenv.isDarwin && pkgs.stdenv.isAarch64 then ''
eval "$(/opt/homebrew/bin/brew shellenv)"
'' else "";
- in ''
- export GPG_TTY="$(tty)"
- export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
- gpgconf --launch gpg-agent
- gpg-connect-agent updatestartuptty /bye > /dev/null
- ${brewPath}
- '';
+ in lib.mkMerge [
+ (lib.mkOrder 550 "ZVM_INIT_MODE=sourcing")
+ ''
+ export GPG_TTY="$(tty)"
+ export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
+ gpgconf --launch gpg-agent
+ gpg-connect-agent updatestartuptty /bye > /dev/null
+ ${brewPath}
+ ''
+ ];
shellAliases = {
cat = "bat --paging=never";
diff = "delta";
@@ -202,8 +202,9 @@
recursive = true;
};
+ xdg.enable = true;
+
home.sessionVariables = {
- XDG_CONFIG_HOME = "${config.home.homeDirectory}/.config";
EDITOR = "${pkgs.neovim}/bin/nvim";
};
diff --git a/users/ryan/darwin.nix b/users/ryan/darwin.nix
index 568306a..0744b2f 100644
--- a/users/ryan/darwin.nix
+++ b/users/ryan/darwin.nix
@@ -10,14 +10,10 @@
ryan.sketchybar.enable = true;
- programs.zen-browser = {
- package = null; # managed via homebrew
- darwinDefaultsId = "app.zen-browser.zen";
- };
-
# Need special config for gpg-agent on macOS
home.file.".gnupg/gpg-agent.conf".text = ''
pinentry-program ${pkgs.pinentry_mac}/Applications/pinentry-mac.app/Contents/MacOS/pinentry-mac
+ extra-socket ${config.home.homeDirectory}/.gnupg/S.gpg-agent.extra
enable-ssh-support
'';
diff --git a/users/ryan/linux.nix b/users/ryan/linux.nix
index add81df..241ed3f 100644
--- a/users/ryan/linux.nix
+++ b/users/ryan/linux.nix
@@ -107,6 +107,7 @@
chromium
foot
kdePackages.kdenlive
+ obs-studio
waybar
fuzzel
@@ -119,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/aria2/default.nix b/users/ryan/modules/aria2/default.nix
new file mode 100644
index 0000000..8082fc1
--- /dev/null
+++ b/users/ryan/modules/aria2/default.nix
@@ -0,0 +1,185 @@
+# users/ryan/modules/aria2/default.nix
+#
+# aria2c RPC daemon + Zen hand-off via baptistecdr's "Aria2 Integration"
+# (AMO slug aria2-extension). The RPC secret is generated once at activation
+# into $XDG_STATE_HOME/aria2/rpc-secret (0600) and never touches the repo or
+# the store; the same activation splices it into both aria2's runtime conf and
+# the extension's storage.sync row.
+{ config, lib, pkgs, ... }:
+let
+ cfg = config.ryan.aria2;
+ zen = config.programs.zen-browser;
+ inherit (pkgs.stdenv.hostPlatform) isDarwin isLinux;
+
+ stateDir = "${config.xdg.stateHome}/aria2";
+ secretFile = "${stateDir}/rpc-secret";
+ runtimeConf = "${stateDir}/aria2.conf";
+ sessionFile = "${stateDir}/session";
+
+ extId = "baptistecdr@users.noreply.github.com";
+ # Fixed so captureServer can point at it and re-seeding is idempotent.
+ serverUuid = "6f1c2b7e-0a43-4d8e-9c55-3b2a1e0d9f10";
+
+ # Secret-free half of the config; rpc-secret is appended at activation.
+ baseConf = (pkgs.formats.keyValue { }).generate "aria2-base.conf" ({
+ enable-rpc = true;
+ rpc-listen-all = false;
+ rpc-listen-port = cfg.rpcPort;
+ dir = cfg.downloadDir;
+ input-file = sessionFile;
+ save-session = sessionFile;
+ save-session-interval = 60;
+ continue = true;
+ max-connection-per-server = 8;
+ split = 8;
+ min-split-size = "1M";
+ file-allocation = if isDarwin then "none" else "falloc";
+ log = "${stateDir}/aria2.log";
+ log-level = "warn";
+ } // lib.optionalAttrs isLinux {
+ #ca-certificate = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
+ });
+
+ # Extension state as stored by src/models/extension-options.ts: one
+ # storage.sync key, "options", holding a *stringified* JSON blob.
+ extOptions = pkgs.writeText "aria2-ext-options.json" (builtins.toJSON {
+ servers.${serverUuid} = {
+ uuid = serverUuid;
+ name = "Local aria2";
+ secure = false;
+ host = "127.0.0.1";
+ port = cfg.rpcPort;
+ path = "/jsonrpc";
+ secret = ""; # filled at activation
+ rpcParameters = { };
+ };
+ captureServer = serverUuid;
+ captureDownloads = true;
+ minFileSizeInBytes = 0;
+ excludedProtocols = [ "blob" "data" ];
+ excludedSites = [ ];
+ excludedFileTypes = [ ];
+ useCompleteFilePath = false;
+ notifyUrlIsAdded = true;
+ notifyFileIsAdded = false;
+ notifyErrorOccurs = true;
+ });
+
+ daemon = pkgs.writeShellApplication {
+ name = "aria2-daemon";
+ runtimeInputs = [ pkgs.aria2 ];
+ text = ''exec aria2c --conf-path="${runtimeConf}" "$@"'';
+ };
+in
+{
+ options.ryan.aria2 = {
+ enable = lib.mkEnableOption "aria2 RPC daemon wired into Zen";
+ rpcPort = lib.mkOption { type = lib.types.port; default = 6800; };
+ downloadDir = lib.mkOption {
+ type = lib.types.str;
+ default = "${config.home.homeDirectory}/Downloads";
+ };
+ daemon = lib.mkOption {
+ type = lib.types.package;
+ readOnly = true;
+ default = daemon;
+ description = "Wrapper that runs aria2c against the runtime conf.";
+ };
+ };
+
+ config = lib.mkIf cfg.enable (lib.mkMerge [
+ {
+ home.packages = [ pkgs.aria2 daemon ];
+
+ # Secret + runtime conf. Must precede the launchd/systemd/Shepherd reload and the
+ # Zen fragment, all of which consume what this writes.
+ home.activation.aria2Runtime =
+ lib.hm.dag.entryBetween
+ [ "setupLaunchAgents" "reloadSystemd" "shepherdServices" "zen-browser-default" ]
+ [ "writeBoundary" ] ''
+ if [[ ! -v DRY_RUN ]]; then
+ install -d -m 0700 "${stateDir}"
+ if [ ! -s "${secretFile}" ]; then
+ (umask 077; ${pkgs.openssl}/bin/openssl rand -hex 32 > "${secretFile}")
+ fi
+ [ -e "${sessionFile}" ] || touch "${sessionFile}"
+ tmp="$(mktemp "${stateDir}/.aria2.conf.XXXXXX")"
+ { cat ${baseConf}; printf 'rpc-secret=%s\n' "$(cat "${secretFile}")"; } > "$tmp"
+ chmod 0600 "$tmp"
+ mv -f "$tmp" "${runtimeConf}"
+ fi
+ '';
+ }
+
+ (lib.mkIf config.ryan.zen.enable {
+ ryan.zen.extensions.aria2 = {
+ id = extId;
+ version = "4.15.4";
+ };
+
+ # The extension only reads storage.sync (no storage.managed), so
+ # 3rdparty policy can't configure it. With Sync disabled, storage.sync
+ # is the local webext-storage DB; upsert our row there while Zen is
+ # closed. Declarative-owned: UI edits to this extension get reverted.
+ programs.zen-browser.activationFragments.default = [{
+ requiresLock = true;
+ skipSubject = "aria2 extension config";
+ text = ''
+ db="${zen.profilesPath}/${zen.profiles.default.path}/storage-sync-v2.sqlite"
+ if [ ! -f "$db" ]; then
+ echo "aria2: $db not created yet; start Zen once (extension initialises storage.sync), quit, rebuild."
+ elif [[ ! -v DRY_RUN ]]; then
+ payload="$(mktemp)"
+ ${lib.getExe pkgs.jq} -nc \
+ --arg secret "$(cat "${secretFile}")" \
+ --arg uuid "${serverUuid}" \
+ --slurpfile opts ${extOptions} \
+ '{options: ($opts[0] | .servers[$uuid].secret = $secret | tojson)}' > "$payload"
+ ${lib.getBin pkgs.sqlite}/bin/sqlite3 "$db" <<SQL
+ INSERT INTO storage_sync_data (ext_id, data, sync_change_counter)
+ VALUES ('${extId}', CAST(readfile('$payload') AS TEXT), 1)
+ ON CONFLICT(ext_id) DO UPDATE SET
+ data = excluded.data,
+ sync_change_counter = sync_change_counter + 1;
+ SQL
+ rm -f "$payload"
+ fi
+ '';
+ }];
+ })
+
+ (lib.mkIf isDarwin {
+ launchd.agents.aria2 = {
+ enable = true;
+ config = {
+ ProgramArguments = [ (lib.getExe daemon) ];
+ RunAtLoad = true;
+ KeepAlive = true;
+ ProcessType = "Background";
+ # Not read by aria2; changes the plist whenever the base conf
+ # changes, so setupLaunchAgents reloads the agent.
+ EnvironmentVariables.ARIA2_BASE_CONF = "${baseConf}";
+ };
+ };
+ })
+
+ # Linux: a plain systemd user unit. On Guix, targets.guix translates it
+ # to a Shepherd service; on NixOS / systemd distros it runs as-is.
+ (lib.mkIf isLinux {
+ systemd.user.services.aria2 = {
+ Unit = {
+ Description = "aria2 RPC daemon";
+ Documentation = "man:aria2c(1)";
+ # Unit text changes with the base conf -> sd-switch restarts it.
+ X-Restart-Triggers = [ "${baseConf}" ];
+ };
+ Service = {
+ ExecStart = lib.getExe daemon;
+ Restart = "on-failure";
+ };
+ Install.WantedBy = [ "default.target" ];
+ };
+ })
+
+ ]);
+}
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;
+ };
+}
diff --git a/users/ryan/modules/zen/default.nix b/users/ryan/modules/zen/default.nix
new file mode 100644
index 0000000..8e7a73b
--- /dev/null
+++ b/users/ryan/modules/zen/default.nix
@@ -0,0 +1,94 @@
+{ config, lib, pkgs, ... }:
+let
+ inherit (lib) mkOption mkEnableOption types mkIf mkMerge;
+ inherit (pkgs.stdenv.hostPlatform) isDarwin;
+ cfg = config.ryan.zen;
+
+ extensionType = types.submodule {
+ options = {
+ enable = mkOption {
+ type = types.bool;
+ default = true;
+ description = "Set false to drop an entry defined elsewhere (attrsOf can't delete keys).";
+ };
+ id = mkOption {
+ type = types.str;
+ description = "Add-on GUID; also the policy key and the AMO API lookup key.";
+ };
+ version = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Hard pin. null = whatever extensions.lock.json holds (bump with lock.sh).";
+ };
+ mode = mkOption {
+ type = types.enum [ "force_installed" "normal_installed" ];
+ default = "force_installed";
+ };
+ privateBrowsing = mkOption {
+ type = types.nullOr types.bool;
+ default = null;
+ description = "Emit private_browsing only when non-null.";
+ };
+ extraSettings = mkOption {
+ type = types.attrsOf types.anything;
+ default = { };
+ description = "Merged last into the ExtensionSettings entry (e.g. default_area).";
+ };
+ };
+ };
+in
+{
+ imports = [ ./extensions.nix ];
+
+ options.ryan.zen = {
+ enable = mkEnableOption "Zen browser with locked policies, profile and extensions";
+
+ extensions = mkOption {
+ type = types.attrsOf extensionType;
+ default = { };
+ description = "Policy-managed extensions, resolved through extensions.lock.json.";
+ };
+
+ exclusive = mkOption {
+ type = types.bool;
+ default = true;
+ description = "Disallow all undeclared extensions and disable all temporary loading";
+ };
+ };
+
+ config = mkIf cfg.enable (mkMerge [
+ {
+ programs.zen-browser = {
+ enable = true;
+ policies = import ./policies.nix;
+ profiles.default = import ./profile.nix;
+ };
+
+ ryan.zen.extensions = {
+ ublock-origin = {
+ id = "uBlock0@raymondhill.net";
+ privateBrowsing = true;
+ };
+ bitwarden = {
+ id = "{446900e4-71c2-419f-a6a7-df9c091e268b}";
+ mode = "normal_installed";
+ privateBrowsing = true;
+ };
+ sponsorblock.id = "sponsorBlocker@ajay.app";
+ dearrow.id = "deArrow@ajay.app";
+ return-youtube-dislikes.id = "{762f9885-5a13-4abd-9c77-433dcd38b8fd}";
+ youtube-nonstop.id = "{0d7cafdd-501c-49ca-8ebb-e3341caaa55e}";
+ tampermonkey.id = "firefox@tampermonkey.net";
+ floccus.id = "floccus@handmadeideas.org";
+ mailvelope.id = "jid1-AQqSMBYb0a8ADg@jetpack";
+ };
+ }
+
+ (mkIf isDarwin {
+ programs.zen-browser = {
+ package = null; # managed via homebrew
+ darwinDefaultsId = "app.zen-browser.zen";
+ };
+ })
+ ]);
+}
diff --git a/users/ryan/modules/zen/extensions.lock.json b/users/ryan/modules/zen/extensions.lock.json
new file mode 100644
index 0000000..cd0df78
--- /dev/null
+++ b/users/ryan/modules/zen/extensions.lock.json
@@ -0,0 +1,62 @@
+{
+ "aria2": {
+ "hash": "sha256-0V6zKAqz8uKlDE4q/szSW732B9X0TUKdo8qUChv2IW0=",
+ "id": "baptistecdr@users.noreply.github.com",
+ "url": "https://addons.mozilla.org/firefox/downloads/file/5055892/aria2_extension-4.15.4.xpi",
+ "version": "4.15.4"
+ },
+ "bitwarden": {
+ "hash": "sha256-XdbvpdIo2+rHoMaog/lReH3GbleYUyLOVJIQsN+NBUw=",
+ "id": "{446900e4-71c2-419f-a6a7-df9c091e268b}",
+ "url": "https://addons.mozilla.org/firefox/downloads/file/5076543/bitwarden_password_manager-2026.9.3.xpi",
+ "version": "2026.9.3"
+ },
+ "dearrow": {
+ "hash": "sha256-MVGlHbgJN0a+ZGwEGvPq1VPl3pX6Tr1frgM+A54QVtE=",
+ "id": "deArrow@ajay.app",
+ "url": "https://addons.mozilla.org/firefox/downloads/file/4897568/dearrow-2.3.10.xpi",
+ "version": "2.3.10"
+ },
+ "floccus": {
+ "hash": "sha256-FOk0NhveQToZjq88hRU3wjJVwR82ZQ/DJAz6hqlkJYg=",
+ "id": "floccus@handmadeideas.org",
+ "url": "https://addons.mozilla.org/firefox/downloads/file/5063393/floccus-5.11.0.xpi",
+ "version": "5.11.0"
+ },
+ "mailvelope": {
+ "hash": "sha256-MUAU3OzESJWHE9zkuylyMh8/4nxj725urSDouFA5Juw=",
+ "id": "jid1-AQqSMBYb0a8ADg@jetpack",
+ "url": "https://addons.mozilla.org/firefox/downloads/file/4846833/mailvelope-6.3.0.xpi",
+ "version": "6.3.0"
+ },
+ "return-youtube-dislikes": {
+ "hash": "sha256-WXGXSfbfOMFgHKXznAIVjV8AXEPp7uofGVJ/RoyUEhc=",
+ "id": "{762f9885-5a13-4abd-9c77-433dcd38b8fd}",
+ "url": "https://addons.mozilla.org/firefox/downloads/file/5012638/return_youtube_dislikes-4.0.6.xpi",
+ "version": "4.0.6"
+ },
+ "sponsorblock": {
+ "hash": "sha256-DVDhYyxvFe4VpUPmcOHFcpdGBaXAJiKRbgjgJoA9+D8=",
+ "id": "sponsorBlocker@ajay.app",
+ "url": "https://addons.mozilla.org/firefox/downloads/file/4897574/sponsorblock-6.1.7.xpi",
+ "version": "6.1.7"
+ },
+ "tampermonkey": {
+ "hash": "sha256-GQAxx428VpYRSDVgHyyOa4Va0eE0313yePj8FYwGWQg=",
+ "id": "firefox@tampermonkey.net",
+ "url": "https://addons.mozilla.org/firefox/downloads/file/4797143/tampermonkey-5.5.0.xpi",
+ "version": "5.5.0"
+ },
+ "ublock-origin": {
+ "hash": "sha256-W3RBWGBFY3BkS9gPFhJehlsObDVrtd/PuEBpln6qUoc=",
+ "id": "uBlock0@raymondhill.net",
+ "url": "https://addons.mozilla.org/firefox/downloads/file/5034826/ublock_origin-1.75.0.xpi",
+ "version": "1.75.0"
+ },
+ "youtube-nonstop": {
+ "hash": "sha256-dlnRgPduqQjqgbhO2b3RiGJOqqYriKzL5titToyu/zg=",
+ "id": "{0d7cafdd-501c-49ca-8ebb-e3341caaa55e}",
+ "url": "https://addons.mozilla.org/firefox/downloads/file/4187690/youtube_nonstop-0.9.2.xpi",
+ "version": "0.9.2"
+ }
+}
diff --git a/users/ryan/modules/zen/extensions.nix b/users/ryan/modules/zen/extensions.nix
new file mode 100644
index 0000000..f91984b
--- /dev/null
+++ b/users/ryan/modules/zen/extensions.nix
@@ -0,0 +1,89 @@
+# version = null -> whatever the lock holds; `lock.sh <attr>` bumps it
+# version = "x.y.z" -> hard pin; lock.sh resolves exactly that version and
+# eval fails if the lock disagrees
+{ config, lib, pkgs, ... }:
+let
+ inherit (lib) mapAttrs' mapAttrsToList nameValuePair optionalAttrs;
+ cfg = config.ryan.zen;
+ exts = lib.filterAttrs (_: e: e.enable) cfg.extensions;
+ zen = config.programs.zen-browser;
+
+ lockFile = ./extensions.lock.json;
+ lock = lib.importJSON lockFile;
+
+ # Validated lock entry. Throws (not assertions) so the message surfaces no
+ # matter which consumer forces the policy first (HM wrapper, darwin defaults).
+ locked = name: e:
+ let
+ l = lock.${name} or (throw
+ "zen extension '${name}' has no entry in extensions.lock.json; run modules/zen/lock.sh");
+ in
+ if l.id != e.id then throw
+ "zen extension '${name}': lock has id ${l.id}, config has ${e.id}; re-run lock.sh"
+ else if e.version != null && l.version != e.version then throw
+ "zen extension '${name}': pinned ${e.version}, lock has ${l.version}; re-run lock.sh"
+ else l;
+
+ xpi = name: e:
+ let l = locked name e; in
+ pkgs.fetchurl {
+ name = "zen-ext-${name}-${l.version}.xpi";
+ inherit (l) url hash;
+ };
+
+ settingsFor = name: e:
+ {
+ install_url = "file://${xpi name e}";
+ installation_mode = e.mode;
+ updates_disabled = true;
+ }
+ // optionalAttrs (e.privateBrowsing != null) { private_browsing = e.privateBrowsing; }
+ // e.extraSettings;
+
+ pins = pkgs.writeText "zen-extension-pins.json" (builtins.toJSON
+ (mapAttrsToList (name: e: { inherit (e) id; version = (locked name e).version; }) exts));
+
+ jq = lib.getExe pkgs.jq;
+ sort = "${pkgs.coreutils}/bin/sort";
+in
+{
+ config = lib.mkIf (cfg.enable && exts != { }) {
+ programs.zen-browser.policies = {
+ # Locks extensions.update.enabled=false; belt to updates_disabled's braces.
+ ExtensionUpdate = false;
+ ExtensionSettings =
+ mapAttrs' (name: e: nameValuePair e.id (settingsFor name e)) exts
+ // optionalAttrs cfg.exclusive { "*".installation_mode = "blocked"; };
+ };
+
+ # Explicit GC root for every pinned XPI, independent of how the policy
+ # gets serialized on each platform. Also a handy place to inspect them.
+ home.file.".local/share/zen-extensions".source = pkgs.linkFarm "zen-extensions"
+ (mapAttrsToList (name: e: { name = "${name}.xpi"; path = xpi name e; }) exts);
+
+ # The policy engine refuses downgrades (installed > pinned is cancelled in
+ # PoliciesHelpers installAddonFromURL). For exactly that case, delete the
+ # installed XPI while Zen is closed; the next start drops it from the DB
+ # and the policy installs the pin. Upgrades are left to the policy engine.
+ programs.zen-browser.activationFragments.default = [{
+ requiresLock = true;
+ skipSubject = "zen extension downgrade check";
+ text = ''
+ prof="${zen.profilesPath}/${zen.profiles.default.path}"
+ if [ -f "$prof/extensions.json" ]; then
+ while IFS=$'\t' read -r id want have; do
+ [ -n "$have" ] && [ "$have" != "$want" ] || continue
+ newest="$(printf '%s\n%s\n' "$want" "$have" | ${sort} -V | tail -n1)"
+ [ "$newest" = "$have" ] || continue
+ echo "zen-extensions: $id installed $have > pinned $want; removing installed XPI"
+ [[ -v DRY_RUN ]] || rm -f "$prof/extensions/$id.xpi"
+ done < <(${jq} -r --slurpfile ej "$prof/extensions.json" '
+ .[] | . as $p
+ | [ $p.id, $p.version,
+ ([ $ej[0].addons[] | select(.id == $p.id and .location == "app-profile") | .version ][0] // "") ]
+ | @tsv' ${pins})
+ fi
+ '';
+ }];
+ };
+}
diff --git a/users/ryan/modules/zen/lock.sh b/users/ryan/modules/zen/lock.sh
new file mode 100755
index 0000000..a375fd8
--- /dev/null
+++ b/users/ryan/modules/zen/lock.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# Resolve ryan.zen.extensions into extensions.lock.json via AMO's v5 API.
+#
+# lock.sh <attr> [--missing]
+#
+# <attr> is the evaluated home-manager config, e.g.
+# ~/.config/home-manager#homeConfigurations.ryan.config
+# ~/.config/nix#darwinConfigurations.<host>.config.home-manager.users.ryan
+#
+# Default: re-resolve everything (unpinned entries move to AMO's current version).
+# --missing: keep existing entries that still satisfy the spec; resolve the rest.
+#
+# Only ryan.zen.extensions.*.{id,version} is evaluated, which never touches the
+# lock, so this works when the lock is empty or stale.
+# Needs: nix (>= 2.19 for `nix hash convert`), curl, jq.
+set -euo pipefail
+
+attr="${1:?usage: lock.sh <hm-config-attr> [--missing]}"
+mode="${2:-all}"
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+lockfile="$here/extensions.lock.json"
+api="https://addons.mozilla.org/api/v5/addons/addon"
+
+spec="$(nix eval --json "$attr.ryan.zen.extensions" \
+ --apply 'es: builtins.mapAttrs (_: e: { inherit (e) id version; }) (builtins.removeAttrs es (builtins.filter (n: !es.${n}.enable) (builtins.attrNames es)))')"
+old="$(cat "$lockfile" 2>/dev/null || echo '{}')"
+new='{}'
+
+uri() { jq -rn --arg s "$1" '$s|@uri'; }
+
+for name in $(jq -r 'keys[]' <<<"$spec"); do
+ id="$(jq -r --arg n "$name" '.[$n].id' <<<"$spec")"
+ want="$(jq -r --arg n "$name" '.[$n].version // empty' <<<"$spec")"
+
+ if [ "$mode" = "--missing" ]; then
+ keep="$(jq -c --arg n "$name" --arg id "$id" --arg v "$want" \
+ '.[$n] | select(. != null and .id == $id and ($v == "" or .version == $v))' <<<"$old")"
+ if [ -n "$keep" ]; then
+ new="$(jq --arg n "$name" --argjson e "$keep" '.[$n] = $e' <<<"$new")"
+ continue
+ fi
+ fi
+
+ if [ -n "$want" ]; then
+ v="$(curl -fsSL "$api/$(uri "$id")/versions/$(uri "$want")/")"
+ else
+ v="$(curl -fsSL "$api/$(uri "$id")/" | jq '.current_version')"
+ fi
+
+ version="$(jq -r '.version' <<<"$v")"
+ url="$(jq -r '.file.url' <<<"$v")"
+ algo_hex="$(jq -r '.file.hash' <<<"$v")" # "sha256:<hex>"
+ [ "${algo_hex%%:*}" = sha256 ] || { echo "$name: unexpected hash $algo_hex" >&2; exit 1; }
+ hash="$(nix hash convert --hash-algo sha256 --to sri "${algo_hex#sha256:}")"
+
+ echo "$name ($id) -> $version" >&2
+ new="$(jq --arg n "$name" --arg id "$id" --arg ver "$version" --arg url "$url" --arg h "$hash" \
+ '.[$n] = {id: $id, version: $ver, url: $url, hash: $h}' <<<"$new")"
+done
+
+jq -S . <<<"$new" > "$lockfile.tmp" && mv "$lockfile.tmp" "$lockfile"
diff --git a/users/ryan/modules/zen/policies.nix b/users/ryan/modules/zen/policies.nix
new file mode 100644
index 0000000..93a5324
--- /dev/null
+++ b/users/ryan/modules/zen/policies.nix
@@ -0,0 +1,78 @@
+let
+ lock-false = {
+ Value = false;
+ Status = "locked";
+ };
+ lock-true = {
+ Value = true;
+ Status = "locked";
+ };
+in
+{
+ EnableTrackingProtection = {
+ Value = true;
+ Locked = true;
+ Cryptomining = true;
+ Fingerprinting = true;
+ EmailTracking = true;
+ };
+ UserMessaging = {
+ WhatsNew = false;
+ ExtensionRecommendations = false;
+ FeatureRecommendations = false;
+ UrlbarInterventions = false;
+ SkipOnboarding = true;
+ MoreFromMozilla = false;
+ Labs = false;
+ Locked = true;
+ };
+ DisableAppUpdate = true;
+ DisableAccounts = true;
+ DisableFirefoxAccounts = true;
+ DisableFirefoxStudies = true;
+ DisablePocket = true;
+ DisableTelemetry = true;
+ AutofillAddressEnabled = false;
+ AutofillCreditCardEnabled = false;
+ DisableMasterPasswordCreation = true;
+ PasswordManagerEnabled = false;
+ PrimaryPassword = false;
+ OfferToSaveLogins = false;
+ NoDefaultBookmarks = true;
+ OverrideFirstRunPage = "";
+ OverridePostUpdatePage = "";
+ FirefoxHome = {
+ Search = true;
+ TopSites = true;
+ SponsoredTopSites = false;
+ Highlights = false;
+ Pocket = false;
+ SponsoredPocket = false;
+ Snippets = false;
+ Locked = true;
+ };
+ SearchSuggestEnabled = true;
+ FirefoxSuggest = {
+ WebSuggestions = true;
+ SponsoredSuggestions = false;
+ ImproveSuggest = false;
+ Locked = true;
+ };
+ PictureInPicture = lock-true;
+ HardwareAcceleration = true;
+ Certificates = {
+ ImportEnterpriseRoots = true;
+ };
+ Preferences = {
+ "xpinstall.whitelist.required" = lock-true;
+ "dom.webgpu.enabled" = lock-true;
+ "media.eme.enabled" = lock-true;
+ "ui.preferReducedMotion" = lock-false;
+ "general.autoScroll" = lock-true;
+ "general.smoothScroll" = lock-true;
+ "browser.crashReports.unsubmittedCheck.autoSubmit2" = lock-false;
+ "browser.aboutConfig.showWarning" = lock-false;
+ "media.videocontrols.picture-in-picture.enable-when-switching-tabs.enabled" = lock-true;
+ };
+}
+
diff --git a/users/ryan/zen/zenProfile.nix b/users/ryan/modules/zen/profile.nix
similarity index 100%
rename from users/ryan/zen/zenProfile.nix
rename to users/ryan/modules/zen/profile.nix
diff --git a/users/ryan/zen/zenPolicies.nix b/users/ryan/zen/zenPolicies.nix
deleted file mode 100644
index 8d39689..0000000
--- a/users/ryan/zen/zenPolicies.nix
+++ /dev/null
@@ -1,128 +0,0 @@
-let
- lock-false = {
- Value = false;
- Status = "locked";
- };
- lock-true = {
- Value = true;
- Status = "locked";
- };
-in
-{
- EnableTrackingProtection = {
- Value = true;
- Locked = true;
- Cryptomining = true;
- Fingerprinting = true;
- EmailTracking = true;
- };
- UserMessaging = {
- WhatsNew = false;
- ExtensionRecommendations = false;
- FeatureRecommendations = false;
- UrlbarInterventions = false;
- SkipOnboarding = true;
- MoreFromMozilla = false;
- Labs = false;
- Locked = true;
- };
- DisableAppUpdate = true;
- DisableAccounts = true;
- DisableFirefoxAccounts = true;
- DisableFirefoxStudies = true;
- DisablePocket = true;
- DisableTelemetry = true;
- AutofillAddressEnabled = false;
- AutofillCreditCardEnabled = false;
- DisableMasterPasswordCreation = true;
- PasswordManagerEnabled = false;
- PrimaryPassword = false;
- OfferToSaveLogins = false;
- NoDefaultBookmarks = true;
- OverrideFirstRunPage = "";
- OverridePostUpdatePage = "";
- FirefoxHome = {
- Search = true;
- TopSites = true;
- SponsoredTopSites = false;
- Highlights = false;
- Pocket = false;
- SponsoredPocket = false;
- Snippets = false;
- Locked = true;
- };
- SearchSuggestEnabled = true;
- FirefoxSuggest = {
- WebSuggestions = true;
- SponsoredSuggestions = false;
- ImproveSuggest = false;
- Locked = true;
- };
- PictureInPicture = lock-true;
- HardwareAcceleration = true;
- Certificates = {
- ImportEnterpriseRoots = true;
- };
- ExtensionSettings = {
- #"*".installation_mode = "blocked";
- # uBlock Origin
- "uBlock0@raymondhill.net" = {
- install_url = "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi";
- installation_mode = "force_installed";
- private_browsing = true;
- };
- # Bitwarden
- "{446900e4-71c2-419f-a6a7-df9c091e268b}" = {
- install_url = "https://addons.mozilla.org/firefox/downloads/latest/bitwarden-password-manager/latest.xpi";
- installation_mode = "normal_installed";
- private_browsing = true;
- };
- # SponsorBlock
- "sponsorBlocker@ajay.app" = {
- install_url = "https://addons.mozilla.org/firefox/downloads/latest/sponsorblock/latest.xpi";
- installation_mode = "force_installed";
- };
- # DeArrow
- "deArrow@ajay.app" = {
- install_url = "https://addons.mozilla.org/firefox/downloads/latest/dearrow/latest.xpi";
- installation_mode = "force_installed";
- };
- # Return Youtube Dislike
- "{762f9885-5a13-4abd-9c77-433dcd38b8fd}" = {
- install_url = "https://addons.mozilla.org/firefox/downloads/latest/return-youtube-dislikes/latest.xpi";
- installation_mode = "force_installed";
- };
- # Youtube Nonstop
- "{0d7cafdd-501c-49ca-8ebb-e3341caaa55e}" = {
- install_url = "https://addons.mozilla.org/firefox/downloads/latest/youtube-nonstop/latest.xpi";
- installation_mode = "force_installed";
- };
- # TamperMonkey
- "firefox@tampermonkey.net" = {
- install_url = "https://addons.mozilla.org/firefox/downloads/latest/tampermonkey/latest.xpi";
- installation_mode = "force_installed";
- };
- # Floccus
- "floccus@handmadeideas.org" = {
- install_url = "https://addons.mozilla.org/firefox/downloads/latest/floccus/latest.xpi";
- installation_mode = "force_installed";
- };
- # Mailvelope
- "jid1-AQqSMBYb0a8ADg@jetpack" = {
- install_url = "https://addons.mozilla.org/firefox/downloads/latest/mailvelope/latest.xpi";
- installation_mode = "force_installed";
- };
- };
- Preferences = {
- "xpinstall.whitelist.required" = lock-true;
- "dom.webgpu.enabled" = lock-true;
- "media.eme.enabled" = lock-true;
- "ui.preferReducedMotion" = lock-false;
- "general.autoScroll" = lock-true;
- "general.smoothScroll" = lock-true;
- "browser.crashReports.unsubmittedCheck.autoSubmit2" = lock-false;
- "browser.aboutConfig.showWarning" = lock-false;
- "media.videocontrols.picture-in-picture.enable-when-switching-tabs.enabled" = lock-true;
- };
-}
-