Building packages can turn complex very quickly. We will start with a few simple examples first and build up from there.
You should read the first two posts in this series, first:
Boring Wallpaper
For our first simple example, imagine your organization insists that every Mac use the same wallpaper (or desktop picture). The first step for an admin is to provide the image file on each Mac.
Apple changed the naming for the background image in macOS Ventura from ‘desktop picture’ to ‘wallpaper’ in macOS Sonoma to match the naming across all their platforms. As we will see, the old name still appears in some places.
Technically, an image file used as a wallpaper image could be stored anywhere on the disk. When you place image files in /Library/Desktop Pictures/
(still the old name) a user will see them as a choice among the pictures in the ‘Wallpaper’ pane in System Settings. They will have to scroll all the way to the right in the ‘Dynamic Wallpapers’ or ‘Pictures’ section but the images you add in that folder will appear there.
The /Library/Desktop Pictures
folder does not exist on a ‘clean’ installation of macOS, but the installation package will create it for us.
All the resources for building the package have to be provided in a certain folder structure. It is easiest to contain all of that in a single project folder.
I will provide the instructions to create and build the projects in the command line as they are easier to represent than descriptions of how to achieve something in the graphical user interface. It does not really matter whether you create a folder from Terminal or in Finder. Do what you are comfortable with. However, Getting comfortable with command line tools in Terminal is an important step towards automated package creation workflows.
My book “macOS Terminal and Shell” can teach you the fundamentals. Buy it on Ko-Fi or Apple Books!
I often find it helpful to have a Finder window for the current working directory open next to Terminal. You can open the current working directory in Finder with the
open .
command.
Create a folder for the project named BoringWallpaper in a location of your choosing.
> mkdir BoringWallpaper
> cd BoringWallpaper
You can download the sample image file I will be using here. Of course you can use another wallpaper image of your choice.
The BoringWallpaper
folder will be our project folder. Create another folder inside BoringWallpaper
named payload
. Inside the payload
folder, we will re-create the path to where we want the file be installed in the file system, in this case /Library/Desktop Pictures
.
Since the
Desktop Pictures
folder name contains a space, we need to quote the path in the shell and scripts. You can also escape the space with a backslash\
. The effect will be the same. In general, I recommend using tab-completion in the command line for file paths which will take care of special characters.
We can create the entire folder structure at once with mkdir -p
:
> mkdir -p "payload/Library/Desktop Pictures"
Then copy the first desktop picture BoringBlueDesktop.png
into payload.
> cp /path/to/BoringBlueDesktop.png "payload/Library/Desktop Pictures"
The payload folder will gather all the files we want the package to install.
Your folder structure inside the BoringWallpaper
project folder should now look like this:
📁 payload
📁 Library
📁 Desktop Pictures
📄 BoringBlueDesktop.png
The payload
folder represents the root of the target volume during installation.This will usually be the root of the system volume /
. We recreated the folder structure where we want the file to be installed in the file system. The installer will create intermediate folders that do not yet exist during installation.
In this example, the /Library
folder will already exist, but the Desktop Pictures
subfolder will not yet exist on a clean system, so it will be created and the image file will be placed inside.
When you run the installer on a system where the Desktop Pictures
subfolder already exists, the image will be placed inside that. Should a file with the same name already exist in that location, it will be overwritten. Other files that might be in that folder will generally not be affected.
Introducing pkgbuild
macOS provides the pkgbuild
command line tool to create installer package components. Make sure your current working directory is BoringWallpaper
and run
> pkgbuild --root payload --install-location / --identifier com.example.BoringWallpaper --version 1 BoringWallpaper.pkg
The --root
option designates the root of our payload, so we pass the payload
folder.
The identifier should be a string modeled on the reverse DNS of your organization, e.g. com.scriptingosx.itservices.BoringWallpaper
. The exact form of the identifier does not really matter as long as it is unique.
We will use com.example.BoringWallpaper
for the identifier. For the exercise, you can use those or replace them with your own. When you build packages for production or deployment they should use your organization’s reverse DNS format.
This should create a BoringWallpaper.pkg
file in your project folder.
You should now inspect the resulting pkg file with the tools from earlier:
> lsbom $(pkgutil --bom BoringWallpaper.pkg )
. 40755 0/0
./Library 40755 0/0
./Library/Desktop Pictures 40755 0/0
./Library/Desktop Pictures/BoringBlueDesktop.png 100644 0/0 17393342618871431
(You may see a ._BoringBlueDesktop.png
file appear here. That is a resource fork file. Preview.app sometimes creates these for file previews. You can safely ignore those.)
There are two relevant things to notice here: the payload contains the intermediate folders /Library
and /Library/Desktop Pictures
, which means they will be created, should they not exist on the system yet. This is generally what we want to happen, but a good thing to keep in mind.
Also notice that pkgbuild
set the owner and group ID for the folders and the image file to 0/0
or root:wheel
. This is the default ownership for files installed by packages, which ensures that non-admin users cannot change, delete or overwrite the files. This is a useful default, but there are options to have more granular control.
pkgbuild
will always preserve the file mode or access privileges. When you change the file mode in of the file in the payload folder before you run pkgbuild
, the command line tool will use that for the payload and Bom. In this case, the 644
or r-wr--r--
file mode works quite well, but for a test, let’s change the mode to 444
(removing the write access for the owner) and re-run the pkgbuild
command.
> chmod u-w payload/BoringBlueDesktop.png
> pkgbuild --root payload --install-location / --identifier com.example.BoringWallpaper --version 1 BoringWallpaper.pkg
pkgbuild: Inferring bundle components from contents of payload
pkgbuild: Wrote package to BoringWallpaper.pkg
> lsbom $(pkgutil --bom BoringWallpaper.pkg )
. 40755 0/0
./Library 40755 0/0
./Library/Desktop Pictures 40755 0/0
./Library/Desktop Pictures/BoringBlueDesktop.png 100644 0/0 17393342618871431
Note that running the
pkgbuild
command again overwrote the previously generated pkg file with warning. This is generally not a problem, but something you need to be aware of.
We will want to change the image file in the following steps, so add the writable flag back:
> chmod u+w "payload/Library/Desktop Pictures/BoringBlueDesktop.png"
Handling extended attributes
In recent versions of macOS, pkgbuild
will preserve extended file attributes in the payload.
This is a change in behavior to earlier versions of macOS, where you had to use the undocumented
--preserve-xattr
option to preserve extended attributes.
Most extended attributes contain metadata for Finder and Spotlight. For example, when you open the image file in Preview, you will get a com.apple.lastuseddate#PS
extended attribute. You can use the -@
option of the ls
command to see extended attributes:
> open "payload/Library/Desktop Pictures/BoringBlueDesktop.png"
> ls -l@ "payload/Library/Desktop Pictures"
total 3400
-rw-r--r--@ 1 armin staff 1739334 Aug 1 14:03 BoringBlueDesktop.png
com.apple.lastuseddate#PS 16
You generally do not want to have extended attributes be part of your package payload. This is especially true of quarantine flags!
There are some exceptions. For example, signed shell scripts store the signature information in an extended attribute. In these cases you will have to carefully build your package creation workflow to ensure only the desired extended attributes are preserved in the package and installed to the target file system.
You can remove extended attributes recursively with the xattr
command:
> xattr -cr payload
Then rebuild the package:
> pkgbuild --root payload --install-location / --identifier com.example.BoringWallpaper --version 1 BoringWallpaper.pkg
pkgbuild: Inferring bundle components from contents of payload
pkgbuild: Wrote package to BoringWallpaper.pkg
Creating a Build Script
The command line tools to create installer package files have a large number of options. Even in our simple example, pkgbuild
requires several options and arguments. Each one needs to be entered correctly so the installer process does the right thing. An error in the identifier or a version number will result in unexpected behavior that may be very hard to track down. In addition, there are steps like running xattr -c
that need to be performed before creating the package.
To avoid errors and simplify the process, we will create a shell script which runs the required commands with the correct options. The script will always repeat the commands with the proper arguments in the correct order, reducing the potential for errors. Updating the version is as simple as changing the variable in the script.
In your favored text editor create a file named buildBoringWallpaperPkg.sh
and with the following code:
#!/bin/sh
pkgname="BoringWallpaper"
version="1.0"
install_location="/"
identifier="com.example.${pkgname}"
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
projectfolder=$(dirname "$0")
payloadfolder="${projectfolder}/payload"
# recursively clear all extended attributes
xattr -cr "${payloadfolder}"
# build the component
pkgbuild --root "${payloadfolder}" \
--identifier "${identifier}" \
--version "${version}" \
--install-location "${install_location}" \
"${projectfolder}/${pkgname}-${version}.pkg"
The script first stores all the required pieces of information in variables. This way you can quickly find and change data in one place and do not have to search through the entire script.
The identifier variable is composed from our com.example reverse-DNS prefix and the pkgname variable set earlier.
Then the script sets the shell PATH
variable, which is always a prudent step.
In the next line, the script determines the folder enclosing the script, by reading the $0
argument which contains the path to the script itself and applying the dirname
command which will return the enclosing folder. This way we can use the projectfolder
variable later to write the resulting pkg file into a fixed location (the project folder), instead of the current working directory.
Finally the pkgbuild
command is assembled from all the variables.
The backslash
\
in a shell script allows a command to continue in the next line. Instead of a command in a single very long line we can have one line per argument. This makes the script easier to read and update.
In Terminal, set the script file’s executable bit with
> chmod +x buildBoringWallpaperPkg.sh
Delete the original BoringWallpaper.pkg
and run the build script.
> rm BoringWallpaper.pkg
> ./buildBoringWallpaperPkg.sh
pkgbuild: Inferring bundle components from contents of ./payload
pkgbuild: Wrote package to ./BoringWallpaper-1.0.pkg
This will create a new package file named BoringWallpaper-1.0.pkg
.
Now you do not have to remember every option exactly but instead can run ./buildBoringDesktopPkg.sh
. If you need to change options like the version number, it is easy to do by changing a variable. Package creation is easy to repeat and if you use a version control system (e.g. git) changes to the script are tracked.
You have literally codified the package creation process. If you are working in a team, you can point to the script and say: “This is how we create this package!” and everyone who has access to the script can recreate the package creation workflow. They can also read the script and understand what is going to happen. A script like this does not replace the need for documentation, but is better than no documentation at all.
To be precise, the build script and the files and folder structure in the payload are required together for the package creation workflow. They should be kept and archived together.
Ideally together with documentation describing:
- motivation/reason for building this package
- where and how the package is intended to be used
- whether the software installed requires certain configurations that are not provided by the pkg, such as licenses or default settings through a script or a configuration profile and where and how to obtain and set those
- macOS and platforms (Intel or Apple silicon) the package was built on
- macOS versions and platforms the package was tested on
- where to obtain the resources in the payload, should they get lost or need to be updated
- person(s) or team responsible for this package project
- version history or change log
- an archive of older versions of the pkg file
- uninstall process or script
- any other relevant links and history, for example problems and issues that lead to certain design choices
For developers, scripts like this can be part of an automated release or CI/CD workflow.
You do not have to include the version number in the package name, but it helps in many situations. It also helps when you build an archive or history of installers. You never know when you will need an older version of an installer. When vendors/developers provide the version number in their file names, it helps admins and users identify new or outdated versions.
You should (again) inspect the new package file you just created in Suspicious Package and using pkgutil
.
Testing the Package
Finally, you can install this package on your test machine.
For this simple package, you can also use the Mac you are currently working on. With more complex packages, especially when we get into installation scripts and LaunchD configuration, a virtual machine or separate testing device is strongly recommended.
When you run the package in the Installer application, note that the dialogs are the defaults and very terse. System administrators will rarely build packages that are meant to be installed with the user interface, so this is not a problem. Most administrative package files will be installed by management systems in the background and never show the UI.
Developers can customize the user interface for installer packages with distribution files, we will get to in a future post.
You can also use the installer command to install the package:
> sudo installer -pkg BoringWallpaper-1.0.pkg -tgt / -verbose
installer: Package name is BoringWallpaper-1.0
installer: Installing at base path /
installer: The install was successful.
After installing, go to /Library/Desktop Pictures
and look for the BoringBlueDesktop.png
image file, then open System Settings and go “Wallpaper.” You will have to scroll down to the “Pictures” section and all the way to the right, but the picture will appear there!
You can also open Terminal and run the pkgutil
commands to inspect what was installed: (replace com.example.*
with your identifier)
> pkgutil --pkgs="com.example.*"
com.example.BoringWallpaper
> pkgutil --info com.example.BoringWallpaper
package-id: com.example.BoringWallpaper
version: 1.0
volume: /
location:
install-time: 1754058568
> pkgutil --files com.example.BoringWallpaper
BoringBlueDesktop.png
> pkgutil --file-info /Library/Desktop\ Pictures/BoringBlueDesktop.png
volume: /
path: /Library/Desktop Pictures/BoringBlueDesktop.png
pkgid: com.example.BoringWallpaper
pkg-version: 1.0
install-time: 1754058568
uid: 0
gid: 0
mode: 100644
If you created an installer package that attempted to install the image file in /System/Library/Desktop Pictures
, it would fail. This directory is protected by two technologies, System Integrity Protection and the read-only sealed System volume. Figuring out the proper location to install management files is an important, but sometimes complicated task.
Removing the installed files
If you tested this package on your main Mac, you can remove the installed files with the following commands:
> sudo rm /Library/Desktop\ Pictures/BoringBlueDesktop.png
> sudo rmdir /Library/Desktop\ Pictures
> sudo pkgutil --forget com.example.BoringWallpaper
Note the rmdir
command will error when there are other files remaining in it. This is intentional here, since we only want to remove the folder if it is empty and not remove files other than those we installed. The /Library
folder is part of the base macOS installation, so we are not going to touch it.
Similar to the build script, it can be useful to maintain the un-install script while developing the package, especially for more complex installs. An un-install script for this project might look like this:
#!/bin/sh
# uninstall Boring Wallpaper
# reverts the installation of com.example.BoringWallpaper
# remove the file
rm -vf "/Library/Desktop Pictures/BoringBlueDesktop.png"
# remove folder
# fails when there are other files remaining inside
# we do not want to affect files we did not install
rmdir -v "/Library/Desktop Pictures"
# forget the pkg receipt
pkgutil --forget com.example.BoringWallpaper
Since the installed file and folder are owned by root, you need to run the entire script with root privileges or sudo:
> sudo ./uninstallBoringWallpaper.sh
Note that many device management services offer the option to run scripts on the Mac clients and they generally run the scripts with root privileges. Consult your management service’s documentation for details.
You have to remember to update the uninstall script when you change the payload and other settings in the package. This will be useful for your testing.
Developers or vendors can also provide the uninstall script to customers in case they need to uninstall the software. Administrators could use the uninstall script in a self service portal to allow end users to remove software they no longer require.
Note that software might create other files while it is running that are not part of the installer package. Use your judgment whether they need to be removed as part of the uninstall script. Some files might contain user data that should be preserved, even when the software is deleted.