Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

Compare

a2c015245921f589bbd7cc787bfd092474a07992 … main · 7 commits

merge base a2c015245921…

Changed files

Commits

HashSubjectAuthorDate
3c1f45ed enable xdg since i guess it wasn't Ryan Schanzenbacher
aeb91079 genericize guix Ryan Schanzenbacher
42a3265e add obs studio to linux Ryan Schanzenbacher
ff4f79c0 add extra socket for ssh gpg stuff Ryan Schanzenbacher
b7d807c3 lock zen extensions; move to new zen module system Ryan Schanzenbacher
4e6cd553 initial aria2 downloader Ryan Schanzenbacher
3d323731 various changes Ryan Schanzenbacher
diff --git a/files/darwin-settings/vorssaint_settings.plist b/files/darwin-settings/vorssaint_settings.plist
index 44a5daa..d4dfed4 100644
--- a/files/darwin-settings/vorssaint_settings.plist
+++ b/files/darwin-settings/vorssaint_settings.plist
@@ -147,7 +147,7 @@
<key>featureAvailable.musicBlock</key>
<false/>
<key>featureAvailable.pastePlain</key>
- <false/>
+ <true/>
<key>featureAvailable.quickLauncher</key>
<true/>
<key>featureAvailable.quickToggles</key>
@@ -171,7 +171,7 @@
<key>featureAvailable.superKey</key>
<false/>
<key>featureAvailable.switcher</key>
- <false/>
+ <true/>
<key>featureAvailable.textSnippets</key>
<false/>
<key>featureAvailable.uninstaller</key>
@@ -215,7 +215,7 @@
<key>lastUpdateIntroVersion</key>
<string>3.2.0</string>
<key>launchAtLoginWanted</key>
- <false/>
+ <true/>
<key>mediaGIFEnd</key>
<real>0.0</real>
<key>mediaGIFFPS</key>
@@ -472,6 +472,8 @@
<true/>
<key>panelPowerOrder</key>
<string>system,adapter,battery,remaining,health</string>
+ <key>panelSectionOrder</key>
+ <string>keepAwake,brightness,system,mixer,power,disk,network,fanControl,utilities,controls,toggles</string>
<key>panelShowBrightness</key>
<true/>
<key>panelShowControls</key>
@@ -533,15 +535,15 @@
<key>panelUtilityWindowLayout</key>
<true/>
<key>pastePlainEnabled</key>
- <false/>
+ <true/>
<key>pastePlainShortcut</key>
- <string>option+shift+command:9</string>
+ <string>shift+command:9</string>
<key>previewSize</key>
<string>normal</string>
<key>quickLauncherHiddenItems</key>
<string></string>
<key>quickLauncherShortcut</key>
- <string>control+command:9</string>
+ <string>option+shift:9</string>
<key>quickLauncherShortcutEnabled</key>
<true/>
<key>radialMenuActivationMode</key>
diff --git a/hosts/RyanMac/configuration.nix b/hosts/RyanMac/configuration.nix
index 687b941..e98db39 100644
--- a/hosts/RyanMac/configuration.nix
+++ b/hosts/RyanMac/configuration.nix
@@ -159,6 +159,7 @@ in {
"kdenlive"
"rustdesk"
"vorssaint"
+ "obs"
];
};
diff --git a/modules/darwin/yabai/default.nix b/modules/darwin/yabai/default.nix
index cc7df15..8aa4fbf 100644
--- a/modules/darwin/yabai/default.nix
+++ b/modules/darwin/yabai/default.nix
@@ -4,6 +4,10 @@
let
cfg = config.local.yabai;
+ spaceFocusBinds = lib.concatStringsSep "\n"
+ (map (i: "shift + alt - ${toString i} : ${yabaiBin} -m window --space ${toString i}")
+ (lib.range 1 cfg.spaceHotkeys.count));
+
# symbolic hotkey IDs 118..126 == "Switch to Desktop 1".."Switch to Desktop 9"
spaceKeycodes = [ 18 19 20 21 22 23 25 26 28 ];
@@ -29,6 +33,14 @@ let
(uuid: pad: " ${uuid}) pad=${toString pad} ;;")
cfg.displayTopPadding);
+ paddingSignals = ''
+ yabai -m signal --add event=display_added action="${displayPaddingScript}" label="per-display padding (added)"
+ yabai -m signal --add event=display_removed action="${displayPaddingScript}" label="per-display padding (removed)"
+ yabai -m signal --add event=space_created action="${displayPaddingScript}" label="per-display padding (space)"
+ ${displayPaddingScript}
+ '';
+
+
displayPaddingScript = pkgs.writeShellScript "yabai-display-padding" ''
set -eu
yabai="${cfg.package}/bin/yabai"
@@ -50,6 +62,18 @@ ${padArms}
yabaiBin = "${cfg.package}/bin/yabai";
+ sudoersFile = pkgs.runCommand "sudoers-yabai" { } ''
+ YABAI_BIN="${yabaiBin}"
+ SHASUM=$(sha256sum "$YABAI_BIN" | cut -d' ' -f1)
+ cat > "$out" <<EOF
+ ${cfg.user} ALL=(root) NOPASSWD: sha256:$SHASUM $YABAI_BIN --load-sa
+ EOF
+ '';
+
+ dockRestartSignal = ''
+ yabai -m signal --add event=dock_did_restart action="/usr/bin/sudo ${yabaiBin} --load-sa" label="reload scripting addition"
+ '';
+
directionBinds = lib.concatStringsSep "\n" (map (d: ''
alt - ${d.key} : ${yabaiBin} -m window --focus ${d.dir} || ${yabaiBin} -m display --focus ${d.dir}
shift + alt - ${d.key} : ${yabaiBin} -m window --swap ${d.dir} || ${yabaiBin} -m window --display ${d.dir}
@@ -74,6 +98,15 @@ in {
description = "yabai package. Referenced by keybindings and the padding script.";
};
+ user = lib.mkOption {
+ type = lib.types.str;
+ default = config.system.primaryUser;
+ defaultText = lib.literalExpression "config.system.primaryUser";
+ description = ''
+ User that is granted permission to run --load-sa
+ '';
+ };
+
enableScriptingAddition = lib.mkOption {
type = lib.types.bool;
default = false;
@@ -83,6 +116,15 @@ in {
'';
};
+ useYabaiSpaceFocus = lib.mkOption {
+ type = lib.types.bool;
+ default = false;
+ description = ''
+ Bind alt+n to yabai native control rather than skhd. Requires scripting
+ additions.
+ '';
+ };
+
defaultPadding = lib.mkOption {
type = lib.types.int;
default = 10;
@@ -94,7 +136,7 @@ in {
defaultTopPadding = lib.mkOption {
type = lib.types.int;
- default = 10;
+ default = 40;
description = ''
Padding for top edge of screen
'';
@@ -168,6 +210,21 @@ in {
};
config = lib.mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = cfg.user != null && cfg.user != "";
+ message = ''
+ local.yabai.user is unset. Set it or system.primaryUser
+ '';
+ }
+ {
+ assertion = cfg.useYabaiSpaceFocus -> cfg.enableScriptingAssition;
+ message = ''
+ local.yabai.useYabaiSpaceFocus requires enableScriptingAddition.
+ '';
+ }
+ ];
+
services.yabai = {
enable = true;
inherit (cfg) package enableScriptingAddition;
@@ -188,39 +245,41 @@ in {
focus_follows_mouse = "autofocus";
} // cfg.extraSettings;
- extraConfig = ''
- yabai -m rule --add app='System Settings' manage=off
- yabai -m rule --add app="^Zen$" title="^Picture-in-Picture$" manage=off
- ''
- + lib.optionalString (cfg.displayTopPadding != { }) ''
-
- # Per-display padding. space_created is required, not redundant:
- # display_added fires when yabai sees the display, which can precede
- # macOS finishing space creation on it, so the first pass may find
- # zero or one space. display_removed is required because yabai
- # reindexes spaces on disconnect and the overrides are keyed by index.
- yabai -m signal --add event=display_added action="${displayPaddingScript}" label="per-display padding (added)"
- yabai -m signal --add event=display_removed action="${displayPaddingScript}" label="per-display padding (removed)"
- yabai -m signal --add event=space_created action="${displayPaddingScript}" label="per-display padding (space)"
- ${displayPaddingScript}
- ''
- + cfg.extraConfig;
+ extraConfig = lib.concatStringsSep "\n" (
+ [
+ ''
+ yabai -m rule --add app='System Settings' manage=off
+ yabai -m rule --add app="^Zen$" title="^Picture-in-Picture$" manage=off
+ ''
+ ]
+ ++ lib.optional cfg.enableScriptingAddition dockRestartSignal
+ ++ lib.optional (cfg.displayTopPadding != { }) paddingSignals
+ ++ lib.optional (cfg.extraConfig != "") cfg.extraConfig
+ );
};
+ environment.etc."sudoers.d/yabai".source =
+ lib.mkIf cfg.enableScriptingAddition (lib.mkForce sudoersFile);
+
services.skhd = lib.mkIf cfg.manageKeybindings {
enable = true;
- skhdConfig = ''
- shift + alt - q : ${yabaiBin} -m window --close
-
- ${directionBinds}
-
- shift + alt - space : ${yabaiBin} -m window --toggle float
- shift + alt - return : ${yabaiBin} -m window --toggle sticky
- alt - f : ${yabaiBin} -m window --toggle zoom-fullscreen
- shift + alt - f : ${yabaiBin} -m window --toggle native-fullscreen
-
- ${spaceMoveBinds}
- '';
+ skhdConfig = lib.concatStringsSep "\n" (
+ [
+ ''
+ shift + alt - q : ${yabaiBin} -m window --close
+
+ ${directionBinds}
+
+ shift + alt - space : ${yabaiBin} -m window --toggle float
+ shift + alt - return : ${yabaiBin} -m window --toggle sticky
+ alt - f : ${yabaiBin} -m window --toggle zoom-fullscreen
+ shift + alt - f : ${yabaiBin} -m window --toggle native-fullscreen
+
+ ${spaceMoveBinds}
+ ''
+ ]
+ ++ lib.optional cfg.useYabaiSpaceFocus spaceFocusBinds
+ );
};
# Prerequisites, not preferences: yabai requires per-display spaces, and
diff --git a/users/ryan/common.nix b/users/ryan/common.nix
index b1599fd..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,12 @@
services.gpg-agent = {
enable = pkgs.stdenv.isLinux;
enableSshSupport = true;
+ enableExtraSocket = true;
+ };
+
+ programs.fzf = {
+ enable = true;
+ enableZshIntegration = true;
};
programs.zsh = {
@@ -128,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";
@@ -197,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;
- };
-}
-