#!/bin/zsh
#
# Utility - OS Upgrade - Deadline.sh
#
#
# Enforces a macOS upgrade. Requires a Jamf policy to cache an "Install macOS" app on the user's Mac.
# Before starting, checks:
# * Current OS version does not match the target upgrade version already.
#
# Before installing, checks:
# * If user is running on a MacBook. If so: if they are running on battery or not.
# * If the installer app exists.
# * If there is enough free space in GB after caching the installer app.
# * If no user is logged in: start upgrade immediately.
#
# Creates a first-boot script and launch daemon to run the script.
# The script runs after a successful upgrade to clean up the installer app and immediately try to submit a Jamf recon.
# Jamf recon may not succeed in all cases if network is not up at the time the recon tries to run.
#
# Script logs: /Library/Logs/AG/
# OS upgrade log: /var/log/startosinstall.log
# Exit Codes:
# 0: Success
# 1: Generic error
# 2: OS already up to date
# 3: Failed to cache OS installer
# 4: Not enough free space
# 5: Running on MacBook without AC power
########################################
# Global Variables
########################################
osUpgradeName="${4}" # e.g. 'macOS 12 Monterey'
osUpgradeMajorVersion="${5}" # For macOS 11 and newer just use the major digit like '11' ; For older use the first two, like '10.15'
osUpgradeAppPath="${6}" # e.g. '/Applications/Install macOS Monterey.app'
requiredInstallSpace=${7} # Space needed for installation of the upgrade in GB
jamfPolicyCacheInstaller="${8}" # Jamf policy trigger to cache the desired macOS installer
notificationName="AG-Utility-OSUpgrade-Deadline" # Name of notification - for logging purposes
titleTxt="${osUpgradeName} Upgrade" # Notification window title and heading text
iconPath="/Library/Application Support/AGCustom/Generic_Vertical_Wordmark.ico" # Icon shown in notification window
loggedInUser=$( /bin/echo "show State:/Users/ConsoleUser" | /usr/sbin/scutil | /usr/bin/awk '/Name

&& ! /loginwindow/ { print $3 }' ) # Get the current user - shell agnostic (sh, bash, zsh, dash)
myJamfHelper="/Library/Application Support/JAMF/bin/jamfHelper.app/Contents/MacOS/jamfHelper" # Short variable for using jamfHelper
caffeinatePID="" # Set up for caffeinate later
freeSpace=$(/bin/df -g / | /usr/bin/awk 'FNR==2{print $4}') # Free space in GB
originalOsVer=$(/usr/bin/sw_vers -productVersion) # OS version for comparing to in post-upgrade script - used to determine if cleanup should be done
finishOSInstallScriptFilePath="/usr/local/jamfps/finishOSInstall.sh" # Post-upgrade script to remove the installers and recon
osinstallersetupdDaemonSettingsFilePath="/Library/LaunchDaemons/com.jamfps.cleanupOSInstall.plist" # Post-upgrade launch daemon to start script above
osinstallLogfile="/var/log/startosinstall.log" # Output from macOS installer
########################################
########################################
# Logging
########################################
logPath="/Library/Logs/AG"
logFile="${logPath}/${notificationName}.log"
if [ ! -d "$logPath" ]; then
/bin/mkdir -p "$logPath"
fi
WriteLog() {
logTxt=$1
/bin/echo "$(/bin/date +%Y-%m-%d_%H:%M:%S) : ${logTxt}" | /usr/bin/tee -a "$logFile"
}
########################################
########################################
# Support Functions
########################################
# Kill a process by PID if it is found
KillProcess() {
processPID="$1"
if /bin/ps -p "$processPID" > /dev/null ; then
/bin/kill "$processPID"
wait "$processPID" 2>/dev/null
fi
}
# Clean up processes and files and exit with the given exit code
CleanExit() {
WriteLog "INFO: Running clean exit with exit code ($1)."
# Kill caffeinate process
if [ -n "$caffeinatePID" ]; then
WriteLog "INFO: Killing caffeinate process ($caffeinatePID)."
KillProcess "$caffeinatePID"
fi
# Remove first time login daemon and script
if [ -f "$finishOSInstallScriptFilePath" ]; then
WriteLog "INFO: Deleting ($finishOSInstallScriptFilePath)."
/bin/rm -f "$finishOSInstallScriptFilePath"
fi
if [ -f "$osinstallersetupdDaemonSettingsFilePath" ]; then
WriteLog "INFO: Deleting ($osinstallersetupdDaemonSettingsFilePath)."
/bin/rm -f "$osinstallersetupdDaemonSettingsFilePath"
fi
exit "$1"
}
# If previous processes remain for some reason, the installation will freeze, so kill those proccesses.
ProcessCleanup() {
killingProcesses=("caffeinate" "startosinstall" "osinstallersetupd")
for processName in "${killingProcesses[@]}"; do
[ -z "$processName" ] && continue
WriteLog "INFO: Killing ($processName) processes."
/usr/bin/killall "$processName" 2>&1 || true
done
}
# Used to show an error message to the user appended with standard information.
# Parameter should be a string of text you want to show the user.
ShowError()
{
if [ "$loggedInUser" != "" ] && [ "$loggedInUser" != "_mbsetupuser" ] && [ "$loggedInUser" != "root" ] && [ "$loggedInUser" != "loginwindow" ]; then
errorTxt=$1
/usr/bin/osascript -e "display dialog \\"$errorTxt
}
#!/bin/zsh
#
# Utility - OS Upgrade - Deadline.sh
#
#
# Enforces a macOS upgrade. Requires a Jamf policy to cache an "Install macOS" app on the user's Mac.
# Before starting, checks:
# * Current OS version does not match the target upgrade version already.
#
# Before installing, checks:
# * If user is running on a MacBook. If so: if they are running on battery or not.
# * If the installer app exists.
# * If there is enough free space in GB after caching the installer app.
# * If no user is logged in: start upgrade immediately.
#
# Creates a first-boot script and launch daemon to run the script.
# The script runs after a successful upgrade to clean up the installer app and immediately try to submit a Jamf recon.
# Jamf recon may not succeed in all cases if network is not up at the time the recon tries to run.
#
# Script logs: /Library/Logs/AG/
# OS upgrade log: /var/log/startosinstall.log
# Exit Codes:
# 0: Success
# 1: Generic error
# 2: OS already up to date
# 3: Failed to cache OS installer
# 4: Not enough free space
# 5: Running on MacBook without AC power
########################################
# Global Variables
########################################
osUpgradeName="${4}" # e.g. 'macOS 12 Monterey'
osUpgradeMajorVersion="${5}" # For macOS 11 and newer just use the major digit like '11' ; For older use the first two, like '10.15'
osUpgradeAppPath="${6}" # e.g. '/Applications/Install macOS Monterey.app'
requiredInstallSpace=${7} # Space needed for installation of the upgrade in GB
jamfPolicyCacheInstaller="${8}" # Jamf policy trigger to cache the desired macOS installer
notificationName="AG-Utility-OSUpgrade-Deadline" # Name of notification - for logging purposes
titleTxt="${osUpgradeName} Upgrade" # Notification window title and heading text
iconPath="/Library/Application Support/AGCustom/XXXXXXX_XXXX_Vertical_Wordmark.ico" # Icon shown in notification window
loggedInUser=$( /bin/echo "show State:/Users/ConsoleUser" | /usr/sbin/scutil | /usr/bin/awk '/Name

&& ! /loginwindow/ { print $3 }' ) # Get the current user - shell agnostic (sh, bash, zsh, dash)
myJamfHelper="/Library/Application Support/JAMF/bin/jamfHelper.app/Contents/MacOS/jamfHelper" # Short variable for using jamfHelper
caffeinatePID="" # Set up for caffeinate later
freeSpace=$(/bin/df -g / | /usr/bin/awk 'FNR==2{print $4}') # Free space in GB
originalOsVer=$(/usr/bin/sw_vers -productVersion) # OS version for comparing to in post-upgrade script - used to determine if cleanup should be done
finishOSInstallScriptFilePath="/usr/local/jamfps/finishOSInstall.sh" # Post-upgrade script to remove the installers and recon
osinstallersetupdDaemonSettingsFilePath="/Library/LaunchDaemons/com.jamfps.cleanupOSInstall.plist" # Post-upgrade launch daemon to start script above
osinstallLogfile="/var/log/startosinstall.log" # Output from macOS installer
########################################
########################################
# Logging
########################################
logPath="/Library/Logs/AG"
logFile="${logPath}/${notificationName}.log"
if [ ! -d "$logPath" ]; then
/bin/mkdir -p "$logPath"
fi
WriteLog() {
logTxt=$1
/bin/echo "$(/bin/date +%Y-%m-%d_%H:%M:%S) : ${logTxt}" | /usr/bin/tee -a "$logFile"
}
########################################
########################################
# Support Functions
########################################
# Kill a process by PID if it is found
KillProcess() {
processPID="$1"
if /bin/ps -p "$processPID" > /dev/null ; then
/bin/kill "$processPID"
wait "$processPID" 2>/dev/null
fi
}
# Clean up processes and files and exit with the given exit code
CleanExit() {
WriteLog "INFO: Running clean exit with exit code ($1)."
# Kill caffeinate process
if [ -n "$caffeinatePID" ]; then
WriteLog "INFO: Killing caffeinate process ($caffeinatePID)."
KillProcess "$caffeinatePID"
fi
# Remove first time login daemon and script
if [ -f "$finishOSInstallScriptFilePath" ]; then
WriteLog "INFO: Deleting ($finishOSInstallScriptFilePath)."
/bin/rm -f "$finishOSInstallScriptFilePath"
fi
if [ -f "$osinstallersetupdDaemonSettingsFilePath" ]; then
WriteLog "INFO: Deleting ($osinstallersetupdDaemonSettingsFilePath)."
/bin/rm -f "$osinstallersetupdDaemonSettingsFilePath"
fi
exit "$1"
}
# If previous processes remain for some reason, the installation will freeze, so kill those proccesses.
ProcessCleanup() {
killingProcesses=("caffeinate" "startosinstall" "osinstallersetupd")
for processName in "${killingProcesses[@]}"; do
[ -z "$processName" ] && continue
WriteLog "INFO: Killing ($processName) processes."
/usr/bin/killall "$processName" 2>&1 || true
done
}
# Used to show an error message to the user appended with standard information.
# Parameter should be a string of text you want to show the user.
ShowError()
{
if [ "$loggedInUser" != "" ] && [ "$loggedInUser" != "_mbsetupuser" ] && [ "$loggedInUser" != "root" ] && [ "$loggedInUser" != "loginwindow" ]; then
errorTxt=$1
/usr/bin/osascript -e "display dialog \\"$errorTxt
The ${osUpgradeName} upgrade script has failed. Please submit a ticket to your XXXX OIT Service Area for assistance at
https://help.xxxx.xxxxxxx.edu
The XXXX OIT ticket submission form will open in your default browser after you exit this dialog.\\" buttons {\\"Exit\\"} default button \\"Exit\\" with title \\"Error: Upgrade Script Failed\\" with icon POSIX file \\"$iconPath\\""
/usr/bin/sudo -u "$loggedInUser" /usr/bin/open
fi
}
### Create First Boot Script ###
# Creates a first boot script under /usr/local/jamfps
# Script cleans up macOS installer app if an upgrade succeeded and also runs a recon
# Deletes itself and the launch daemon that calls it
# Because the parent shell script creates a new shell script using HEREDOC ( this << EOF >that ),
# use a backslash before the dollar sign to avoid evaluating the variable (or command) as part of creating the script.
# Note: we WANT to evaluate some variables from this parent script (e.g. the daemon settings path)
CreateFirstBootScript() {
WriteLog "INFO: Creating first boot script to run a Jamf recon and delete installer app on successful upgrade."
/bin/mkdir -p /usr/local/jamfps
/bin/cat << EOF > "$finishOSInstallScriptFilePath"
#!/bin/zsh
## First Run Script to remove the installer.
## Wait until /var/db/.AppleUpgrade disappears
while [ -e /var/db/.AppleUpgrade ];
do
echo "\\$(date "+%a %h %d %H:%M:%S"): Waiting for /var/db/.AppleUpgrade to disappear." >> /usr/local/jamfps/firstbootupgrade.log
/bin/sleep 60
done
## Wait until the upgrade process completes
INSTALLER_PROGRESS_PROCESS=\\$(/usr/bin/pgrep -l "Installer Progress")
until [ "\\$INSTALLER_PROGRESS_PROCESS" = "" ];
do
echo "\\$(date "+%a %h %d %H:%M:%S"): Waiting for Installer Progress to complete." >> /usr/local/jamfps/firstbootupgrade.log
/bin/sleep 1
INSTALLER_PROGRESS_PROCESS=\\$(/usr/bin/pgrep -l "Installer Progress")
done
## Clean up installer app and recon if we upgraded
osVer=\\$(/usr/bin/sw_vers -productVersion)
if [ "\\$osVer" != "$originalOsVer" ]; then
if [ -e "$osUpgradeAppPath" ]; then
/bin/rm -rf "$osUpgradeAppPath"
fi
/usr/local/jamf/bin/jamf recon
fi
## Remove LaunchDaemon
if [ -e "$osinstallersetupdDaemonSettingsFilePath" ]; then
/bin/rm -f "$osinstallersetupdDaemonSettingsFilePath"
fi
## Remove Script
if [ -e "$finishOSInstallScriptFilePath" ]; then
/bin/rm -f "$finishOSInstallScriptFilePath"
fi
exit 0
EOF
/usr/sbin/chown root:admin "$finishOSInstallScriptFilePath"
/bin/chmod 755 "$finishOSInstallScriptFilePath"
}
### Create Launch Daemon ###
# Create launch daemon to kick off our first boot script above
CreateLaunchDaemon() {
/bin/cat << EOF > "$osinstallersetupdDaemonSettingsFilePath"
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.jamfps.cleanupOSInstall</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-c</string>
<string>$finishOSInstallScriptFilePath</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
EOF
# Set the permission on the file just made.
/usr/sbin/chown root:wheel "$osinstallersetupdDaemonSettingsFilePath"
/bin/chmod 644 "$osinstallersetupdDaemonSettingsFilePath"
}
########################################
########################################
# Pre-check
########################################
# If we're already on our target OS version - stop
# Check if the full detected OS version starts with our desired major OS version number
if echo "$originalOsVer" | /usr/bin/grep -q "^$osUpgradeMajorVersion"; then
WriteLog "WARN: Upgrade target is macOS ${osUpgradeMajorVersion}. Mac is running macOS ${originalOsVer}. Stopping."
CleanExit 2
fi
########################################
########################################
# Main Script
########################################
# Reset ignored software updates
WriteLog "INFO: Resetting ignored software updates."
/usr/sbin/softwareupdate --reset-ignored
### Start upgrade checks ###
WriteLog "INFO: Starting upgrade checks."
# Installer available check
WriteLog "INFO: Checking if (${osUpgradeAppPath}) exists."
if [ ! -e "$osUpgradeAppPath" ]; then
WriteLog "WARN: (${osUpgradeAppPath}) not found."
WriteLog "INFO: Calling Jamf policy to cache ${osUpgradeName} app."
/usr/local/bin/jamf policy --event "$jamfPolicyCacheInstaller"
if [ ! -e "$osUpgradeAppPath" ]; then
WriteLog "ERROR: Jamf policy to cache ${osUpgradeName} app failed. Stopping."
CleanExit 3
fi
else
WriteLog "INFO: Found (${osUpgradeAppPath}). Continuing."
fi
# Free space check
WriteLog "INFO: Checking if ${requiredInstallSpace} GB of free space is available."
if [ $((freeSpace)) -lt $((requiredInstallSpace)) ]; then
WriteLog "WARN: Less than ${requiredInstallSpace} GB free space. Cannot install. Stopping."
CleanExit 4
else
WriteLog "INFO: ${freeSpace} GB available. Continuing."
fi
# AC power check
# Check battery state if we're on a MacBook
WriteLog "INFO: Checking if we're running on a MacBook. If so, checking we have AC power."
if /usr/sbin/system_profiler SPHardwareDataType | /usr/bin/grep -q "Book"; then # Fairly quick system_profiler query due to limiting to SPHardwareDataType
WriteLog "INFO: Running on a MacBook. Checking AC power state."
onACPower=$(/usr/sbin/ioreg -l | /usr/bin/grep ExternalConnected | /usr/bin/cut -d"=" -f2 | /usr/bin/sed -e 's/ //g') # Returns "Yes" or "No"
if [ "$onACPower" = "No" ]; then
WriteLog "WARN: Not on AC power. Stopping."
CleanExit 5
fi
else
WriteLog "INFO: Not running on a MacBook. Skipping AC power state check."
fi
### End upgrade checks ###
# If no console user logged in start silent upgrade process
if [ "$loggedInUser" = "" ] || [ "$loggedInUser" = "_mbsetupuser" ] || [ "$loggedInUser" = "root" ] || [ "$loggedInUser" = "loginwindow" ]; then
WriteLog "INFO: No active console user. Starting upgrade."
if [ -f "$osinstallLogfile" ]; then
WriteLog "INFO: Clearing out old OS install log file."
/bin/rm -f "$osinstallLogfile"
fi
# Cleanup any old upgrade processes
ProcessCleanup
# Caffeinate to prevent screen sleep during upgrade
# Capture the caffeinate process ID for ending later
/usr/bin/caffeinate -dis &!
caffeinatePID=$!
# Create our boot script and corresponding launch daemon to recon and clean up if we successfully upgrade
CreateFirstBootScript
CreateLaunchDaemon
#set -m
"${osUpgradeAppPath}/Contents/Resources/startosinstall" --agreetolicense --nointeraction >> $osinstallLogfile 2>&1 &!
#set +m
WriteLog "INFO: startosinstall started and detached from script. Log will be written to (${osinstallLogfile}) Exiting script."
exit 0
fi
# Pull down our logo if it isn't cached
if [ ! -f "$iconPath" ]; then
WriteLog "WARN: Icon file missing. Calling Jamf policy to download icon file."
/usr/local/bin/jamf policy --event agLogo
fi
# Show initial dialog with delay choices
WriteLog "INFO: Starting macOS upgrade deadline notification for user (${loggedInUser})."
notificationResponse=$("$myJamfHelper" -windowType utility -button1 OK -showDelayOptions "0, 3600, 7200, 10800, 14400" -timeout 14400 -countdown -alignCountdown left -icon "${iconPath}" -iconSize 200 -title "${titleTxt}" -heading "${titleTxt}" -description "Your Mac will now upgrade to ${osUpgradeName}. To prevent any unintended data loss, please save your work and quit any running apps before clicking OK.
Do not manually power off or sleep your Mac until the upgrade is complete. You must remain connected to the internet to complete the upgrade.
You may delay the upgrade for up to 4 hours. After your selected delay expires the upgrade will automatically begin and quit any running apps.")
WriteLog "INFO: jamfHelper return code was (${notificationResponse})"
# Return code with showDelayOption AND timeout should be 1 for both: no delay choice and notification timeout
# Other return codes will be the timeout with "1" appended, e.g. 18001 for 1800 seconds
# No other return codes should happen
# For delays: subtract 30 minutes (1800 seconds) from the sleep to show a final countdown dialog later
if [ "$notificationResponse" = "1" ]; then
WriteLog "INFO: (${loggedInUser}) chose to start upgrade immediately."
#set -m
"$myJamfHelper" -windowType utility -icon "${iconPath}" -iconSize 200 -title "${titleTxt}" -heading "Upgrade Starting" -description "The ${osUpgradeName} upgrade has started. Your Mac will automatically restart to continue. This may take up to 1 hour. Do not manually restart or shut down.
Please wait." &!
jamfHelperPID=$! # Capture the jamfHelper process ID to feed to 'startosinstall' as the process ID to end once reboot is about to begin
if [ -f "$osinstallLogfile" ]; then
WriteLog "INFO: Clearing out old OS install log file."
/bin/rm -f "$osinstallLogfile"
fi
# Cleanup any old upgrade processes
ProcessCleanup
# Caffeinate to prevent screen sleep during upgrade
# Capture the caffeinate process ID for ending later
/usr/bin/caffeinate -dis &!
caffeinatePID=$!
# Create our boot script and corresponding launch daemon to recon and clean up if we successfully upgrade
CreateFirstBootScript
CreateLaunchDaemon
"${osUpgradeAppPath}/Contents/Resources/startosinstall" --agreetolicense --forcequitapps --pidtosignal $jamfHelperPID >> $osinstallLogfile 2>&1 &!
#set +m
/bin/sleep 3
WriteLog "INFO: startosinstall started and detached from script. Log will be written to (${osinstallLogfile}) Exiting script."
exit 0
elif [ "$notificationResponse" = "36001" ]; then
WriteLog "INFO: (${loggedInUser}) chose 1 hour delay. Sleeping for 1800 seconds and showing final countdown at 30 minutes remaining."
/bin/sleep 2700
elif [ "$notificationResponse" = "72001" ]; then
WriteLog "INFO: (${loggedInUser}) chose 2 hour delay. Sleeping for 5400 seconds and showing final countdown at 30 minutes remaining."
/bin/sleep 5400
elif [ "$notificationResponse" = "108001" ]; then
WriteLog "INFO: (${loggedInUser}) chose 3 hour delay. Sleeping for 9000 seconds and showing final countdown at 30 minutes remaining."
/bin/sleep 9000
elif [ "$notificationResponse" = "144001" ]; then
WriteLog "INFO: (${loggedInUser}) chose 4 hour delay. Sleeping for 12600 seconds and showing final countdown at 30 minutes remaining."
/bin/sleep 12600
else
WriteLog "ERROR: Unexpected return code (${notificationResponse}) from jamfHelper. Exiting."
ShowError "A notification window exited in an unexpected way."
CleanExit 1
fi
# If a delay was chosen, show a follow-up dialog with 30 minutes (1800 seconds) remaining
WriteLog "INFO: Delay expired. Showing final notification."
notificationResponse=$("$myJamfHelper" -windowType utility -button1 "Upgrade" -timeout 1800 -countdown -alignCountdown right -icon "${iconPath}" -iconSize 200 -title "${titleTxt}" -heading "${titleTxt}" -description "Your Mac will now upgrade to ${osUpgradeName}. To prevent any unintended data loss, please save your work and quit any running apps before clicking Upgrade.
Do not power off or sleep your Mac until the upgrade is complete. You must remain connected to the internet to complete the upgrade.")
WriteLog "INFO: Result was (${notificationResponse})"
# Return code with only a timeout should be 0 for both timeout and upgrade button click
# No other return codes should happen without an error
# 'set -m' forces subsequent backgrounded commands to detach from the parent script and continue running after script exit
# More reliable than 'nohup' in varied circumstances
if [ "$notificationResponse" = "0" ]; then
WriteLog "INFO: Delay expired or (${loggedInUser}) clicked 'Upgrade'."
#set -m
"$myJamfHelper" -windowType utility -icon "${iconPath}" -iconSize 200 -title "${titleTxt}" -heading "Upgrade Starting" -description "The ${osUpgradeName} upgrade has started. Your Mac will automatically restart to continue. This may take up to 1 hour. Do not manually restart or shut down.
Please wait." &!
jamfHelperPID=$!
if [ -f "$osinstallLogfile" ]; then
WriteLog "INFO: Clearing out old OS install log file."
/bin/rm -f "$osinstallLogfile"
fi
# Cleanup any old upgrade processes
ProcessCleanup
# Caffeinate to prevent screen sleep during upgrade
# Capture the caffeinate process ID for ending later
/usr/bin/caffeinate -dis &!
caffeinatePID=$!
# Create our boot script and corresponding launch daemon to recon and clean up if we successfully upgrade
CreateFirstBootScript
CreateLaunchDaemon
"${osUpgradeAppPath}/Contents/Resources/startosinstall" --agreetolicense --nointeraction --pidtosignal $jamfHelperPID >> $osinstallLogfile 2>&1 &
#set +m
WriteLog "INFO: startosinstall started and detached from script. Log will be written to (${osinstallLogfile}) Exiting script."
else
WriteLog "ERROR: Unexpected return code (${notificationResponse}) from jamfHelper. Exiting."
ShowError "A notification window exited in an unexpected way."
CleanExit 1
fi
/bin/sleep 3
exit 0