The latest issue (#335) of the weekly news summary for MacAdmins is available over on MacAdmins.news!
Installomator v10.6
It is that time again, we have released a new version of Installomator!
Many thanks to all the maintainers and contributors!
It has been quite a while since we published a release and there was quite a backlog. In some ways, Installomator is a victim of its own success. It is quite easy to create a new label and many contributors are doing that. It is also easy to provide an update to a broken label and while many contributors are doing that, there are fewer of them.
This means that we are accumulating a lot of stale and broken labels. Since there is now way for us to gather information on which labels are actually being used and how much, we end up with lot of turnover.
The team of maintainers has some ideas to tackle some of these problems with automation, but human curation will have to remain a step all along the way.
You can get the latest version of Installomator on the repo or download the installer pkg from here.
Swift Command Line Tools and Argument Parser — Part 1
When building tools in Swift, I usually start with a command line tool. This allows me to ignore the complexity of creating a user interface while figuring out the underlying APIs and data models.
Technically, command line tools have a user interface, as well. They print output to pipes, standard out or standard error for data, progress, status or other information. They should provide information using the exit status. They take input from standard in, pipes, environment variables, or from command line arguments.
While there are many subtleties to consider with all of these, these “interfaces” are still less complex to handle than a full user interface built with AppKit, UIKit or SwiftUI.
Swift provides the functionality to deal with files, outputs, pipes with APIs. This post will not cover those. This post will focus on getting arguments from the command line.
Note: this tutorial was written using Swift 5.10, Xcode 15.4, swift-argument-parser 1.5 on macOS 14.6.1. The details and user interfaces may change with different versions, but the fundamentals should remain.
CommandLine.arguments
The built-in way to get the arguments for your process is with CommandLine.arguments
which returns an array of String
objects. CommandLine.arguments
is quite basic, but can be suitable for simple use cases.
Create a project directory named CLArgs
and use swift package init
to create a swift package to build an executable:
> mkdir CLArgs
> cd CLArgs
> swift package init --type executable
This will create a skeleton project, you will find a basic “Hello, world” code in Sources/main.swift
. Replace the print
statement there with:
import Foundation
let arguments = CommandLine.arguments.dropFirst()
guard let name = arguments.first
else {
print("command requires an argument!")
exit(1)
}
print("Hello, \(name)")
Note: You can use your favorite text editor or IDE to edit SPM projects. You can also use Xcode. When you run xed .
in the Swift package directory, Xcode will open the Swift package in a project view. You can edit, build and run the package in Xcode or use Xcode for editing and build and run it from the command line.
In terminal, build and run the project with
> swift run CLArgs Armin
This tells the swift
to build the CLArgs
target defined in Package.swift
and run it with the argument Armin
. You should see this output:
> swift run CLArgs Armin
Building for debugging...
[1/1] Write swift-version-39B54973F684ADAB.txt
Build of product 'CLArgs' complete! (0.11s)
Hello, Armin
Let’s look at the code in detail.
let arguments = CommandLine.arguments.dropFirst()
CommandLine.arguments
returns an array of strings. By convention, the first argument (arguments[0]
) contains the path to the executable. In most situations, you will not be interested in this first argument. One straightforward way to deal with this is to ‘drop’ the first element of the array right away.
guard let name = arguments.first
else {
print("command requires an argument!")
exit(1)
}
We get the first element of the arguments
array. Additional arguments are simply ignored. When no arguments are provided, this will return nil and guard
statement will trigger, where we print an error message and exit the code with a non-zero value, signaling a failure.
print("Hello, \(name)")
The actual point of this sample code: print a greeting with the name.
In this simplest of examples, we spend a majority of the code on preparing the arguments and verifying that they meet our requirements.
CommandLine.arguments
will serve you well for simple needs and quick command line tools. However, you will quickly notice that a robust command line tool needs to verify the existence of certain arguments, whether the value matches certain criteria, and print error messages and usage directions when the arguments don’t match the expectations. Many command line tools also have flags and options with short and long forms that need to be processed.
This turns into a lot of code very quickly.
Swift Argument Parser
Enter Swift Argument Parser. A package that provides “straightforward, type-safe argument parsing for Swift.”
You could modify the Package.swift
file in our CLArgs project to import Swift Argument Parser but there is an even easier way to start. Back out of the CLArgs
project directory and create a new one:
> cd ..
> mkdir SwiftArg
> cd SwiftArg
> swift package init --type tool
When you inspect the Package.swift
file in this new project, you will see that it is already linked to the Swift Argument Parser package. Sources/SwiftArgs.swift
contains another “Hello, world” template code, but using Swift Argument Parser.
import ArgumentParser
@main
struct SwiftArgs: ParsableCommand {
mutating func run() throws {
print("Hello, world!")
}
}
The struct here implements the ParsableCommand
protocol which allows us to use all the nice functionality from the ArgumentParser
library. It is also marked with the @main
tag, which tells the compiler to run the main()
function in this when the binary is launched. The main()
function is implemented by ParsableCommand
which, well, parses the arguments and then launches the run()
function.
Swift Package Manager vs Xcode projects
You can open and edit Swift Package Manager projects in Xcode with the xed .
command. Recent Xcode versions know how to work with SPM projects without needing to create an Xcode project file. Xcode will use the configurations in the Package.swift file. This is useful when you like to work in Xcode, but want the project to remain transferable to other editors or IDEs.
There is a weird quirk. When you build and/or run the project from within Xcode it will use the default Xcode build directory (default is ~/Library/Developer/Xcode/DerivedData/
). This is different from the location that the swift build
or swift run
commands in Terminal use (.build
in the package directory). This can lead to longer build times and confusion.
You can also use swift-argument-parser and other packages within Xcode projects. This can be necessary if you are building the command line as a target within a larger Xcode project. Maybe you want to use some of Xcode’s advanced features for managing projects, like build phases and Archives. Or maybe you just prefer working in Xcode.
To create a command line with ArgumentParser in Xcode, create a new Project and select the ‘Command Line Tool’ template for macOS. Once the new project is created, select ‘Add Package Dependencies…’ from the File menu. Locate ‘swift-argument-parser’ in the ‘Apple Swift Packages’ collection or just enter the URL in the search field and click ‘Add Package…’ (twice)
Then, you have to delete the main.swift
file from the template and create a new SwiftArgs.swift
with this code:
import Foundation
import ArgumentParser
@main
struct SwiftArgs: ParsableCommand {
mutating func run() throws {
print("Hello, world!")
}
}
This is the same as the template code created with the swift package init --type tool
from above.
When testing and running the command line tool in Xcode will will want to pass arguments into the binary. You can do so by editing the scheme. Choose Product > Scheme > Edit Scheme… from the menu or click on the target icon (the one with the command line icon in the center of the menu bar) and select Edit Scheme… Make sure you are on the ‘Run’ section in that dialog and select the ‘Arguments’ tab. Here you can add, remove, enable or disable the arguments that Xcode passes into your tool when you run it from Xcode.
Continually changing the arguments in the scheme editor can be tedious. You can also use ‘Show Build Folder in Finder’ from the ‘Product’ menu, open the Products/Debug
folder in Terminal by dragging that folder on to the Terminal icon in the Dock and run the built command from there with ./SwiftArgs
Whichever way you prefer to create and work with your project, the rest of this tutorial will work the same way.
Using Swift Argument Parser
Right now, we are just running print("Hello, world!")
, which is quite underwhelming. Let’s step this up just a little bit:
@main
struct SwiftArgs: ParsableCommand
@Argument var name: String
func run() {
print("Hello, \(name)")
}
}
First we create a property called name
of type String
with the @Argument
property wrapper. This tells the ArgumentParser
library, that we want this variable filled with an argument from the command line. When the run()
function is called, we can “just use” the name property, like any other Swift property.
When you run this, something interesting happens: we get an error!
> swift run SwiftArgs
Error: Missing expected argument '<name>'
USAGE: swift-args <name>
ARGUMENTS:
<name>
OPTIONS:
-h, --help Show help information.
When you check the exit code of the command with echo $?
you see it return an error code of 64. This means it was missing arguments or got malformed arguments. As should be good practice for command line tools, our tool did print a help message, describing what it was expecting. Here we see that our SwiftArgs command expects a single argument giving a name.
Run the command again, but with an argument:
> swift run SwiftArgs Armin
Hello, Armin
Now everything works as expected. When our tool launches, ArgumentParser grabs the argument, places it in our name
property and executes the run()
function in our struct that implements ParsableCommand
. Since ArgumentParser errors out with the help message, when an argument is missing or too many arguments are present, we can be certain that the name
variable is populated when our code runs.
Command Configuration
There is a small detail that is bugging me, though. The help message generated by ArgumentParser deduced that the name of binary should be swift-args
instead of SwiftArgs
, but the binary name is SwiftArgs
, which is the name of the directory we initialized the project in. This is because of different naming standards for Swift types and command line tools. You can change the name of the executable created in the Package.swift
file in line 15 under .executableTarget
.
We could change the name to something completely different here, say apdemo
for ‘Argument Parser Demo. When you apply that change in
Package.swift` it changes the name of the binary, but the auto-generated help message does not pick that up. It still use the auto-generated name.
> swift run apdemo --help
USAGE: swift-args <name>
ARGUMENTS:
<name>
OPTIONS:
-h, --help Show help information.
(Isn’t it neat that ArgumentParser automatically implements --help
and -h
flags?)
We could change the name of our struct, which will work in simple situations. But you will have a situation where the struct name will not match what you want for the executable name. There is a way to tell ArgumentParser exactly what we want, though.
Insert this code below the struct SwiftArgs
line and above the @Argument
:
static let configuration = CommandConfiguration(
commandName: "apdemo"
)
When you now look at the generated help again, the command name matches:
OVERVIEW: apdemo - swift-argument-parser tutorial tool
USAGE: apdemo <name>
ARGUMENTS:
<name>
OPTIONS:
-h, --help Show help information.
There is more information we can provide in the command configuration. Extend the CommandConfiguration
initializer like this:
static let configuration = CommandConfiguration(
commandName: "apdemo",
abstract: "apdemo - swift-argument-parser tutorial tool",
version: "0.1"
)
and run the command to get the help message again.
OVERVIEW: apdemo - swift-argument-parser tutorial tool
USAGE: apdemo <name>
ARGUMENTS:
<name>
OPTIONS:
--version Show the version.
-h, --help Show help information.
The abstract
appears as an ‘overview’ above the help message and we now see a new option --version
. When you run the tool with that option, you will not be surprised to see the 0.1
provided in the configuration, but it is useful nonetheless.
There are more fields you can provide in the CommandConfiguration
: discussion
allows you to provide a long form description of the command. usage
allows you to override the auto-generated usage text. There are some more that we will explore later.
More Arguments
You can add more @Arguments
and they will be filled in order from the arguments provided at the command line. Add another property with the @Argument
wrapper:
@Argument var name: String
@Argument var age: Int
func run() {
print("Hello, \(name)!")
print("You are \(age) years old.")
}
When you run the tool without any arguments, you can inspect the updated help message. The usage and arguments area now shows both expected arguments. When you run the tool with a single argument, you get an abbreviated help, showing only the missing argument. When you provide a name and a number as command line arguments, everything works as expected.
But what if you provide two strings?
> swift run apdemo Armin lalala
Error: The value 'lalala' is invalid for '<age>'
Help: <age>
Usage: apdemo <name> <age>
See 'apdemo --help' for more information.
We declared the age
property as an Int
, so ArgumentParser expects an integer number for the second argument. When the second argument cannot be parsed into an integer, it shows the error.
Change the type of the age
property to a double and run it again with a decimal for the age.
Some Help, please?
name
and age
might be enough to tell a user of your command line tool what to enter. But I think we should provide a bit more explanation. You can attach a help
message to the argument:
@Argument(help: "a name")
var name: String
@Argument(help: "age (integer number)")
var age: Int
I have broken the property declarations into two lines each for clarity and changed the age
back to an Int
for simplicity. The help messages will appear next to the argument names in the long and short help messages.
Off to a good start
We have just started to scratch the surface of what swift-argument-parser can do for us. In the next part, we will cover options and flags.
Building a LaunchD Installer pkg for desktoppr (and other tools)
In my last post introducing desktoppr 0.5, I said that a Mac admin could build a customized pkg with a customized LaunchAgent property list, that would run desktoppr manage
at login and every few hours in the background to re-set the wallpaper when the configuration profile is updated.
I admit I glossed over the process of building a package that installs and launches a LaunchAgent (or LaunchDaemon). Since that is not a trivial configuration, I will describe the details in this post.
LaunchAgent? LaunchDaemon? LaunchD?
On macOS, launchd
is the system-wide process that controls and launches every other process running on the system. When you look at the hierarchical process list in Activity Monitor, you will always see launchd
running with the process ID 1
as the single child process of kernel_task
and all other processes are a child of launchd
.
More practically, users and admins can use launchd
to control processes that need to be available at all times or be launched in certain intervals or at certain events.
launchd
has two types of processes: LaunchDaemons and LaunchAgents. Since the terms daemons (or demons) and agents do not have clear definitions outside of launchd
, a bit of explanation is required.
LaunchDaemons
LaunchDaemons are processes that run with system privileges (“as root”). Processes that belong to your management system and security suites may need to run periodically or on certain events in the background and require root level access to the system to do their jobs. Other processes or services need to listen for incoming network traffic on privileged ports.
Note that background process are often called ‘agents’ regardless of whether they run with root privileges or as a user. The launchd terminology is more precise here.
Apple has done a lot of work over the past few year to provide Endpoint Security or Network Extension frameworks that mitigate the need for third party root daemons, but there are still relevant use cases, especially when you are a managing Macs.
A LaunchDaemon is configured by providing a property list file in /Library/LaunchDaemons
. This configuration plist will be automatically loaded at reboot, or can be loaded using the launchctl
command.
LaunchAgents
LaunchAgents are processes that run with user privileges. Generally, if something needs to read or write to anything in the user home folder, it should be a LaunchAgent. Over the recent years, Apple has put restrictions on what data process can access in user space, which has made LaunchAgents more of a pain to manage, but they still have their use cases.
A LaunchAgent is configured by providing a property list file in /Library/LaunchAgents
on the system level or in ~/Library/LaunchAgents
in a user’s home folder. Configuration property lists that are in a user home folder, will only run for that user, while LaunchAgents that are configured in the system wide /Library/LaunchAgents
will be loaded for all users on the system.
A LaunchAgent configuration plist will be automatically loaded at login, or can be loaded using the launchctl
command.
Since desktoppr
affects a user setting (the desktop/wallpaper), it has to run as the user, so we need to build and install a LaunchAgent. Creating and installing a LaunchDaemon is pretty much the same process, though.
Ingredients
To build an installation package to install and run a LaunchAgent or LaunchDaemon, you need three ingredients:
- the binary or script which will be launched
- a launchd configuration property list
- installation scripts to unload and load the configuration
For desktoppr, we already have the binary (downloadable as a zip from the project release page) and a sample configuration property list file.
The sample launchd configuration looks like this:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.scriptingosx.desktopprmanage</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/desktoppr</string>
<string>manage</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>10800</integer>
</dict>
</plist>
The label is required and is used to uniquely identify the LaunchAgent or Daemon with launchd and the launchctl
command. Reverse domain notification is common. The filename of the configuration property list should match the label.
The ProgramArguments
key provides an array with the command and arguments that will be launched. The first item in the array should provide the full path to the binary or script. Then you provide arguments, one string
item per argument. Since the individual arguments are separated by the string
tags, you do not need to escape spaces or other special characters in the arguments.
It is worth noting that launchd
will launch the binary or script directly, so syntax and substitutions that are done in shells will not work. This includes variable and command substitution like $HOME
, $USER
, or $(date)
as well as pipes and output redirection. You get a single command with a list of static arguments. (You can use the StandardOutPath
and StandardErrorPath
keys in the launchd config plist to redirect output.)
For the desktoppr LaunchAgent, that suits us just fine, as we are only passing the manage
argument which tells desktoppr
to get the details from a configuration profile (sample here).
The next key, RunAtLoad
tells launchd, to run the binary with the arguments immediately when the configuration file is loaded. Since configuration files in /Library/LaunchAgents
and ~/Library/LaunchAgents
are automatically loaded when a user logs in, this generally means desktoppr manage
will be run when a user logs in, which suits us well.
The last key StartInterval
tells launchd to re-launch the process after a certain time (given in seconds, the 10800
seconds in our sample file translate to three hours). Should the system be sleeping at the time, it will not run the LaunchAgent at that time, or when the system wakes up, but wait until the next interval period comes around.
There are other keys that control when the process gets launched, such as StartCalendarInterval
or WatchPath
. You can read details in the man page for launchd.plist
or on the excellent launchd.info page. There are also apps, like Peter Borg’s Lingon that provide a user interface for creating these plist files.
Move or copy the plist file into the correct folder. The plist files in /Library/LaunchDaemons
and /Library/LaunchAgents
must be owned by root
. They must not be writable by group or other. The file mode 644
(rw-r--r--
) is recommended. Similarly, the binary or script needs to be owned by root
and cannot be writable by group or other. The file mode 755
(rwxr-xr-x
) is recommended. Wrong file privileges will result in a Load/Bootstrap failed: 5: Input/output error
.
Make sure that the binary or script does not have a quarantine flag attached. You can check with ls -al@
or xattr -p com.apple.quarantine /path/to/file
and remove a quarantine flag with xattr -d com.apple.quarantine /path/to/file
. When the binary or script has the quarantine flag, the configuration file will load fine but the actual execution will fail quietly.
Loading the LaunchAgent or LaunchDaemon
The easiest way to load a LaunchDaemon is to restart the Mac. The easiest way to load a LaunchAgent is to log out and login. This is usually not practical.
The launchctl
command manages LaunchDaemons and LaunchAgents and we can use this to load LaunchAgents and LaunchDaemons on demand.
To load our desktoppr agent, use
> launchctl load /Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist
People are already readying their pitchforks here. “Hold on,” they say. “Apple has marked load
and unload
as ‘legacy.’ You shouldn’t use them!”
While it is true that load
and unload
(and a bunch of other commands) are labeled as ‘legacy’ in the launchctl
man page, this does not mean they are deprecated and should be avoided. The difference between the legacy and the ‘new’ commands is that the legacy commands pick up whether a process should be run in user or root context (the ‘target domain’) from the context that launchctl
runs in, whereas the new bootstrap
and bootout
commands need the target domain to be stated explicitly.
This makes the modern commands more precise, but often more wordy to use. You will see that the legacy commands are simpler to use in the interactive terminal, while the ‘modern’ commands are more useful in scripts. The equivalent ‘modern’ command is:
> launchctl bootstrap gui/501 /Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist
where 501
is the user ID for your user account. This is 501
by default for the first user on on a macOS system, but you should verify what your account’s user ID actually is with id -u
.
See what I mean with “more wordy to use”? That doesn’t mean they are bad or should be avoided, but that we can be picky about when to use which format. You could argue that using the modern commands throughout would be more consistent, but you still find the ‘legacy’ command is lots of documentation, so I feel a Mac admin needs to be aware of both.
Note: the ‘modern’ commands were introduced in OS X Yosemite 10.10 in 2014.
To load a LaunchDaemon, you need to run launchctl
with root privileges. In the interactive shell, that means with sudo
:
> sudo launchctl load /Library/LaunchDaemons/com.example.daemon.plist
or
> sudo launchctl bootstrap system/ /Library/LaunchDaemons/com.example.daemon.plist
To stop a LaunchAgent from being launched going forward, unload the configuration:
> launchctl unload /Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist
or
> launchctl bootout gui/501/com.scriptingosx.desktopprmanage
The bootout
command uses the label
instead of the file path.
Putting it all together in an installer
Note: I will be showing how to build and installer package for our LaunchAgent using command line tools. These instructions should have all the information you need, even if you prefer using apps such as Whitebox Packages or Composer.
If you are not comfortable with using Terminal on macOS yet, please consider my book “macOS Terminal and Shell.” If you want to learn more about building installation packages, consider my book “Packaging for Apple Administrators.”
Download the zip file for desktoppr
from the releases page. Download the sample launchd property list file and modify the launch criteria (StartInterval
or StartCalendarInterval
) to your requirements.
Open Terminal, change directory to a location where you want to create the project directory and create a project directory:
> mkdir DesktopprManagePkg
> cd DesktopprManagePkg
Then create a payload
and a scripts
directory in this project folder:
> mkdir payload
> mkdir scripts
Unzip the desktoppr
binary from the downloaded zip archive into the right folder hierarchy in the payload
folder:
> mkdir -p payload/usr/local/bin/
> ditto -x -k ~/Downloads/desktoppr-0.5-218.zip payload/usr/local/bin/
Remove the quarantine flag from the expanded desktoppr
binary and test it by getting the version and the current wallpaper/desktop picture:
> xattr -d com.apple.quarantine payload/usr/local/bin/desktoppr
> payload/usr/local/bin/desktoppr version
0.5
> payload/usr/local/bin/desktoppr
/System/Library/CoreServices/DefaultDesktop.heic
Create the /Library/LaunchAgents
directory in the payload
and copy or move the LaunchAgent configuration plist there:
> mkdir -p payload/Library/LaunchAgents/
> cp ~/Downloads/com.scriptingosx.desktopprmanage.plist payload/Library/LaunchAgents/
When the installation package runs, the files in the payload will be moved to the respective locations on the target drive. The pkgbuild
tool will set the owner of the files to root
when building the package, but we should verify that the file mode is correct:
> stat -f %Sp payload/usr/local/bin/desktoppr
-rwxr-xr-x
> stat -f %Sp payload/Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist
-rw-r--r--
If the privileges don’t match the desired mode, set them with chmod
> chmod 755 payload/usr/local/bin/desktoppr
> chmod 644 payload/Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist
and run the stat
commands from above again to verify.
If you built the installer package now, it will install the files in the right locations and the LaunchAgent will load and run desktoppr manage
on next login. This will set the desktop/wallpaper according to the information in the configuration profile. You should install and update the configuration profile using your management system.
Loading the LaunchAgent or LaunchDaemon
However, as I said earlier, requiring or waiting for a logout or reboot is not ideal, we would like to have the LaunchAgent load immediately when the package is installed. We can achieve this by adding a postinstall
script to the installation package. This script will be executed after the payload files have been installed.
With your favored text editor, create a file named postinstall
(no file extension) with this content in the scripts
sub directory.
#!/bin/sh
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
label="com.scriptingosx.desktopprmanage"
launchdPlist="/Library/LaunchAgents/${label}.plist"
# do not load when target volume ($3) is not current startup volume
if [ "$3" != "/" ]; then
echo "not installing on startup volume, exiting"
exit 0
fi
# for LaunchAgent, check if user is logged in
# see https://scriptingosx.com/2019/09/get-current-user-in-shell-scripts-on-macos/
currentUser=$(echo "show State:/Users/ConsoleUser" | scutil | awk '/Name :/ { print $3 }')
# don't load at loginwindow
if [ "$currentUser" = "loginwindow" ] || [ "$currentUser" = "_mbsetupuser" ]; then
echo "no user logged in, exiting"
exit 0
fi
uid=$(id -u "$currentUser")
echo "loading $label"
launchctl bootstrap "gui/$uid" "$launchdPlist"
Set the proper file privileges on the postinstall
file with
> chmod 755 scripts/postinstall
First the script sets the PATH
. Then some variables for the label and bath to the label. You can change these to adapt the script to other LaunchAgents or LaunchDaemons.
Then the scripts checks the $3
argument. The installer system passes the target volume to the postinstall
script in this argument. While it is a not common in deployment workflows any more, you can still install a pkg on a target volume that is not the current boot volume “/
“. We remain safe with our script and check for that possibility, and exit without loading the agent.
LaunchAgents have to be loaded or bootstrapped into the current user’s context. The script gets the current user and checks whether the system might be sitting at the login window. We exit without loading if the system is at the login window or if the current user is _mbsetupuser
which means the package is being installed while the system is sitting at Setup Assistant during first setup. Now that the LaunchAgent plist is in place, it will be loaded the first time the user logs in.
When there is a user logged in, we bootstrap the LaunchAgent right away. This is where the modern launchctl
syntax shows its advantages. With the legacy commands, this would be:
launchctl asuser "$uid" launchctl load "$launchdPlist"
When you load a LaunchDaemon in a postinstall
, the script is much simpler, as you don’t need to check for a current user. You can directly load the daemon into the system
target domain:
launchctl bootstrap system/ "$launchdPlist"
Unloading before installation
When you are updating existing software with a package, you have to consider the situation where a LaunchAgent or LaunchDaemon is already running. In this case, we should unload before the installation. We can add a preinstall
script (also no file extension) to the scripts
folder that will be run before the payload is installed. This closely parallels the postinstall
script.
#!/bin/sh
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
label="com.scriptingosx.desktopprmanage"
launchdPlist="/Library/LaunchAgents/${label}.plist"
# do not unload when target volume ($3) is not current startup volume
if [ "$3" != "/" ]; then
echo "not installing on startup volume, exiting"
exit 0
fi
if ! launchctl list | grep -q "$label"; then
echo "$label not loaded, exiting"
exit 0
fi
# for LaunchAgent, check if user is logged in
# see https://scriptingosx.com/2019/09/get-current-user-in-shell-scripts-on-macos/
currentUser=$(echo "show State:/Users/ConsoleUser" | scutil | awk '/Name :/ { print $3 }')
# don't unload at loginwindow or Setup Assistant
if [ "$currentUser" = "loginwindow" ] || [ "$currentUser" = "_mbsetupuser" ]; then
echo "no user logged in, exiting"
exit 0
fi
uid=$(id -u "$currentUser")
echo "loading $label"
launchctl bootout "gui/$uid" "$launchdPlist"
Again, set the proper file privileges on the preinstall
file with
> chmod 755 scripts/preinstall
There is one extra step compared to the postinstall
script. The preinstall
uses launchctl list
to check if the launchd configuration is loaded, before attempting to unload it.
Building the Package Installer
Now we get to put everything together. You can use pkgbuild
to build an installer package:
> pkgbuild --root payload/ --scripts scripts/ --version 1 --identifier com.scriptingosx.desktopprmanage --install-location / DesktopprManage-1.pkg
pkgbuild: Inferring bundle components from contents of payload/
pkgbuild: Adding top-level preinstall script
pkgbuild: Adding top-level postinstall script
pkgbuild: Wrote package to DesktopprManage-1.pkg
This works well, but it is difficult to memorize all the proper arguments for the pkgbuild
command. If you forget or misconfigure one of them it will either fail outright or lead to a pkg installer file with unwanted behavior. I prefer to put the command in a script. Create a script file buildDesktopprManages.sh
at the top of your project directory with these contents:
#!/bin/sh
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
pkgname="DesktopprManage"
version="1.1"
identifier="com.scriptingosx.${pkgname}"
install_location="/"
minOSVersion="10.13"
# determine enclosing folder
projectfolder=$(dirname "$0")
# build the component package
pkgbuild --root "${projectfolder}/payload" \
--identifier "${identifier}" \
--version "${version}" \
--ownership recommended \
--install-location "${install_location}" \
--scripts "${projectfolder}/scripts" \
--min-os-version "${minOSVersion}" \
--compression latest \
"${pkgname}-${version}.pkg"
This way, you do not have to memorize the pkgbuild
command and its arguments and it is easier to modify parameters such as the version. Remember to change the version as you build newer versions of this package going forward!
Some management systems deploy a pkg file using certain MDM commands. Then you need a signed distribution package instead of a simple component pkg file. (You can learn more about the different types of packages in this blog post or this MacDevOps YVR presentation. Building a sign distribution pkg is somewhat more involved. I have a template script for that in the repo.
Conclusion
As I said, building a LaunchAgent for desktoppr manage
is not trivial. However, building LaunchAgents and LaunchDaemons and proper installer pkgs to deploy them is an important skill for Mac admins.
There are some other tools available, such as outset
, or your management system might have options to run scripts at certain triggers. But in general, I recommend to understand the underlying processes and technologies before using tools, even when the tools are great and a good fit for the task at hand.
You can use this process of building a LaunchAgent and installer pkg for desktoppr as a template for your own scripts and projects. You can find all the files in the desktoppr repo example directory.
desktoppr 0.5 — Managed by profile
Earlier this year, I released a beta version of desktoppr 0.5 which added an option to control the tool using a configuration profile. You can read all the details of how I built out this workflow in this blog post.
I have not received any feedback on it, which can mean two things: either it is working just fine or people simply aren’t aware of the beta. I guess both could be true at the same time here?
The new release is now available in the desktoppr repo. Nothing has changed compared to the 0.5beta except the version number and updated documentation.
Using desktoppr manage
The new manage
verb allows a Mac Admin to set the wallpaper using data in a configuration profile. Together with the new option to download an image file for the wallpaper from an URL, this removes the requirement of updating two or more packages when you want to update the wallpaper on managed devices.
To use the new manage
option, you need three pieces
- the latest
desktoppr
installation pkg from the repo - a LaunchAgent that runs
desktoppr manage
- a configuration profile with your settings
desktoppr LaunchAgent
The LaunchAgent plist file controls when desktoppr will run.
The sample LaunchAgent file has both the RunAtLoad
key and a StartInterval
key. This means that desktoppr
will run when the LaunchAgent loads and repeatedly after the time set in the StartInterval
(in seconds, the sample file has 10800
, so every three hours, as long as the system is awake).
The desktoppr profile provides a setOnlyOnce
key which stops desktoppr from re-setting the same wallpaper over and over again. This way we can run desktoppr frequently, but it will only reset the wallpaper when the picture
key in the profile has changed.
Build a pkg to install the LaunchAgent with your preferred frequency (or none) in /Library/LaunchAgents
and deploy that together with the desktoppr pkg.
Configuration Profile
Since macOS Ventura, managed binaries and scripts launched by a LaunchAgent or Daemon should be pre-approved with a configuration profile from the management system, so that the user gets less dialogs about them and cannot disable them. Since desktoppr
is a signed binary, this is quite straightforward and the sample configuration profile already contains the com.apple.servicemanagement
section.
The second payload in the sample profile contains the settings. The only one that is required is the picture
key which contains either a path to a local image file for the wallpaper, or a URL to an image file. Using a hosted file allows you to provide a custom branded wallpaper and change it without needing to deploy files to the client using more custom pkgs.
When you are not using a local file, you can (and probably should) provide a sha256
key with a checksum of the image file. This should protect from attempt to inject something malicious by hi-jacking the download URL. You can generate a sha256 sum for the image file with shasum -a 256 <path/to/file>
.
The setOnlyOnce
key controls whether desktoppr manage
will re-set the wallpaper every time it runs (when setOnlyOnce
is false
) or only when the picture
key in the profile changed (when setOnlyOnce
is true
). Configuring setOnlyOnce
to true allows a user to change the wallpaper and it will not be overridden until you change the configuration profile. Then the user can change it again. This seemed like a useful compromise between management and user choice to me.
The scale
and color
keys work just like the options of the command line tool. Note that setting the color
option is broken in macOS 14 as of this writing.
You can of course use different combinations of LaunchAgent configuration and other triggers for desktoppr, such as a self service portal to get all kinds of different workflows and levels of ‘lockdown.’ Remember that Apple provides a config profile setting to fully set and lock the wallpaper.
Conclusion
I have found this new managed desktoppr option useful in my deployments. I hope you do, too. Let me know!
Goodbye, Charles Edge
“A man is not dead while his name is still spoken.”
Terry Pratchett — Going Postal
As you have probably heard, Charles Edge, prolific writer of books and blog posts, regular conference presenter, and host of the MacAdmins Podcast died unexpectedly last week-end.
This came as a shock, to me and the entire community. My thoughts are with his family, friends, and all the people he worked with in the many endeavors he was a part of.
We crossed paths frequently at conferences and local user group meetings in Los Angeles, on the MacEnterprise mailing list and IRC, and later on Mac Admins Slack. Somehow, Charles was everywhere and did everything. He also continuously motivated and encouraged others to do their “thing” and cheered them along all the way.
He was generous with knowledge, help, and advice, but most of all, with attention. At conferences and meetings, he would often be in a group, not only talking, but also listening and sharing. He loved geeking out, a fact that was demonstrated weekly on the Mac Admins Podcast.
I got invited to the podcast a few times, and even though I always suspected he know far more about… well… anything, he let me talk about my perspective and experience, neither taking the spotlight, nor hiding his enthusiasm, but sharing. It was infectious.
His enthusiasm went so much further than Mac Admin related topics. In my last conversation with him, just a few weeks ago, we talked about managed Apple IDs, Swift, Dungeons and Dragons and 3D printing. A “normal” chat with Charles… It saddens me deeply it was the last.
Reading and hearing all the memories of him, that everybody is sharing online, it is quite stunning how many lives and careers he influenced for the better.
He left a dent. Charles’ name will be spoken for a long time.
Prefs CLI Tools for Mac Admins
Recently I have been working on some… well… “stuff” that uses custom configuration profiles. Very custom, and since I am testing things, they need to be updated a lot.
The issue with defaults
When you are working with defaults/preferences/settings/property lists on macOS, you will be familiar with the defaults
command line tool. But, as useful as defaults
can be, it has some downsides.
One of the great advantages of macOS’ preference system is that settings can be provided on multiple levels or domains. In my book “Property Lists, Preferences and Profiles for Apple Administrators, I have identified 19 different levels where settings for a single application can originate.
You will be most familiar with plist files in /Library/Preferences
(system), ~/Library/Preferences
(user), and managed configuration profiles (managed). When an app or tool requests a setting, the preferences system will merge all those levels together and present only the most relevant value. When the developer uses the system APIs (correctly), they do not have to worry about all the underlying levels, domains and mechanisms very much, but automatically gain support for things like separated system and user level settings files and support for management through configuration profiles.
The macOS defaults
command line tool can work with settings on different levels or domains, but will only show the settings from one at a time. By default it only works with the user domain settings stored in ~/Library/Preferences/
. When you have settings in multiple levels or from configuration profiles, you may be able to point defaults
directly at the files. Or in the case of managed settings from profiles, you have to use a different tool. Either way, you have to determine which setting might override another and which final value might be visible to the app or process.
A new prefs
tool
Years back, I had built a python script, called prefs.py
, which would not only show the coalesced set of settings but their origin level. When macOS removed Python 2 in macOS 12.3, this tool obviously broke.
While working with preferences and profiles recently, this feature would have been quite useful to debug and verify preferences. I could have adapted the existing tool to work with MacAdmins Python 3, but felt I would learn something from recreating it in Swift. I had already started down that road just a bit for my sample project in this post.
So, you can find the new Swift-based prefs
command line tool on GitHub. You can also download a signed and notarized pkg which will install the binary in /usr/local/bin/
.
If its most basic form, you run it with a domain or application identifier. It will then list the merged settings for that preference domain, showing the level where the final value came from.
% prefs com.apple.screensaver
moduleDict [host]: {
moduleName = "Computer Name";
path = "/System/Library/Frameworks/ScreenSaver.framework/PlugIns/Computer Name.appex";
type = 0;
}
PrefsVersion [host]: 100
idleTime [host]: 0
lastDelayTime [host]: 1200
tokenRemovalAction [host]: 0
showClock [host]: 0
CleanExit [host]: 1
I find this useful when researching where services and applications store their settings and also to see if a custom configuration profile is set up and applying correctly. There is a bit of documentation in the repo’s ReadMe and you can get a description of the options with prefs --help
.
plist2profile
Another tool that would have been useful to my work, but that was also written in python 2 is Tim Sutton’s mcxToProfile
. Back in the day, this tool was very useful when transitioning from Workgroup Manager and mcx based management to the new MDM and configuration profile based methods. If you have a long-lived management service, you will probably find some references to mcxToProfile in the custom profiles.
Even after Workgroup Manager and mcx based settings management was retired, Tim’s tool allowed to create a custom configuration profile from a simple property list file. Configuration Profiles require a lot of metadata around the actual settings keys and values, and mcxToProfile
was useful in automating that step.
Some management systems, like Jamf Pro, have this feature built in. Many other management systems, however, do not. (Looking at you Jamf School.) But even then creating a custom profile on your admin Mac or as part of an automation, can be useful.
So, you probably guessed it, I also recreated mcxToProfile in Swift. The new tool is called plist2profile
and available in the same repo and pkg. I have focused on the features I need right now, so plist2profile
is missing several options compared to mcxToProfile. Let me know if this is useful and I might put some more work into it.
That said, I added a new feature. There are two different formats or layouts that configuration profiles can use to provide custom setting. The ‘traditional’ layout goes back all the way to the mcx data format in Workgroup Manager. This is what mcxToProfile would create as well. There is another, flatter format which has less metadata around it. Bob Gendler has a great post about the differences.
From what I can tell, the end effect is the same between the two approaches. plist2profile
uses the ‘flatter’, simpler layout by default, but you can make it create the traditional mcx format by adding the --mcx
option.
Using it is simple. You just need to give it an identifier and one or more plist files from which it will build a custom configuration profile:
% plist2profile --identifier example.settings com.example.settings.plist
You can find more instructions in the ReadMe and in the commands help with plist2profile --help
Conclusion
As I had anticipated, I learned a lot putting these tools together. Not just about the preferences system, but some new (and old) Swift strategies that will be useful for the actual problems I am trying to solve.
I also learnt more about the ArgumentParser package to parse command line arguments. This is such a useful and powerful package, but their documentation fails in the common way. It describes what you can do, but not why or how. There might be posts about that coming up.
Most of all, these two tools turned out to be useful to my work right now. Hope they will be useful to you!
zsh scripts and Root Escalations
There was an update for the Support.app this week which fixed a CVE. There is an argument to be had about whether this CVE deserves its high rating, but it is worth discussing the underlying issue and presenting some solutions. (Erik Gomez has some great comments on Mac Admins Slack.)
zsh is far more configurable than most other shells, most certainly more configurable than the aging bash 3.2 that comes with macOS. One aspect of being more configurable is that it has multiple different configuration files that are loaded at different times when the shell starts. The most common configuration file you will have encountered is ~/.zshrc
which is loaded for all interactive shells.
Another, less commonly used, configuration file, ~/.zshenv
(and its global sibling /etc/zshenv
) is loaded every time a zsh process launches, including script launches. That means, when you (or something else in the system) launches a script with a #!/bin/zsh
shebang, the zsh process will read the files /etc/zshenv
and ~/.zshenv
when they exist, and execute their code.
Update 2024-03-22: some links for further reading:
- Root3: Support App 2.5.2 resolves unexpected code execution
- Matteo Bolognini: zsh_checker.sh
- Matteo Boognini: custom Jamf Protect analytic
- Scripting OS X: pkgcheck (can be used to scan shebangs in scripts in pkgs)
- Dan Snelson: SQL Update Statement to Address CVE-2024-27301
The Setup
We can see this in action.
First, create a simple zsh “hello.sh” type script with this content:
#!/bin/zsh
echo "Hello, $(whoami)"
Make the script file executable (chmod +x hello.sh
) and run it. You should see output like
% ./hello.sh
Hello, armin
where armin
is replaced with your current username.
Next, create a new file ~/.zshenv
(~/
means at the root of your home directory) with your favored text editor and add the following line:
echo "zshenv: as $(whoami) called from $0"
If you already have a ~/.zshenv
you will want to rename it for now so we don’t modify that. (mv ~/.zshenv ~/.zshenv_old
)
Note that the configuration file neither has a shebang, nor does it need to be executable.
When you open a new Terminal window, you should see the line
zshenv: as armin called from -zsh
among the other output at the top of the Terminal window, since ~/.zshenv
is read and evaluated every time a zsh process starts. The shebang at the beginning of the script ensures a new zsh environment is created for it, even when your interactive shell is not zsh.
When you run hello.sh
you will see that ~/.zshenv
is executed as well:
% ./hello.sh
zshenv: as armin called from /bin/zsh
Hello, armin
So far, so good. This is how zshenv
is supposed to work. It’s purpose is to contain environment variables and other settings that apply to all processes on the system. On macOS this is undermined by the fact that most apps and process are not started from a shell, so they don’t see environment variables set anywhere in the zsh (or another shell’s) configuration files, so .zshenv
is rarely used. .zshrc
is far more useful.
But now we come to escalation aspect: run hello.sh
with root privileges using sudo
:
% sudo ./hello.sh
zshenv: as root called from /bin/zsh
Hello, root
We see that both our script and ~/.zshenv
are run with root privileges. Again, that is how sudo
and .zshenv
are supposed to work.
Since the zshenv
configuration files are evaluated every time a zsh is launched, it is far more likely that they will eventually run with elevated privileges, than the other configuration files. Other shells, like bash and sh have no equivalent configuration file that gets run on every launch.
The Escalation
The potential danger is the following: a process only needs user privileges to create or modify ~/.zshenv
. A malicious attacker can inject code into your .zshenv
, wait patiently for a script with a zsh shebang to be run with root privileges, and then execute some malicious code with root privileges on your system.
This can also occur with preinstall
or postinstall
scripts with a zsh shebang in installation package files (pkg files) when the installation is initiated by the user. A malicious attacker could scan your system for apps where they know the installer package contains zsh scripts, which makes the chance that eventually a zsh script is run with root permissions quite certain. Then all they have to do is be patient and wait…
As root escalations go, this is not a very efficient one. The attacker is at the mercy of the user to wait when they execute a zsh script with escalated root privileges. On some Macs, that may very well be never.
This also only works when the user can gain administrative privileges. Standard users cannot use sudo
to gain root privileges, or run installation packages.
There are more effective means of escalating from user to root privileges on macOS. Most easily by asking the user directly for the password with a dialog that appears benign. However, if you are a frequent user or author of zsh scripts, it is important to be aware.
From what I can tell so far, this does not affect scripts launched from non-user contexts, such as scripts from Munki, Jamf Pro or other management solutions, though there may be odd, unexpected edge cases.
As an organization, you can monitor changes to all shell configuration files with a security tool (such as Jamf Protect). There are more shenanigans an attacker could achieve by modifying these files. There are, however, many legit reasons to change these files, so most changes will not indicate an attack or intrusion. Nevertheless, the information could be an important puzzle piece in combination with other behaviors, when putting together the progress or pattern of an intrusion.
Update 2024-03-22: Matteo Bolognini has added a custom analytic for Jamf Protect to detect changes to the zshenv file.
Configuration file protection
This weakness is fairly straightforward to protect against. First, since the attack requires modification of ~/.zshenv
, you can protect that file.
If you did not have a ~/.zshenv
, create an empty file in its place or remove the line of code from our sample .zshenv
before. Then apply the following flag:
% sudo chflags schg ~/.zshenv
This command sets the system immutable flag (schg
, ‘system change’), which makes it require root privileges to modify, rename or delete the file.
If you want to later modify this file, you can unlock it with
% sudo chflags noschg ~/.zshenv
Just remember to protect it again when you are done.
Script protection
Since most Mac users will never open terminal and never touch any shell configuration file, relying on protecting the configuration file is not sufficient. However, it is quite easy to protect the zsh scripts you create from this kind of abuse.
One solution is to use a $!/bin/sh
shebang instead of #!/bin/zsh
. Posix sh does not have a configuration file which gets loaded on every launch, so this problem does not exist for sh
.
If you are using zsh features that are not part of the POSIX sh standard you will need to change the script. In my opinion, installation scripts that are too complex to use POSIX sh, are attempting to do too much and should be simplified, anyway. So, this can also work as an indicator for “good” installation scripts.
In addition, installer packages might be used in situations where zsh is not available. Installation tools that install package files when the Mac is booted to Recovery (where there is no zsh) are used far less than they used to, but it is still possible and making your installer pkgs resilient to all use cases is generally a good choice.
I believe for installation package scripts, using an sh
shebang is a good recommendation.
Some scripts popular with Mac Admins, like Installomator, do not usually run from installer packages, but are regularly run with root privileges. For these cases, (or for installation scripts, where cannot or do not want to use sh) you can protect from the above escalation, by adding the --no-rcs
option to the shebang:
#!/bin/zsh --no-rcs
echo "Hello, $(whoami)"
zsh’s --no-rcs
option suppresses the launch of user level configuration files. The rc
stands for ‘Run Command’ files and is the same rc
that appears in zshrc
or bashrc
or several other unix configuration files.
That allows you to keep using zsh and zsh features, but still have safe scripts. This is the solution that Support.app and the latest version of Nudge have implemented. I have also created a PR for Installomator, which should be merged in the next release.
Conclusion
Modifying ~/.zshenv
is not a very effective means of gaining root privileges, but it is something developers and Mac admins that create zsh script that may be run with root privileges should be aware of. Switch to an sh shebang or add --no-rcs
to the zsh shebang of scripts that might be run with root privileges to protect.
Using desktoppr in a managed environment
A few years back, I built and released a small tool to simplify the deployment of macOS desktop pictures (also known as “wallpapers” since macOS Ventura and on other platforms) without actually locking them down or needing privacy (PPPC) exemptions. The tool is desktoppr and is quite popular among Mac admins.
I haven’t updated desktoppr in the last three years, and even that last update was just a re-compile to make the binary universal. It was always meant to do one thing and it has done that well, so there was no need for updates.
Recently however, I have been thinking about the workflows to install and set a custom desktop picture or wallpaper in a managed environment.
Note: my thoughts here are for Macs that are owned and managed by organizations and given out to employees to work with. They do not really apply to shared devices in classrooms, carts, labs or kiosk style deployments or even 1:1 devices in an education setting. Other rules (figurative, literal, and legal) apply in these scenarios and you probably want to set and lock a desktop picture/wallpaper with a configuration profile.
Why do it at all?
This is a fair question. My general recommendation for Mac Admins regarding pre-configuring, or even locking down settings in user space is to only do it when you absolutely need to for compliance and security reasons.
I often get the argument that it makes the lives of Mac Admins and tech support easier when you pre-configure settings that “everyone wants anyway.” However, I believe, from my own experience, that “tech geeks” (and I include myself here) are poor judges for which settings should be a general preset and which are just our geeky personal habits and preferences.
This holds especially true when macOS updates change default settings. There have been examples for this throughout macOS history, such as the “natural scrolling” on trackpads, whether to use dark mode, or more recently, the “click to desktop” behavior in macOS Sonoma.
You may be able to generate the statistics from your support ticketing system that tells you which pre-configuration would cut down a significant number of incidents and then, by all means, apply those. (I’d be very interested in those stats and which settings those are.)
But generally, a more hands-off approach is less intrusive, and also less effort in management and maintenance.
So, why configure the wallpaper?
Given my lecture above, this is a fair question. The desktop picture/wallpaper is special. It gives you a chance for a great first impression.
The desktop picture/wallpaper is the first thing a user sees when they log in to a new Mac. Apple understands this, and creates new, beautiful images for each major release of macOS to emphasize its branding. Likewise, when you are managing Macs, this is a very compelling opportunity to present your organization’s branding. This will also demonstrate to end users that this Mac is different. This will identify this Mac as a managed Mac from your organization.
Of course, many users will go ahead and change the desktop picture/wallpaper immediately, or eventually. And so they should. The ability to configure and setup the device the way they like it will make it “theirs,” i.e. more personal, and also might encourage some to learn a bit more about macOS and the device they are working with. Limiting a power user in how they can configure “their” device and how they work with it will be a cause for much frustration, friction and complaints. This is why desktoppr exists: to set the desktop picture/wallpaper once, but then allow the user to change it later.
But first impressions are important. There are very few ways Apple lets Mac admins customize the first use experience. The desktop picture/wallpaper is fairly simple to manage and can leave a big impression.
Ok, but how?
That said, there a few considerations to get this first impression just right. In general, to set a customized wallpaper you need to:
- install the image file(s)
- install desktoppr
- run desktoppr to set the wallpaper
The challenge is to get the timing right on these steps. In this case the first two steps can (and should) happen right after enrollment. The third step configures user space, so it cannot happen before the user account is created and the user logs in. If you run it too late, the user will see the default macOS wallpaper for a while and then switch to the new one, which is not really ideal to create a good first impression.
In Jamf Pro (the management system I have most experience with) you can put the packages for the image file and desktoppr in the PreStage or enroll them with policies triggered at enrollmentComplete
. Since these pkgs usually are not very large, the image files and the desktoppr binary are ready to go when the user finishes setting up their account and gets to the desktop for the first time.
Jamf Pro provides an option to run policies at login, but that also has few seconds delay before it actually runs, giving the user a “flash” of the default macOS wallpaper.
The good news is, that when you run desktoppr with a LaunchAgent, it runs early enough in the login process, that the user does not get “flashed” by Apple’s default wallpaper.
That extended our workflow to:
- install the image file(s)
- install desktoppr
- install LaunchAgent plist
- desktoppr runs as LaunchAgent at login to set the wallpaper
The LaunchAgent will run on every login, and reset the wallpaper even if the user changed a different one. This might be desirable for some deployments, but in general this goes against the notion to leave the user in control. There are workarounds to this. You can build a script that sets the wallpaper and creates a flag file somewhere in the user’s home directory. On subsequent runs, the script would check for that flag file and skip re-setting the wallpaper.
Also, since macOS Ventura, the system warns users about background items in the system. We can manage these warnings with a configuration profile. Since the desktoppr binary is signed, this works quite well. But if you insert a custom shell script as the LaunchAgent to perform all this extra logic, you need to sign this script and managing the background item profile gets a lot more messy.
This works, but now our workflow is:
- install the image file(s)
- install desktoppr
- install LaunchAgent plist
- install signed custom script
- configure and deploy managed Background Item profile
- script runs at login and sets the wallpaper, when necessary
If any of the pieces in step 1–5 change, you need to update at least one installation pkg, upload them to your management system and deploy them down to the clients. Neither of these are very complicated, but the number of moving pieces will make this very tedious quickly.
These workflow steps will not vary much from one deployment to another. So I thought it would be nice if I could integrate some of these steps in desktoppr and find some means to make the pieces less volatile.
Manage the arguments
The first moving piece I wanted to fix was the LaunchAgent plist file. This needs to contain the path to the image file as the first argument to the desktoppr binary, which will be different for every deployment. This value might even change over time for a single given deployment as the org wants to updated and “fresh” desktop pictures/wallpapers with new branding.
For a managed environment, we can also provide this information with a configuration profile. This separates the ‘data’ (the path to the image file) from the logic (the other information in the launchd plist file, that controls when it runs).
I added a new verb manage
to desktoppr that tells it to get configuration from a preference domain or configuration profile instead of the arguments. Now, we can put desktoppr
with the single argument manage
in the LaunchAgent plist. This means the LaunchAgent will not have to change when you update image paths or other settings. Instead, the admin updates the configuration profile in the MDM server. The LaunchAgent plist file is still required, but it won’t need frequent updates any more. (Step 3 is now less volatile)
Setting once
The next step is some new logic in desktoppr to only set the wallpaper once. For that, desktoppr now “remembers” when it last set the wallpaper and to which file. When it is called again to set it to the same file, it does nothing, even if the user changed the desktop picture/wallpaper. Some deployments might still want to reset the wallpaper every time the LaunchAgent triggers, so I added a key to the configuration profile to enable or disable this behavior. This flag only takes effect when desktoppr manage
is run, other invocations of desktoppr will set the desktop picture/wallpaper regardless.
This removes the requirement for a custom script that determines whether desktoppr should set the wallpaper or not. (Step 4) Since we are now running desktoppr manage
directly from the LaunchAgent, and the desktoppr
binary is signed, it is easy to create the PPPC profile to designate this as a managed background item. (Step 5 is less volatile)
There is a sample configuration profile in the repo which has the settings payload and the payload for the background item pre-approval.
Fetch image files
The last volatile bit is the image file itself. So far, you have to create an installation pkg for the image file and install that before desktoppr runs.
To avoid this, I taught desktoppr to download an image file from a URL and use that. So now, you can upload a file to a web server (or AWS, or some file sync service that can provide a static URL) and use that as your desktop.
Since images can be a vector for malware, we add the option to verify the downloaded file with an sha256 hash given in the configuration profile.
Since the download can take a few seconds, this re-introduces the “flash” of the default wallpaper. I don’t really see a way to avoid this and if this really upsets you, you will have to fall back to pre-installing an image file. Nevertheless, I found the option to have desktoppr download an image file to be useful and left it in there.
The new workflow
- create and deploy a desktoppr configuration profile
- install desktoppr with a LaunchAgent plist (these shouldn’t change very often)
- desktoppr runs, downloads the image file and sets the wallpaper, when necessary
All the custom configuration is now in the configuration profile. You will only need to update the desktoppr installation pkg when the binary is an updated.
New use cases
This shortened workflow enables some new workflows. First of all, since there is logic in desktoppr to only change when necessary, we don’t really have to wait until the next login any more. (Most users only “log in” after a reboot for a software update.) We can change the LaunchAgent plist to run in the background every few hours or so. That way, new settings in the configuration profile should be picked up by all the clients within a few hours after coming online and receiving the new profile from the MDM.
Introducing desktoppr 0.5beta
I have been playing around with this and testing for a bit now. It has been working well for me, but I know there will some edge cases and workflows out there that I am not anticipating. For this reason, I am releasing the new manage
feature as a beta, so you can start testing it and reporting any improvements, issues, or challenges.
I am curious to see what you are going to do with it.
Installomator v10.5
We have released an update for installomator which brings it to v10.5. You can can see the details in its release notes page.
With an astounding 78 new labels and updates to 42 existing labels, this brings Installomator to 733 labels. The project has also reached 100 contributors who have put in at least one pull request!
Many thanks to everyone who contributed, whether through pull requests, or by filing issues or by joining in the discussion in the #installomator channel on the Mac Admins Slack.