diff --git a/modules/module-list.nix b/modules/module-list.nix index 79c877c..d77afc6 100644 --- a/modules/module-list.nix +++ b/modules/module-list.nix @@ -112,6 +112,7 @@ ./programs/fish.nix ./programs/gnupg.nix ./programs/man.nix + ./programs/mas.nix ./programs/info ./programs/nix-index ./programs/ssh.nix diff --git a/modules/programs/mas.nix b/modules/programs/mas.nix new file mode 100644 index 0000000..39f66af --- /dev/null +++ b/modules/programs/mas.nix @@ -0,0 +1,209 @@ +{ + config, + lib, + options, + pkgs, + ... +}: + +let + inherit (lib) + attrValues + concatStringsSep + escapeShellArg + getExe + literalExpression + mapAttrsToList + mkEnableOption + mkIf + mkOption + mkOptionDefault + mkPackageOption + optionalString + types + ; + + cfg = config.programs.mas; + + apps = mapAttrsToList (name: id: { inherit name id; }) cfg.packages; + + desiredIds = map (app: toString app.id) apps; + homebrewIds = map toString (attrValues config.homebrew.masApps); + + hasWork = cfg.update || cfg.packages != { } || cfg.cleanup || homebrewIds != [ ]; + + activationScript = + if hasWork then + '' + echo >&2 "setting up App Store apps (mas)..." + + runAsUser() { + sudo \ + --preserve-env=PATH \ + --set-home \ + --user=${escapeShellArg cfg.user} \ + "$@" + } + + listStatus=0 + listOutput=$( + runAsUser ${getExe cfg.package} list 2>&1 + ) || listStatus=$? + + if (( listStatus != 0 )); then + echo >&2 "warning: mas list failed (exit ''${listStatus}):" + echo >&2 "''${listOutput}" + if echo "''${listOutput}" | grep -qi "not signed in"; then + echo >&2 "login required; skipping App Store installs/updates/cleanup" + exit 0 + fi + fi + + # Only emit cleanup-only shell variables when cleanup is enabled; otherwise shellcheck + # treats them as unused and fails the activation script build. + installedIds=() + ${if cfg.cleanup then + '' + # Parse mas list output: "ID AppName (version)" + declare -A installedApps + while IFS= read -r line; do + [[ -z "$line" ]] && continue + line="''${line#"''${line%%[![:space:]]*}"}" + id="''${line%% *}" + rest="''${line#"$id"}" + rest="''${rest#"''${rest%%[![:space:]]*}"}" + name="''${rest% (*}" + name="''${name%"''${name##*[![:space:]]}"}" + [[ -n "$id" ]] && { + installedIds+=( "$id" ) + installedApps["$id"]="$name" + } + done <<<"$listOutput" + '' + else + '' + while IFS= read -r line; do + [[ -z "$line" ]] && continue + line="''${line#"''${line%%[![:space:]]*}"}" + id="''${line%% *}" + [[ -n "$id" ]] && installedIds+=( "$id" ) + done <<<"$listOutput" + ''} + + ${optionalString cfg.update '' + runAsUser ${getExe cfg.package} update || true + ''} + + desiredIds=( + ${concatStringsSep "\n " desiredIds} + ) + + is_installed() { + local needle=$1 + for id in "''${installedIds[@]}"; do + if [[ "$id" == "$needle" ]]; then + return 0 + fi + done + return 1 + } + + ${optionalString (cfg.packages != { }) '' + for appId in "''${desiredIds[@]}"; do + if is_installed "$appId"; then + continue + fi + runAsUser ${getExe cfg.package} install "$appId" || true + done + ''} + + ${optionalString cfg.cleanup '' + homebrewIds=( + ${concatStringsSep "\n " homebrewIds} + ) + + keepIds=( "''${desiredIds[@]}" "''${homebrewIds[@]}" ) + + for installedId in "''${installedIds[@]}"; do + keep=false + for keepId in "''${keepIds[@]}"; do + if [[ "$installedId" == "$keepId" ]]; then + keep=true + break + fi + done + + if ! $keep; then + appName="''${installedApps[$installedId]:-$installedId}" + echo >&2 "removing $appName from App Store" + runAsUser ${getExe cfg.package} uninstall "$installedId" || true + fi + done + ''} + '' + else + ""; +in +{ + options.programs.mas = { + enable = mkEnableOption "managing Mac App Store apps with mas"; + + user = mkOption { + type = types.str; + default = config.system.primaryUser; + defaultText = literalExpression "config.system.primaryUser"; + description = '' + The user account that runs {command}`mas`. This user must be signed into the Mac App Store + for installs or updates to succeed. + ''; + }; + + package = mkPackageOption pkgs "mas" { }; + + packages = mkOption { + type = types.attrsOf types.ints.positive; + default = { }; + example = literalExpression '' + { + Xcode = 497799835; + "1Password for Safari" = 1569813296; + } + ''; + description = '' + Applications to install from the Mac App Store. Attribute names are only for readability; + values must be the numeric identifiers used by {command}`mas`. + ''; + }; + + update = mkOption { + type = types.bool; + default = true; + description = '' + Whether to run {command}`mas update` during system activation in addition to installing the + configured apps. + ''; + }; + + cleanup = mkOption { + type = types.bool; + default = false; + description = '' + Whether to uninstall Mac App Store apps that are currently installed but not listed in + {option}`programs.mas.packages`. Apps listed in {option}`homebrew.masApps` are also preserved. + This runs before install/update; any app id not in either set will be removed. + ''; + }; + }; + + config = { + system.requiresPrimaryUser = + mkIf (cfg.enable && options.programs.mas.user.highestPrio == (mkOptionDefault { }).priority) + [ + "programs.mas.enable" + ]; + + environment.systemPackages = mkIf cfg.enable [ cfg.package ]; + + system.activationScripts.mas.text = mkIf cfg.enable activationScript; + }; +} diff --git a/modules/system/activation-scripts.nix b/modules/system/activation-scripts.nix index df6376a..c00f37c 100644 --- a/modules/system/activation-scripts.nix +++ b/modules/system/activation-scripts.nix @@ -134,6 +134,7 @@ in ${cfg.activationScripts.keyboard.text} ${cfg.activationScripts.fonts.text} ${cfg.activationScripts.nvram.text} + ${cfg.activationScripts.mas.text} ${cfg.activationScripts.homebrew.text} ${cfg.activationScripts.postActivation.text} diff --git a/release.nix b/release.nix index dfa4364..e6dc9e2 100644 --- a/release.nix +++ b/release.nix @@ -95,6 +95,8 @@ in { tests.nixpkgs-overlays = makeTest ./tests/nixpkgs-overlays.nix; tests.nixpkgs-config-allow-unfree = makeTest ./tests/nixpkgs-config-allow-unfree.nix; tests.programs-gnupg = makeTest ./tests/programs-gnupg.nix; + tests.programs-mas = makeTest ./tests/programs-mas.nix; + tests.programs-mas-no-cleanup = makeTest ./tests/programs-mas-no-cleanup.nix; tests.programs-ssh = makeTest ./tests/programs-ssh.nix; tests.programs-tmux = makeTest ./tests/programs-tmux.nix; tests.programs-zsh = makeTest ./tests/programs-zsh.nix; diff --git a/tests/programs-mas-no-cleanup.nix b/tests/programs-mas-no-cleanup.nix new file mode 100644 index 0000000..ebeeebd --- /dev/null +++ b/tests/programs-mas-no-cleanup.nix @@ -0,0 +1,60 @@ +{ + config, + pkgs, + ... +}: + +let + mas = + pkgs.runCommand "mas-0.0.0" { } '' + mkdir -p $out/bin + cat > $out/bin/mas <<'EOF' + #!/usr/bin/env bash + exit 0 + EOF + chmod +x $out/bin/mas + '' + // { + meta.mainProgram = "mas"; + }; +in +{ + system.primaryUser = "primary-mas-user"; + + programs.mas = { + enable = true; + user = "test-mas-user"; + package = mas; + update = true; + cleanup = false; + packages = { + Xcode = 497799835; + }; + }; + + homebrew.masApps = { + "KeepFromHomebrew" = 424242; + }; + + test = '' + echo "checking mas present in systemPackages" >&2 + test -x ${config.out}/sw/bin/mas + + echo "checking mas activation script still installs and updates apps" >&2 + grep 'desiredIds=(' ${config.out}/activate + grep '497799835' ${config.out}/activate + grep 'mas install \"$appId\"' ${config.out}/activate + grep 'mas update' ${config.out}/activate + + echo "checking cleanup-only variables are omitted" >&2 + if grep 'declare -A installedApps' ${config.out}/activate; then + echo "unexpected installedApps declaration when cleanup is disabled" >&2 + exit 1 + fi + + if grep 'keepIds=(' ${config.out}/activate; then + echo "unexpected keepIds declaration when cleanup is disabled" >&2 + exit 1 + fi + ''; +} diff --git a/tests/programs-mas.nix b/tests/programs-mas.nix new file mode 100644 index 0000000..678c2be --- /dev/null +++ b/tests/programs-mas.nix @@ -0,0 +1,71 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + mas = + pkgs.runCommand "mas-0.0.0" { } '' + mkdir -p $out/bin + cat > $out/bin/mas <<'EOF' + #!/usr/bin/env bash + exit 0 + EOF + chmod +x $out/bin/mas + '' + // { + meta.mainProgram = "mas"; + }; +in +{ + system.primaryUser = "primary-mas-user"; + + programs.mas = { + enable = true; + user = "test-mas-user"; + package = mas; + update = true; + cleanup = true; + packages = { + "1Password for Safari" = 1569813296; + Xcode = 497799835; + }; + }; + + homebrew.masApps = { + "KeepFromHomebrew" = 424242; + }; + + test = '' + echo "checking mas present in systemPackages" >&2 + test -x ${config.out}/sw/bin/mas + + echo "checking mas activation script uses requested user" >&2 + grep -- '--user=test-mas-user' ${config.out}/activate + + echo "checking mas desired ids are present" >&2 + grep 'desiredIds=(' ${config.out}/activate + grep '1569813296' ${config.out}/activate + grep '497799835' ${config.out}/activate + + echo "checking mas install loop exists" >&2 + grep 'mas install \"$appId\"' ${config.out}/activate + + echo "checking mas update is triggered" >&2 + grep 'mas update' ${config.out}/activate + + echo "checking mas parses installedApps from mas list" >&2 + grep 'declare -A installedApps' ${config.out}/activate + grep 'installedApps\[' ${config.out}/activate + + echo "checking mas cleanup log and uninstall" >&2 + grep 'removing .* from App Store' ${config.out}/activate + grep 'runAsUser .*/mas uninstall' ${config.out}/activate + + echo "checking homebrew.masApps ids are kept during cleanup" >&2 + grep 'homebrewIds=(' ${config.out}/activate + grep '424242' ${config.out}/activate + ''; +}