mas: add module for Mac App Store management

Signed-off-by: Angel J <78835633+Iamanaws@users.noreply.github.com>
This commit is contained in:
Angel J 2025-12-26 18:06:38 -08:00
parent a1fa429e94
commit deadc7204c
No known key found for this signature in database
GPG key ID: 4CE6C8BA718B9657
6 changed files with 344 additions and 0 deletions

View file

@ -112,6 +112,7 @@
./programs/fish.nix ./programs/fish.nix
./programs/gnupg.nix ./programs/gnupg.nix
./programs/man.nix ./programs/man.nix
./programs/mas.nix
./programs/info ./programs/info
./programs/nix-index ./programs/nix-index
./programs/ssh.nix ./programs/ssh.nix

209
modules/programs/mas.nix Normal file
View file

@ -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;
};
}

View file

@ -134,6 +134,7 @@ in
${cfg.activationScripts.keyboard.text} ${cfg.activationScripts.keyboard.text}
${cfg.activationScripts.fonts.text} ${cfg.activationScripts.fonts.text}
${cfg.activationScripts.nvram.text} ${cfg.activationScripts.nvram.text}
${cfg.activationScripts.mas.text}
${cfg.activationScripts.homebrew.text} ${cfg.activationScripts.homebrew.text}
${cfg.activationScripts.postActivation.text} ${cfg.activationScripts.postActivation.text}

View file

@ -95,6 +95,8 @@ in {
tests.nixpkgs-overlays = makeTest ./tests/nixpkgs-overlays.nix; tests.nixpkgs-overlays = makeTest ./tests/nixpkgs-overlays.nix;
tests.nixpkgs-config-allow-unfree = makeTest ./tests/nixpkgs-config-allow-unfree.nix; tests.nixpkgs-config-allow-unfree = makeTest ./tests/nixpkgs-config-allow-unfree.nix;
tests.programs-gnupg = makeTest ./tests/programs-gnupg.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-ssh = makeTest ./tests/programs-ssh.nix;
tests.programs-tmux = makeTest ./tests/programs-tmux.nix; tests.programs-tmux = makeTest ./tests/programs-tmux.nix;
tests.programs-zsh = makeTest ./tests/programs-zsh.nix; tests.programs-zsh = makeTest ./tests/programs-zsh.nix;

View file

@ -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
'';
}

71
tests/programs-mas.nix Normal file
View file

@ -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
'';
}