├── MAUCacheAdmin ├── PSModule ├── MAUCacheAdmin.Tests.ps1 └── MAUCacheAdmin │ ├── MAUCacheAdmin.psd1 │ ├── MAUCacheAdmin.psm1 │ ├── Private │ ├── ConvertFrom-AppPackageDictionary.ps1 │ ├── ConvertFrom-BytesToString.ps1 │ ├── ConvertFrom-Plist.ps1 │ ├── Get-HttpClientHandler.ps1 │ ├── Get-MAUApp.ps1 │ ├── Get-PlistFromURI.ps1 │ ├── Invoke-HttpClientDownload.ps1 │ └── Utilities.ps1 │ ├── Public │ ├── Get-MAUApps.ps1 │ ├── Get-MAUCacheDownloadJobs.ps1 │ ├── Get-MAUProductionBuilds.ps1 │ ├── Invoke-MAUCacheDownload.ps1 │ ├── Save-MAUCollaterals.ps1 │ └── Set-MAUCacheAdminHttpClientHandler.ps1 │ └── README.md ├── README.md ├── config_profile_examples ├── com.microsoft.autoupdate2-prod.plist └── com.microsoft.autoupdate2-test.plist ├── maucache.service └── psMacUpdatesOFFICE.ps1 /MAUCacheAdmin: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | #set -x 3 | 4 | TOOL_NAME="Microsoft AutoUpdate Cache Admin" 5 | TOOL_VERSION="3.7" 6 | 7 | ## Copyright (c) 2023 Microsoft Corp. All rights reserved. 8 | ## Scripts are not supported under any Microsoft standard support program or service. The scripts are provided AS IS without warranty of any kind. 9 | ## Microsoft disclaims all implied warranties including, without limitation, any implied warranties of merchantability or of fitness for a 10 | ## particular purpose. The entire risk arising out of the use or performance of the scripts and documentation remains with you. In no event shall 11 | ## Microsoft, its authors, or anyone else involved in the creation, production, or delivery of the scripts be liable for any damages whatsoever 12 | ## (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or other pecuniary 13 | ## loss) arising out of the use of or inability to use the sample scripts or documentation, even if Microsoft has been advised of the possibility 14 | ## of such damages. 15 | ## Feedback: pbowden@microsoft.com 16 | 17 | # Configure automatic cleanup of old data. Make sure to account for older versions you may want to continue hosting. 18 | CLEANUP=false 19 | CLEANUP_DAYS=90 20 | 21 | # Variables for HipChat Notifications 22 | : "${HIPCHAT_NOTIFY:=false}" 23 | : "${HIPCHAT_NOTIFY_ADDRESS:=}" 24 | : "${HIPCHAT_AUTH_TOKEN:=}" 25 | : "${HIPCHAT_ROOM_ID:=}" 26 | 27 | # Variables for Slack Notifications 28 | : "${SLACK_NOTIFY:=false}" 29 | : "${SLACK_WEBHOOK_URL:=https://hooks.slack.com/services/}" 30 | : "${SLACK_ICON_URL:=https://macadmins.software/icons/mau4.png}" 31 | 32 | # Variables for Microsoft Teams Notifications 33 | : "${TEAMS_NOTIFY:=false}" 34 | : "${TEAMS_WEBHOOK_URL:=https://outlook.office.com/webhook/}" 35 | 36 | ############################################################################################################ 37 | 38 | # Constants 39 | MAUID_MAU4X="0409MSau04" 40 | MAUID_WORD2019="0409MSWD2019" 41 | MAUID_EXCEL2019="0409XCEL2019" 42 | MAUID_POWERPOINT2019="0409PPT32019" 43 | MAUID_OUTLOOK2019="0409OPIM2019" 44 | MAUID_ONENOTE2019="0409ONMC2019" 45 | MAUID_WORD2016="0409MSWD15" 46 | MAUID_EXCEL2016="0409XCEL15" 47 | MAUID_POWERPOINT2016="0409PPT315" 48 | MAUID_OUTLOOK2016="0409OPIM15" 49 | MAUID_ONENOTE2016="0409ONMC15" 50 | MAUID_SKYPE2016="0409MSFB16" 51 | MAUID_INTUNECP="0409IMCP01" 52 | MAUID_REMOTEDESKTOP10="0409MSRD10" 53 | MAUID_ONEDRIVE="0409ONDR18" 54 | MAUID_DEFENDER="0409WDAV00" 55 | MAUID_EDGE="0409EDGE01" 56 | MAUID_TEAMS="0409TEAMS10" 57 | MAUID_TEAMS2="0409TEAMS21" 58 | MAUID_OFFICELICHELPER="0409OLIC02" 59 | CHANNEL_COLLATERAL_PROD="https://officecdnmac.microsoft.com/pr/C1297A47-86C4-4C1F-97FA-950631F94777/MacAutoupdate/" 60 | CHANNEL_COLLATERAL_INSIDERSLOW="https://officecdnmac.microsoft.com/pr/1ac37578-5a24-40fb-892e-b89d85b6dfaa/MacAutoupdate/" 61 | CHANNEL_COLLATERAL_INSIDERFAST="https://officecdnmac.microsoft.com/pr/4B2D7701-0A4F-49C8-B4CB-0C2D4043F51F/MacAutoupdate/" 62 | SCRATCH_AREA="$TMPDIR""MAUCache" 63 | 64 | # Platform detection 65 | PLATFORM=$(uname -s) 66 | 67 | ShowUsage () { 68 | # Shows tool usage and parameters 69 | echo $TOOL_NAME - $TOOL_VERSION 70 | echo "Purpose: Downloads MAU collateral and packages from the Office CDN to a local web server" 71 | echo "Usage: MAUCacheAdmin --CachePath: [--CheckInterval:] [--CleanUp] [--HTTPOnly] [--NoPackages] [--NoCollateral] [--ShowCollateral] [--CopyCollateralFrom:] [--CopyCollateralTo:] [--InsiderSupport]" 72 | echo "Example: MAUCacheAdmin --CachePath:/Volumes/web/MAU/cache --CheckInterval:60" 73 | echo "Example: MAUCacheAdmin --CachePath:/Volumes/web/MAU/cache --ShowCollateral" 74 | echo "Example: MAUCacheAdmin --CachePath:/Volumes/web/MAU/cache --CopyCollateralFrom:15.27.16101000 --CopyCollateralTo:Production" 75 | echo 76 | exit 0 77 | } 78 | 79 | InitializeScratchArea () { 80 | # Creates and cleans temporary file area 81 | if [ -d "$SCRATCH_AREA" ]; then 82 | rm -i -f "$SCRATCH_AREA""*" 83 | else 84 | mkdir "$SCRATCH_AREA" 85 | fi 86 | } 87 | 88 | InitializeCacheArea () { 89 | # Verifies cache area connectivity and cleans up as necessary 90 | if [ ! -d "$CACHEPATH" ]; then 91 | echo "Error: $CACHEPATH is not accessible" 92 | echo 93 | exit 1 94 | fi 95 | } 96 | 97 | CleanupOldFiles () { 98 | # Cleanup old cache and manifest files 99 | if [ $CLEANUP = true ]; then 100 | echo "Touching legacy packages needed for older macOS versions to avoid re-download..." 101 | touch "$CACHEPATH/Microsoft_AutoUpdate_4.40.21101001_Updater.pkg" 102 | touch "$CACHEPATH/Microsoft_Excel_16.30.19101301_Updater.pkg" 103 | touch "$CACHEPATH/Microsoft_Excel_16.43.20110804_Updater.pkg" 104 | touch "$CACHEPATH/Microsoft_Excel_16.54.21101001_Updater.pkg" 105 | touch "$CACHEPATH/Microsoft_OneNote_16.30.19101301_Updater.pkg" 106 | touch "$CACHEPATH/Microsoft_OneNote_16.43.20110804_Updater.pkg" 107 | touch "$CACHEPATH/Microsoft_OneNote_16.54.21101001_Updater.pkg" 108 | touch "$CACHEPATH/Microsoft_Outlook_16.30.19101301_Updater.pkg" 109 | touch "$CACHEPATH/Microsoft_Outlook_16.43.20110804_Updater.pkg" 110 | touch "$CACHEPATH/Microsoft_Outlook_16.54.21101001_Updater.pkg" 111 | touch "$CACHEPATH/Microsoft_PowerPoint_16.30.19101301_Updater.pkg" 112 | touch "$CACHEPATH/Microsoft_PowerPoint_16.43.20110804_Updater.pkg" 113 | touch "$CACHEPATH/Microsoft_PowerPoint_16.54.21101001_Updater.pkg" 114 | touch "$CACHEPATH/Microsoft_Remote_Desktop_10.1.8_updater.pkg" 115 | touch "$CACHEPATH/Microsoft_Remote_Desktop_10.2.13_updater.pkg" 116 | touch "$CACHEPATH/Microsoft_Remote_Desktop_10.3.12_updater.pkg" 117 | touch "$CACHEPATH/Microsoft_Remote_Desktop_10.5.2_updater.pkg" 118 | touch "$CACHEPATH/Microsoft_Word_16.30.19101301_Updater.pkg" 119 | touch "$CACHEPATH/Microsoft_Word_16.43.20110804_Updater.pkg" 120 | touch "$CACHEPATH/Microsoft_Word_16.54.21101001_Updater.pkg" 121 | echo "Removing old files..." 122 | find "$CACHEPATH" -type f -mtime +"$CLEANUP_DAYS" -print -delete 123 | fi 124 | } 125 | 126 | ResolveDownloadUrl () { 127 | # Selects either HTTPS from the Office CDN origin, or HTTP directly from Akamai 128 | if [ "$HTTPONLY" ]; then 129 | HTTPURL=(${1/https/http}) 130 | FILEURL=(${HTTPURL/officecdnmac.microsoft.com/officecdn-microsoft-com.akamaized.net}) 131 | else 132 | FILEURL="$1" 133 | fi 134 | echo "$FILEURL" 135 | } 136 | 137 | BuildApplicationArray () { 138 | # Builds an array of all the MAU-enabled applications that we care about 139 | MAUAPP[0]="$MAUID_MAU4X" 140 | MAUAPP[1]="$MAUID_WORD2019" 141 | MAUAPP[2]="$MAUID_EXCEL2019" 142 | MAUAPP[3]="$MAUID_POWERPOINT2019" 143 | MAUAPP[4]="$MAUID_OUTLOOK2019" 144 | MAUAPP[5]="$MAUID_ONENOTE2019" 145 | MAUAPP[6]="$MAUID_WORD2016" 146 | MAUAPP[7]="$MAUID_EXCEL2016" 147 | MAUAPP[8]="$MAUID_POWERPOINT2016" 148 | MAUAPP[9]="$MAUID_OUTLOOK2016" 149 | MAUAPP[10]="$MAUID_ONENOTE2016" 150 | MAUAPP[11]="$MAUID_SKYPE2016" 151 | MAUAPP[12]="$MAUID_INTUNECP" 152 | MAUAPP[13]="$MAUID_REMOTEDESKTOP10" 153 | MAUAPP[14]="$MAUID_ONEDRIVE" 154 | MAUAPP[15]="$MAUID_DEFENDER" 155 | MAUAPP[16]="$MAUID_EDGE" 156 | MAUAPP[17]="$MAUID_TEAMS" 157 | MAUAPP[18]="$MAUID_TEAMS2" 158 | MAUAPP[19]="$MAUID_OFFICELICHELPER" 159 | } 160 | 161 | BuildCollateralArray () { 162 | # Builds an array of MAU collateral file-paths which we'll use later for downloading XML and CAT files 163 | DOWNLOADARRAY[0]="$1"$MAUID_MAU4X 164 | DOWNLOADARRAY[1]="$1"$MAUID_WORD2019 165 | DOWNLOADARRAY[2]="$1"$MAUID_EXCEL2019 166 | DOWNLOADARRAY[3]="$1"$MAUID_POWERPOINT2019 167 | DOWNLOADARRAY[4]="$1"$MAUID_OUTLOOK2019 168 | DOWNLOADARRAY[5]="$1"$MAUID_ONENOTE2019 169 | DOWNLOADARRAY[6]="$1"$MAUID_WORD2016 170 | DOWNLOADARRAY[7]="$1"$MAUID_EXCEL2016 171 | DOWNLOADARRAY[8]="$1"$MAUID_POWERPOINT2016 172 | DOWNLOADARRAY[9]="$1"$MAUID_OUTLOOK2016 173 | DOWNLOADARRAY[10]="$1"$MAUID_ONENOTE2016 174 | DOWNLOADARRAY[11]="$1"$MAUID_SKYPE2016 175 | DOWNLOADARRAY[12]="$1"$MAUID_INTUNECP 176 | DOWNLOADARRAY[13]="$1"$MAUID_REMOTEDESKTOP10 177 | DOWNLOADARRAY[14]="$1"$MAUID_ONEDRIVE 178 | DOWNLOADARRAY[15]="$1"$MAUID_DEFENDER 179 | DOWNLOADARRAY[16]="$1"$MAUID_EDGE 180 | DOWNLOADARRAY[17]="$1"$MAUID_TEAMS 181 | DOWNLOADARRAY[18]="$1"$MAUID_TEAMS2 182 | DOWNLOADARRAY[19]="$1"$MAUID_OFFICELICHELPER 183 | } 184 | 185 | DownloadCollateralFiles () { 186 | # Downloads XML/CAT collateral files 187 | for i in "$@" 188 | do 189 | echo Downloading collateral file: "$i" 190 | (cd "$SCRATCH_AREA" && curl --progress-bar --remote-name --location "$i.{xml,cat}" && curl --progress-bar --remote-name --location "$i-chk.xml") 191 | done 192 | } 193 | 194 | DownloadProductionBuildSource () { 195 | # Downloads list of official production builds 196 | local CHANNELURL="$1" 197 | (cd "$SCRATCH_AREA" && curl --progress-bar --remote-name --location "$CHANNELURL""builds.txt") 198 | IFS=$'\n' read -d '' -r -a PRODBUILDS < "$SCRATCH_AREA""/builds.txt" 199 | if [[ "$PRODBUILDS[0]" == "1"* ]]; then 200 | return 201 | else 202 | PRODBUILDS="" 203 | fi 204 | } 205 | 206 | ArchiveCollateralFiles () { 207 | # Archives collateral files in the cache folder 208 | local APPID="$1" 209 | if [ $NOCOLLATERAL ]; then 210 | return 211 | else 212 | if [ ! -d "$CACHEPATH/collateral" ]; then 213 | mkdir "$CACHEPATH/collateral" 214 | fi 215 | local APPVER=$(GetAppVersionFromXML "$APPID") 216 | if [ ! -d "$CACHEPATH/collateral/$APPVER" ]; then 217 | mkdir "$CACHEPATH/collateral/$APPVER" 218 | fi 219 | cp "$SCRATCH_AREA/$APPID-chk.xml" "$CACHEPATH/collateral/$APPVER" 220 | cp "$SCRATCH_AREA/$APPID.xml" "$CACHEPATH/collateral/$APPVER" 221 | cp "$SCRATCH_AREA/$APPID.cat" "$CACHEPATH/collateral/$APPVER" 222 | # Disable versioned manifests 223 | # NOTE: The following command will only work on macOS 224 | /usr/libexec/PlistBuddy -c "Set :VersionedManifests bool false" "$CACHEPATH/collateral/$APPVER/$APPID-chk.xml" >/dev/null 2>&1; 225 | fi 226 | } 227 | 228 | DownloadUpdatePackages () { 229 | # Logic to evaluate what files need to be downloaded into the cache 230 | APPID="$1" 231 | APPNAME=$(GetAppNameFromMAUID "$1") 232 | if [ "$APPID" == "0409MSOF14" ] || [ "$APPID" == "0409UCCP14" ]; then 233 | LOCATION=($(cd $SCRATCH_AREA && (grep -o 'http[^\"]*dmg' $APPID".xml") | sort | uniq)) 234 | else 235 | LOCATION=($(cd $SCRATCH_AREA && (grep -o 'http[^\"]*pkg' $APPID".xml") | sort | uniq)) 236 | fi 237 | for i in "${LOCATION[@]}" 238 | do 239 | SKIPDOWNLOAD=false 240 | PACKAGENAME=$(basename "$i") 241 | if [[ $PACKAGENAME == *"Delta"* ]]; then 242 | for PRODVER in "${PRODBUILDS[@]}" 243 | do 244 | if [[ $PACKAGENAME == *"$PRODVER""_to_"* ]]; then 245 | SKIPDOWNLOAD=false 246 | break 247 | else 248 | SKIPDOWNLOAD=true 249 | fi 250 | done 251 | fi 252 | if [ "$SKIPDOWNLOAD" == false ]; then 253 | PACKAGEURL=$(ResolveDownloadUrl "$i") 254 | PACKAGESIZECDN=$(GetDownloadSize "$i") 255 | PACKAGESIZELOCAL=$(GetLocalSize "$PACKAGENAME") 256 | PACKAGESIZECDNMEG=$(expr $PACKAGESIZECDN / 1024 / 1024) 257 | if [ -f "$CACHEPATH/$PACKAGENAME" ]; then 258 | if [ "$PACKAGESIZELOCAL" == "$PACKAGESIZECDN" ]; then 259 | echo Package "$i" already exists in the cache ...skipping 260 | else 261 | echo Package "$i" exists in the cache but is corrupt ...removing 262 | (cd "$CACHEPATH" && rm -f "$i") 263 | DownloadPackage "$PACKAGEURL" "$APPNAME" "$PACKAGESIZECDNMEG" 264 | fi 265 | else 266 | DownloadPackage "$PACKAGEURL" "$APPNAME" "$PACKAGESIZECDNMEG" 267 | fi 268 | fi 269 | done 270 | } 271 | 272 | GetAppVersionFromXML () { 273 | # Returns the current app version from the XML collateral 274 | XML="$1"".xml" 275 | local APPVER=($(cd $SCRATCH_AREA && grep -A1 -m2 'Update Version' "$XML" | grep 'string' | sed -e 's,.*\([^<]*\).*,\1,g')) 276 | if [ "$APPVER" == '' ]; then 277 | echo "Legacy" 278 | else 279 | echo "$APPVER" 280 | fi 281 | } 282 | 283 | GetAppNameFromMAUID () { 284 | # Performs a reverse look-up from MAUID to friendly name 285 | case "$1" in 286 | $MAUID_MAU4X) APPNAME="MAU 4.x" 287 | ;; 288 | $MAUID_WORD2019) APPNAME="Word 365/2021/2019" 289 | ;; 290 | $MAUID_EXCEL2019) APPNAME="Excel 365/2021/2019" 291 | ;; 292 | $MAUID_POWERPOINT2019) APPNAME="PowerPoint 365/2021/2019" 293 | ;; 294 | $MAUID_OUTLOOK2019) APPNAME="Outlook 365/2021/2019" 295 | ;; 296 | $MAUID_ONENOTE2019) APPNAME="OneNote 365/2021/2019" 297 | ;; 298 | $MAUID_WORD2016) APPNAME="Word 2016" 299 | ;; 300 | $MAUID_EXCEL2016) APPNAME="Excel 2016" 301 | ;; 302 | $MAUID_POWERPOINT2016) APPNAME="PowerPoint 2016" 303 | ;; 304 | $MAUID_OUTLOOK2016) APPNAME="Outlook 2016" 305 | ;; 306 | $MAUID_ONENOTE2016) APPNAME="OneNote 2016" 307 | ;; 308 | $MAUID_SKYPE2016) APPNAME="Skype for Business" 309 | ;; 310 | $MAUID_INTUNECP) APPNAME="Intune Company Portal" 311 | ;; 312 | $MAUID_REMOTEDESKTOP10) APPNAME="Remote Desktop v10" 313 | ;; 314 | $MAUID_ONEDRIVE) APPNAME="OneDrive" 315 | ;; 316 | $MAUID_DEFENDER) APPNAME="Defender ATP" 317 | ;; 318 | $MAUID_EDGE) APPNAME="Edge" 319 | ;; 320 | $MAUID_TEAMS) APPNAME="Teams 1.0 classic" 321 | ;; 322 | $MAUID_TEAMS2) APPNAME="Teams 2.1" 323 | ;; 324 | $MAUID_OFFICELICHELPER) APPNAME="Office Licensing Helper" 325 | ;; 326 | esac 327 | echo "$APPNAME" 328 | } 329 | 330 | GetDownloadSize () { 331 | # Gets the size of a file based on its header, then strips non-numeric characters 332 | URL="$1" 333 | local CONTENTHTTPLENGTH=$(curl --head -s $URL | grep -i 'Content-Length:' | cut -d ' ' -f2) 334 | CONTENTLENGTH=$(echo ${CONTENTHTTPLENGTH//[!0-9]/}) 335 | echo $CONTENTLENGTH 336 | } 337 | 338 | GetLocalSize () { 339 | # Gets the size of a file from the local disk 340 | local FILENAME="$1" 341 | # The stat command works differently between macOS and other Linux platforms like RHEL 342 | if [ "$PLATFORM" == "Darwin" ]; then 343 | local FILELENGTH=($(cd "$CACHEPATH" && stat -qf%z "$FILENAME")) 344 | else 345 | local FILELENGTH=($(cd "$CACHEPATH" && stat -c%s "$FILENAME")) 346 | fi 347 | echo $FILELENGTH 348 | } 349 | 350 | HipChatNotify () { 351 | # Send Alert to HipChat Room Specified in Global Variable when Package is Downloaded 352 | if [ $HIPCHAT_NOTIFY == true ]; then 353 | echo "New Package Detected: $PACKAGE" 354 | curl -v -k -d "room_id=${HIPCHAT_ROOM_ID}&from=ServerAlert&message=""MAU Server Alert - New Package Downloaded: ${PACKAGE}""&color=purple" ${HIPCHAT_NOTIFY_ADDRESS}/message?auth_token=${HIPCHAT_AUTH_TOKEN}&format=json 355 | fi 356 | } 357 | 358 | SlackNotify () { 359 | # Send Alert to Slack Webhook in Global Variable when Package is Downloaded 360 | if [ $SLACK_NOTIFY == true ]; then 361 | echo "New Package Detected: $PACKAGE" 362 | curl -X POST -H 'Content-type: application/json' --data '{"username":"MAUCacheAdmin","icon_url":"'"$SLACK_ICON_URL"'","text":"New Package Downloaded: '"$PACKAGE"'"}' $SLACK_WEBHOOK_URL 363 | fi 364 | } 365 | 366 | TeamsNotify () { 367 | # Send Alert to Teams Webhook in Global Variable when Package is Downloaded 368 | if [ $TEAMS_NOTIFY == true ]; then 369 | echo "New Package Detected: $PACKAGE" 370 | curl -H 'Content-type: application/json' -d '{"text":"New Package Downloaded: '"$PACKAGE"'"}' $TEAMS_WEBHOOK_URL 371 | fi 372 | } 373 | 374 | DownloadPackage () { 375 | # Downloads the specified update package 376 | URL="$1" 377 | APPLICATION="$2" 378 | SIZE="$3" 379 | PACKAGE=$(basename "$1") 380 | HipChatNotify 381 | SlackNotify 382 | TeamsNotify 383 | echo "===================================================" 384 | echo Application: "$APPLICATION" 385 | echo Package: "$PACKAGE" 386 | echo Size: "$SIZE" MB 387 | echo URL: "$URL" 388 | (cd "$CACHEPATH" && curl --progress-bar --remote-name --location $URL) 389 | } 390 | 391 | ShowCollateral () { 392 | # Shows collateral information of the downloaded collateral files 393 | echo "----------------------------------------------------------------------------------------" 394 | printf '%-14s %-42s %-11s %s\n' \ 395 | "App ID" "Application" "Build Date" "[Folder]" 396 | echo "----------------------------------------------------------------------------------------" 397 | 398 | for d in "$CACHEPATH/collateral/"*; 399 | do 400 | cd "$d/"; 401 | if ls *.xml >/dev/null 2>&1; then 402 | for XML in *.xml; 403 | do 404 | Folder=$(echo "$d" | awk -F "/" '{print $NF}') 405 | if [ "$Folder" = "Legacy" ]; then 406 | AppIdentifier=$(echo "$XML" | sed -e 's/.xml//g') 407 | ver="" 408 | BuildDate=$(grep -A1 'Date' "$XML" | grep 'date' | sed -e 's,.*\([^<]*\).*,\1,g' | tail -1 | cut -d"T" -f1) 409 | AppTitle=$(grep -A1 'Title' "$XML" | grep 'string' | sed -e 's,.*\([^<]*\).*,\1,g' | tail -1) 410 | 411 | printf '%-14s %-42s %-11s %s\n' \ 412 | "$AppIdentifier" "$AppTitle" "$BuildDate" "[$Folder]" 413 | else 414 | AppIdentifier=$(echo "$XML" | sed -e 's/.xml//g') 415 | ver=$(grep -A1 -m2 'Update Version' "$XML" | grep 'string' | sed -e 's,.*\([^<]*\).*,\1,g') 416 | BuildDate=$(grep -A1 'Date' "$XML" | grep 'date' | sed -e 's,.*\([^<]*\).*,\1,g' | tail -1 | cut -d"T" -f1) 417 | AppTitle=$(grep -A1 'Title' "$XML" | grep 'string' | sed -e 's,.*\([^<]*\).*,\1,g' | tail -1) 418 | 419 | printf '%-14s %-42s %-11s %s\n' \ 420 | "$AppIdentifier" "$AppTitle" "$BuildDate" "[$Folder]" 421 | fi 422 | done 423 | else 424 | Folder=$(echo "$d" | awk -F "/" '{print $NF}') 425 | 426 | printf '%-14s %-42s %-11s %s\n' \ 427 | "" "" "" "[$Folder]" 428 | fi 429 | done 430 | echo "----------------------------------------------------------------------------------------" 431 | exit 0 432 | } 433 | 434 | CopyCollateral () { 435 | # Copies collateral files from one folder to another. 436 | COPYCOLLATERALFROM=$(echo $COPYCOLLATERALFROM | sed 's/[][]//g') 437 | COPYCOLLATERALTO=$(echo $COPYCOLLATERALTO | sed 's/[][]//g') 438 | echo "Copying collateral files FROM:[$COPYCOLLATERALFROM] folder TO:[$COPYCOLLATERALTO] folder." 439 | cp "$CACHEPATH/collateral/$COPYCOLLATERALFROM/"* "$CACHEPATH/collateral/$COPYCOLLATERALTO/" 440 | exit 0 441 | } 442 | 443 | 444 | # Evaluate command-line arguments 445 | if [[ $# = 0 ]]; then 446 | ShowUsage 447 | else 448 | for KEY in "$@" 449 | do 450 | case $KEY in 451 | --Help|-h|--help) 452 | ShowUsage 453 | shift # past argument 454 | ;; 455 | --CachePath:*|-c:*|--cachepath:*) 456 | CACHEPATH=${KEY#*:} 457 | shift # past argument 458 | ;; 459 | --CheckInterval:*|-i:*|--checkinterval:*) 460 | CHECKINTERVAL=${KEY#*:} 461 | shift # past argument 462 | ;; 463 | --CleanUp|-cu|--cleanup) 464 | CLEANUP=true 465 | shift # past argument 466 | ;; 467 | --HTTPOnly|-u|--httponly) 468 | HTTPONLY=true 469 | shift # past argument 470 | ;; 471 | --NoPackages|-np|--nopackages) 472 | NOPACKAGES=true 473 | shift # past argument 474 | ;; 475 | --NoCollateral|-n|--nocollateral) 476 | NOCOLLATERAL=true 477 | shift # past argument 478 | ;; 479 | --ShowCollateral|-sc|--showcollateral) 480 | ShowCollateral 481 | shift # past argument 482 | ;; 483 | --CopyCollateralFrom:*|-ccf:*|--copycollateralfrom:*) 484 | COPYCOLLATERALFROM=${KEY#*:} 485 | shift # past argument 486 | ;; 487 | --CopyCollateralTo:*|-cct:*|--copycollateralto:*) 488 | COPYCOLLATERALTO=${KEY#*:} 489 | CopyCollateral 490 | shift # past argument 491 | ;; 492 | --InsiderSupport|-is|--insidersupport) 493 | INSIDER=true 494 | shift # past argument 495 | ;; 496 | *) 497 | ShowUsage 498 | ;; 499 | esac 500 | shift # past argument or value 501 | done 502 | fi 503 | 504 | ## Main 505 | while : 506 | do 507 | # Get a clean area for writing temporary files 508 | InitializeScratchArea 509 | # Verify that the cache area is ready to go 510 | InitializeCacheArea 511 | #Cleanup old cache and manifest files 512 | CleanupOldFiles 513 | # Build channel array 514 | CHANNELS[0]="$CHANNEL_COLLATERAL_PROD" 515 | if [ $INSIDER ]; then 516 | CHANNELS[1]="$CHANNEL_COLLATERAL_INSIDERSLOW" 517 | CHANNELS[2]="$CHANNEL_COLLATERAL_INSIDERFAST" 518 | fi 519 | # Routine for all channels in the array 520 | for c in "${CHANNELS[@]}" 521 | do 522 | # Resolve collateral download URL 523 | COLLATERALURL=$(ResolveDownloadUrl "$c") 524 | # Download list of production builds as source 525 | DownloadProductionBuildSource "$COLLATERALURL" 526 | # Build an array of the collateral files to download 527 | BuildCollateralArray "$COLLATERALURL" 528 | # Download collateral files for each application 529 | DownloadCollateralFiles "${DOWNLOADARRAY[@]}" 530 | # Build an array of all MAU-enabled applications 531 | BuildApplicationArray 532 | # Build an array of each package location and download those packages 533 | for a in "${MAUAPP[@]}" 534 | do 535 | ArchiveCollateralFiles "$a" 536 | if [ ! $NOPACKAGES ]; then 537 | DownloadUpdatePackages "$a" "$c" 538 | fi 539 | done 540 | done 541 | 542 | # If CheckInterval wasn't specified on the command-line, just run once 543 | if [ "$CHECKINTERVAL" == '' ]; then 544 | exit 0 545 | else 546 | # Otherwise, sleep for the specified number of minutes before checking again 547 | echo "Sleeping for $CHECKINTERVAL minutes..." 548 | CHECKINTERVALSECS=$(expr $CHECKINTERVAL \* 60) 549 | # Wait until the next check interval 550 | sleep "$CHECKINTERVALSECS" 551 | fi 552 | done 553 | 554 | exit 0 555 | -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin.Tests.ps1: -------------------------------------------------------------------------------- 1 | Import-Module -Name Pester 2 | Import-Module -Name PSScriptAnalyzer 3 | 4 | Describe 'Module-level tests' { 5 | 6 | it 'the module imports successfully' { 7 | { Import-Module "$PSScriptRoot\MAUCacheAdmin\MAUCacheAdmin.psm1" -ErrorAction Stop } | should -not -throw 8 | } 9 | 10 | it 'the module has an associated manifest' { 11 | Test-Path "$PSScriptRoot\MAUCacheAdmin\MAUCacheAdmin.psd1" | should -Be $true 12 | } 13 | 14 | it 'passes all default PSScriptAnalyzer rules' { 15 | Invoke-ScriptAnalyzer -Path "$PSScriptRoot\MAUCacheAdmin\MAUCacheAdmin.psm1" | should -BeNullOrEmpty 16 | } 17 | } 18 | 19 | Describe "All functions pass PSScriptAnalyzer rules" { 20 | BeforeDiscovery { 21 | $scripts = Get-ChildItem -Path "$PSScriptRoot\MAUCacheAdmin\*.ps1" -Recurse -File 22 | 23 | Write-Debug "Debug $($script.Count)" 24 | 25 | 26 | $testCases = foreach ($script in $scripts) 27 | { 28 | $results = Invoke-ScriptAnalyzer -Path $script.FullName -Verbose:$false 29 | 30 | if ($null -eq $results) { 31 | @{ 32 | Path = $script.FullName 33 | Pass = $true 34 | Rule = "Passed all rules" 35 | Severity = $null 36 | Line = $null 37 | Message = $null 38 | } 39 | continue 40 | } 41 | 42 | foreach ($rule in $results) 43 | { 44 | @{ 45 | Path = $script.FullName 46 | Pass = $false 47 | Rule = $rule.RuleName 48 | Severity = $rule.Severity 49 | Line = $rule.Line 50 | Message = $rule.Message 51 | } 52 | } 53 | } 54 | } 55 | 56 | it "[] " -TestCases $testCases -Skip:(!$testCases) { 57 | param($Severity,$Path,$Line,$Message) 58 | $because = "$Severity $Message - $($Path):$Line" 59 | $Message | Should -BeNullOrEmpty -Because $because 60 | } 61 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/MAUCacheAdmin.psd1: -------------------------------------------------------------------------------- 1 | # 2 | # Module manifest for module 'MAUCacheAdmin' 3 | # 4 | # Generated by: Nick Ireland 5 | # 6 | # Generated on: 28/04/2023 7 | # 8 | 9 | @{ 10 | 11 | # Script module or binary module file associated with this manifest. 12 | RootModule = 'MAUCacheAdmin.psm1' 13 | 14 | # Version number of this module. 15 | ModuleVersion = '1.0.3' 16 | 17 | # ID used to uniquely identify this module 18 | GUID = '41ac64b9-6f2c-46a2-9d55-e6fb6c3d75ad' 19 | 20 | # Author of this module 21 | Author = 'Nick Ireland' 22 | 23 | 24 | # Description of the functionality provided by this module 25 | Description = 'Module for managing Microsoft AutoUpdate cache.' 26 | 27 | # Minimum version of the Windows PowerShell engine required by this module 28 | PowerShellVersion = '5.1' 29 | 30 | # Assemblies that must be loaded prior to importing this module 31 | RequiredAssemblies = @("System.Net.Http") 32 | 33 | } 34 | -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/MAUCacheAdmin.psm1: -------------------------------------------------------------------------------- 1 | # Set Module wide error action preference 2 | $Script:ErrorActionPreference = 'Stop' 3 | 4 | # Get public and private function definition files. 5 | $Public = @(Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1" -ErrorAction SilentlyContinue) 6 | $Private = @(Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue) 7 | 8 | # Dot source the files. 9 | foreach ($import in @($Public + $Private)) { 10 | try { 11 | Write-Verbose "Importing $($import.FullName)" 12 | . $import.FullName 13 | } catch { 14 | Write-Error "Failed to import function $($import.FullName): $_" 15 | } 16 | } 17 | 18 | # Export all of the public functions making them available to the user 19 | foreach ($file in $Public) { 20 | Export-ModuleMember -Function $file.BaseName 21 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Private/ConvertFrom-AppPackageDictionary.ps1: -------------------------------------------------------------------------------- 1 | function ConvertFrom-AppPackageDictionary { 2 | [OutputType('System.Object[]')] 3 | [CmdletBinding()] 4 | param ( 5 | [Parameter(Mandatory=$true)] 6 | [System.Collections.Specialized.OrderedDictionary[]] 7 | $AppPackageDictionaries 8 | ) 9 | 10 | $logPrefix = "$($MyInvocation.MyCommand):" 11 | Write-Verbose "$logPrefix Processing $($AppPackageDictionaries.Count) App Packages" 12 | 13 | # Cast the OrderedDictionary objects to a PSCustomObject 14 | $appPackageObjects = @($AppPackageDictionaries | Foreach-Object {[PSCustomObject]$_}) 15 | 16 | Write-Verbose "$logPrefix Returning $($appPackageObjects.Count) converted objects" 17 | return $appPackageObjects 18 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Private/ConvertFrom-BytesToString.ps1: -------------------------------------------------------------------------------- 1 | function ConvertFrom-BytesToString { 2 | [OutputType('System.String')] 3 | [CmdletBinding()] 4 | param ( 5 | [Parameter(Mandatory=$true)] 6 | [Int64] 7 | $Bytes 8 | ) 9 | 10 | if ($Bytes -lt 1) { 11 | return "0 Bytes" 12 | } 13 | if ($Bytes -lt 1MB) { 14 | return "$([Math]::Round($Bytes / 1KB, 2)) KB" 15 | } 16 | if ($Bytes -lt 1GB) { 17 | return "$([Math]::Round($Bytes / 1MB, 2)) MB" 18 | } 19 | if ($Bytes -lt 1TB) { 20 | return "$([Math]::Round($Bytes / 1GB, 2)) GB" 21 | } 22 | if ($Bytes -lt 1PB) { 23 | return "$([Math]::Round($Bytes / 1TB, 2)) TB" 24 | } 25 | 26 | return "$([Math]::Round($Bytes / 1PB, 2)) PB" 27 | 28 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Private/ConvertFrom-Plist.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Convert a XML Plist to a PowerShell object 4 | .DESCRIPTION 5 | Converts an XML PList (property list) in to a usable object in PowerShell. 6 | 7 | Properties will be converted in to ordered hashtables, the values of each property may be integer, double, date/time, boolean, string, or hashtables, arrays of any these, or arrays of bytes. 8 | .EXAMPLE 9 | $pList = [xml](get-content 'somefile.plist') | ConvertFrom-Plist 10 | .PARAMETER plist 11 | The property list as an [XML] document object, to be processed. This parameter is mandatory and is accepted from the pipeline. 12 | .INPUTS 13 | system.xml.document 14 | .OUTPUTS 15 | system.object 16 | .NOTES 17 | Script / Function / Class assembled by Carl Morris, Morris Softronics, Hooper, NE, USA 18 | Initial release - Aug 27, 2018 19 | Jan 16, 2021 - Corrected return type of tags. 20 | Sep 19, 2021 - Corrected reaction to empty , , , and tags. 21 | .LINK 22 | https://github.com/msftrncs/PwshReadXmlPList 23 | .FUNCTIONALITY 24 | data format conversion 25 | #> 26 | function ConvertFrom-Plist { 27 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseProcessBlockForPipelineCommand", "")] 28 | Param( 29 | # parameter to pass input via pipeline 30 | [Parameter(Mandatory, Position = 0, 31 | ValueFromPipeline, ValueFromPipelineByPropertyName, 32 | HelpMessage = 'XML Plist object.')] 33 | [ValidateNotNullOrEmpty()] 34 | [xml]$plist 35 | ) 36 | 37 | # define a class to provide a method for accelerated processing of the XML tree 38 | class plistreader { 39 | # define a static method for accelerated processing of the XML tree 40 | static [object] processTree ($node) { 41 | return $( 42 | <# iterate through the collection of XML nodes provided, recursing through the children nodes to 43 | extract properties and their values, dictionaries, or arrays of all, but note that property values 44 | follow their key, not contained within them. #> 45 | if ($node.HasChildNodes) { 46 | switch ($node.Name) { 47 | dict { 48 | # for dictionary, return the subtree as a ordered hashtable, with possible recursion of additional arrays or dictionaries 49 | $collection = [ordered]@{} 50 | $currnode = $node.FirstChild # start at the first child node of the dictionary 51 | while ($null -ne $currnode) { 52 | if ($currnode.Name -eq 'key') { 53 | # a key in a dictionary, add it to a collection 54 | if ($null -ne $currnode.NextSibling) { 55 | # note: keys are forced to [string], insures a $null key is accepted 56 | $collection[[string][plistreader]::processTree($currnode.FirstChild)] = [plistreader]::processTree($currnode.NextSibling) 57 | $currnode = $currnode.NextSibling.NextSibling # skip the next sibling because it was the value of the property 58 | } else { 59 | throw "Dictionary property value missing!" 60 | } 61 | } else { 62 | throw "Non 'key' element found in dictionary: <$($currnode.Name)>!" 63 | } 64 | } 65 | # return the collected hash table 66 | $collection 67 | continue 68 | } 69 | array { 70 | # for arrays, recurse each node in the subtree, returning an array (forced) 71 | , @($node.ChildNodes.foreach{ [plistreader]::processTree($_) }) 72 | continue 73 | } 74 | string { 75 | # for string, return the value, with possible recursion and collection 76 | [plistreader]::processTree($node.FirstChild) 77 | continue 78 | } 79 | integer { 80 | # must be an integer type value element, return its value 81 | [plistreader]::processTree($node.FirstChild).foreach{ 82 | # try to determine what size of interger to return this value as 83 | if ([int]::TryParse($_, [ref]$null)) { 84 | # a 32bit integer seems to work 85 | $_ -as [int] 86 | } elseif ([int64]::TryParse($_, [ref]$null)) { 87 | # a 64bit integer seems to be needed 88 | $_ -as [int64] 89 | } else { 90 | # try an unsigned 64bit interger, the largest available here. 91 | $_ -as [uint64] 92 | } 93 | } 94 | continue 95 | } 96 | real { 97 | # must be a floating type value element, return its value 98 | [plistreader]::processTree($node.FirstChild) -as [double] 99 | continue 100 | } 101 | date { 102 | # must be a date-time type value element, return its value 103 | [plistreader]::processTree($node.FirstChild) -as [datetime] 104 | continue 105 | } 106 | data { 107 | # must be a data block value element, return its value as [byte[]] 108 | , [convert]::FromBase64String([plistreader]::processTree($node.FirstChild)) 109 | continue 110 | } 111 | default { 112 | # we didn't recognize the element type! 113 | throw "Unhandled PLIST property type <$($node.Name)>!" 114 | } 115 | } 116 | } else { 117 | # some nodes are empty, such as Boolean, others are empty because they have no content (null) 118 | switch ($node.Name) { 119 | true { $true; continue } # return a Boolean TRUE value 120 | false { $false; continue } # return a Boolean FALSE value 121 | dict { [ordered]@{}; continue } # return an empty dictionary 122 | array { , @(); continue } # return an empty array 123 | string { [string]''; continue } # return an empty string 124 | data { , [byte[]]@(); continue } # return an empty byte array ([byte[]]) 125 | default { $node.Value } # return the element value 126 | } 127 | } 128 | ) 129 | } 130 | } 131 | 132 | # process the 'plist' item of the input XML object 133 | [plistreader]::processTree($plist.item('plist').FirstChild) 134 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Private/Get-HttpClientHandler.ps1: -------------------------------------------------------------------------------- 1 | function Get-HttpClientHandler { 2 | if ($null -eq $Script:HttpClientHandler) { 3 | # Set default HTTP Client Handler if one has not been defined 4 | $Script:HttpClientHandler = [System.Net.Http.HttpClientHandler]::new() 5 | } 6 | 7 | return $Script:HttpClientHandler 8 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Private/Get-MAUApp.ps1: -------------------------------------------------------------------------------- 1 | function Get-MAUApp { 2 | [CmdletBinding()] 3 | param ( 4 | [Parameter(Mandatory=$true)] 5 | [string] 6 | $AppID, 7 | [Parameter(Mandatory=$true)] 8 | [string] 9 | $AppName, 10 | [Parameter(Mandatory=$true)] 11 | [Uri] 12 | $ChannelURI, 13 | [Parameter(Mandatory=$true)] 14 | [System.Net.Http.HttpClient] 15 | $HttpClient 16 | ) 17 | 18 | $logPrefix = "$($MyInvocation.MyCommand):" 19 | Write-Verbose "$logPrefix Processing AppID = $AppID, AppName = $AppName, ChannelURI = $ChannelURI" 20 | 21 | # Define Collateral Object 22 | $app = [PSCustomObject]@{ 23 | AppID = $AppID 24 | AppName = $AppName 25 | VersionInfo = $null 26 | CollateralURIs = [PSCustomObject]@{ 27 | AppXML = [Uri]::new($ChannelURI, "$AppID.xml") 28 | CAT = [Uri]::new($ChannelURI, "$AppID.cat") 29 | ChkXml = [Uri]::new($ChannelURI, "$AppID-chk.xml") 30 | HistoryXML = [Uri]::new($ChannelURI, "$AppID-history.xml") 31 | } 32 | Packages = $null 33 | HistoricPackages = @{} 34 | } 35 | 36 | # Process App XML 37 | Write-Verbose "$logPrefix Getting App Packages" 38 | [System.Collections.Specialized.OrderedDictionary[]]$appPackageDicts = Get-PlistObjectFromURI -URI $app.CollateralURIs.AppXML -HttpClient $HttpClient 39 | if ($null -eq $appPackageDicts) { 40 | Write-Verbose "$logPrefix No object returned from Get-PlistObjectFromURI!" 41 | throw "Failed to process $($app.CollateralURIs.AppXML)" 42 | } 43 | $app.Packages = @(ConvertFrom-AppPackageDictionary -AppPackageDictionaries $appPackageDicts) 44 | 45 | # Process Version Check XML 46 | Write-Verbose "$logPrefix Getting App Version Info" 47 | $versionObj = [PSCustomObject](Get-PlistObjectFromURI -URI $app.CollateralURIs.ChkXml -HttpClient $HttpClient) 48 | $app.VersionInfo = [PSCustomObject]@{ 49 | Version = $versionObj.'Update Version' 50 | Date = Get-Date $versionObj.Date 51 | Type = $versionObj.Type 52 | } 53 | 54 | # Fix unknown versions 55 | if ($app.VersionInfo.Version -eq "99999") { 56 | $verFromPkg = @($app.Packages.'Update Version')[0] 57 | 58 | $app.VersionInfo.Version = if ($null -eq $verFromPkg) {"Legacy"} else {$verFromPkg} 59 | } 60 | 61 | # Process App History XML 62 | Write-Verbose "$logPrefix Getting App History Packages" 63 | [string[]]$historicAppVersions = Get-PlistObjectFromURI -URI $app.CollateralURIs.HistoryXML -HttpClient $HttpClient -Optional 64 | if ($null -eq $historicAppVersions) { 65 | # Leave HistoricPackages empty ( -history.xml files are optional and only on certain apps ) 66 | Write-Verbose "$logPrefix No App history XML found" 67 | return $app 68 | } 69 | Write-Verbose "$logPrefix Found $($historicAppVersions.Count) historic versions ($($historicAppVersions -join ", "))" 70 | $historicAppVersions | ForEach-Object { 71 | [System.Collections.Specialized.OrderedDictionary[]]$historyAppPackageDicts = Get-PlistObjectFromURI -URI ([Uri]::new($ChannelURI, "$($AppID)_$_.xml")) -HttpClient $HttpClient 72 | $historyAppPackageObjs = @(ConvertFrom-AppPackageDictionary -AppPackageDictionaries $historyAppPackageDicts) 73 | $app.HistoricPackages[$_] = $historyAppPackageObjs 74 | } 75 | 76 | return $app 77 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Private/Get-PlistFromURI.ps1: -------------------------------------------------------------------------------- 1 | function Get-PlistObjectFromURI { 2 | [CmdletBinding()] 3 | param ( 4 | [Parameter(Mandatory=$true)] 5 | [Uri] 6 | $URI, 7 | [Parameter(Mandatory=$true)] 8 | [System.Net.Http.HttpClient] 9 | $HttpClient, 10 | [Switch] 11 | $Optional 12 | ) 13 | $logPrefix = "$($MyInvocation.MyCommand):" 14 | Write-Verbose "$logPrefix Processing $URI" 15 | 16 | try { 17 | [xml]$xmlObject = $HttpClient.GetStringAsync($URI).GetAwaiter().GetResult() 18 | } 19 | catch [System.Net.Http.HttpRequestException] { 20 | # Return null if $Optional is set 21 | if ($Optional) { 22 | # Return null if no response was found ( EG -history.xml files are only on certain apps ). 23 | # The MAU CDN does not consistently return 404s, seems to also return 400 Bad Request sometimes when requesting a file that doesn't exist 24 | # so we don't bother checking the response code and just assume if there is a request exception and its Optional, we return null. 25 | Write-Verbose "$logPrefix Request for $URI Returned $($_.Exception.Message)" 26 | return $null 27 | } 28 | # Rethrow the exception if it was not handled 29 | throw 30 | } 31 | 32 | Write-Verbose "$logPrefix Converting XML object to Plist Object" 33 | return $xmlObject | ConvertFrom-Plist 34 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Private/Invoke-HttpClientDownload.ps1: -------------------------------------------------------------------------------- 1 | function Invoke-HttpClientDownload { 2 | [CmdletBinding()] 3 | param ( 4 | [Parameter(Mandatory=$true)] 5 | [Uri] 6 | $Uri, 7 | [Parameter(Mandatory=$true, ParameterSetName="OutFile")] 8 | [String] 9 | $OutFile, 10 | [Parameter(Mandatory=$true, ParameterSetName="Path")] 11 | [String] 12 | $Path, 13 | [switch] 14 | $UseRemoteLastModified, 15 | [switch] 16 | $Force 17 | ) 18 | 19 | $logPrefix = "$($MyInvocation.MyCommand):" 20 | 21 | if ($PSCmdlet.ParameterSetName -eq "Path" -and -not (Test-Path -Path $Path)) { 22 | Throw "The target directory does not exist ($Path)" 23 | } 24 | 25 | try { 26 | $httpClient = [System.Net.Http.HttpClient]::new((Get-HttpClientHandler), $false) 27 | $response = $httpClient.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() 28 | if (!$response.IsSuccessStatusCode) { 29 | Throw "Status code: $($response.StatusCode)" 30 | } 31 | 32 | if ($PSCmdlet.ParameterSetName -eq "Path") { 33 | $targetFileName = if (![string]::IsNullOrEmpty($response.Content.Headers.ContentDisposition.FileName)) { 34 | $response.Content.Headers.ContentDisposition.FileName 35 | } else { 36 | $Uri.Segments[-1] 37 | } 38 | $targetPath = Join-Path -Path $Path -ChildPath $targetFileName 39 | } else { 40 | $targetPath = $OutFile 41 | } 42 | 43 | if (-not $Force -and (Test-Path -Path $targetPath)) { 44 | throw "The target file already exists ($targetPath)" 45 | } 46 | 47 | $targetSize = $response.Content.Headers.ContentLength 48 | $lastModified = $response.Content.Headers.LastModified.DateTime 49 | $lastNotified = [DateTime]::Now.AddMinutes(-1) 50 | 51 | $stream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() 52 | $fileStream = [System.IO.File]::Create($targetPath) 53 | $buffer = New-Object byte[] 256KB 54 | while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { 55 | $fileStream.Write($buffer, 0, $read) 56 | 57 | # Delay the notifications to prevent CPU bottleneck on the download 58 | if (([System.DateTime]::Now - $lastNotified).TotalMilliseconds -gt 250) { 59 | $percent = [int]($fileStream.Length / $targetSize * 100 ) 60 | Write-Progress -Activity "Downloading File" -Status "$($fileStream.Length) of $targetSize Bytes - $percent %" -CurrentOperation "Downloading $Uri to $targetPath" -PercentComplete $(if($percent -lt 1){1}else{$percent}) 61 | $lastNotified = [System.DateTime]::Now 62 | } 63 | } 64 | $fileStream.Close() 65 | Write-Verbose "$logPrefix File downloaded successfully to: $targetPath" 66 | 67 | if ($UseRemoteLastModified) { 68 | $fileInfo = [System.IO.FileInfo]::new($targetPath) 69 | $fileInfo.LastWriteTimeUtc = $lastModified 70 | } 71 | } 72 | catch { 73 | Throw "Failed to download the file ($_)" 74 | } 75 | finally { 76 | Write-Progress -Activity "Downloading File" -Completed 77 | if ($null -ne $httpClient) { $httpClient.Dispose() } 78 | if ($null -ne $response) { $response.Dispose() } 79 | if ($null -ne $fileStream) { $fileStream.Close(); $fileStream.Dispose() } 80 | } 81 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Private/Utilities.ps1: -------------------------------------------------------------------------------- 1 | filter FixLineBreaks { 2 | if ($PSVersionTable.PSVersion -gt [Version]::new("6.0.0")) { 3 | [Regex]::Replace($_, "`r?`n", [Environment]::NewLine) 4 | } else { 5 | [Regex]::Replace($_, "(\\r\\n|\\r|\\n)", [Environment]::NewLine) 6 | } 7 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Public/Get-MAUApps.ps1: -------------------------------------------------------------------------------- 1 | function Get-MAUApps { 2 | [OutputType('System.Object[]')] 3 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")] 4 | [CmdletBinding()] 5 | param ( 6 | [Parameter(Mandatory = $true)] 7 | [ValidateSet("Production", "Preview", "Beta")] 8 | [string] 9 | $Channel 10 | ) 11 | 12 | # Set http client for the function 13 | $httpClient = [System.Net.Http.HttpClient]::new((Get-HttpClientHandler), $false) 14 | 15 | # Create URI builder and set the path based on the provided channel 16 | $mauCdnUriBuilder = [System.UriBuilder]::new("https://officecdnmac.microsoft.com") 17 | switch ($Channel) { 18 | "Production" { $mauCdnUriBuilder.Path = "/pr/C1297A47-86C4-4C1F-97FA-950631F94777/MacAutoupdate/" } 19 | "Preview" { $mauCdnUriBuilder.Path = "/pr/1ac37578-5a24-40fb-892e-b89d85b6dfaa/MacAutoupdate/" } 20 | "Beta" {$mauCdnUriBuilder.Path = "/pr/4B2D7701-0A4F-49C8-B4CB-0C2D4043F51F/MacAutoupdate/" } 21 | } 22 | 23 | # Define the target apps 24 | $targetApps = @( 25 | #[PSCustomObject]@{AppID = "0409MSau03"; AppName = "MAU 3.x"} 26 | [PSCustomObject]@{AppID = "0409MSau04"; AppName = "MAU 4.x"} 27 | [PSCustomObject]@{AppID = "0409MSWD2019"; AppName = "Word 365/2021/2019"} 28 | [PSCustomObject]@{AppID = "0409XCEL2019"; AppName = "Excel 365/2021/2019"} 29 | [PSCustomObject]@{AppID = "0409PPT32019"; AppName = "PowerPoint 365/2021/2019"} 30 | [PSCustomObject]@{AppID = "0409OPIM2019"; AppName = "Outlook 365/2021/2019"} 31 | [PSCustomObject]@{AppID = "0409ONMC2019"; AppName = "OneNote 365/2021/2019"} 32 | [PSCustomObject]@{AppID = "0409MSWD15"; AppName = "Word 2016"} 33 | [PSCustomObject]@{AppID = "0409XCEL15"; AppName = "Excel 2016"} 34 | [PSCustomObject]@{AppID = "0409PPT315"; AppName = "PowerPoint 2016"} 35 | [PSCustomObject]@{AppID = "0409OPIM15"; AppName = "Outlook 2016"} 36 | [PSCustomObject]@{AppID = "0409ONMC15"; AppName = "OneNote 2016"} 37 | [PSCustomObject]@{AppID = "0409MSFB16"; AppName = "Skype for Business"} 38 | [PSCustomObject]@{AppID = "0409IMCP01"; AppName = "Intune Company Portal"} 39 | [PSCustomObject]@{AppID = "0409MSRD10"; AppName = "Remote Desktop v10"} 40 | [PSCustomObject]@{AppID = "0409ONDR18"; AppName = "OneDrive"} 41 | [PSCustomObject]@{AppID = "0409WDAV00"; AppName = "Defender ATP"} 42 | [PSCustomObject]@{AppID = "0409EDGE01"; AppName = "Edge"} 43 | [PSCustomObject]@{AppID = "0409TEAMS10"; AppName = "Teams 1.0 classic"} 44 | [PSCustomObject]@{AppID = "0409TEAMS21"; AppName = "Teams 2.1"} 45 | [PSCustomObject]@{AppID = "0409OLIC02"; AppName = "Office Licensing Helper"} 46 | ) 47 | 48 | $pos = 1 49 | $mauApps = @($targetApps | ForEach-Object { 50 | Write-Progress -Id 0 -Activity "Processing Apps $pos of $($targetApps.Count)" -Status "Channel: $Channel AppID: $($_.AppID) AppName: $($_.AppName)" -PercentComplete ($pos / $targetApps.Count * 100) 51 | Get-MAUApp -AppID $_.AppID -AppName $_.AppName -ChannelURI $mauCdnUriBuilder.Uri -HttpClient $httpClient 52 | $pos++ 53 | }) 54 | Write-Progress -Id 0 -Activity "Processing Apps $pos of $($targetApps.Count)" -Completed 55 | 56 | $httpClient.Dispose() 57 | 58 | return $mauApps 59 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Public/Get-MAUCacheDownloadJobs.ps1: -------------------------------------------------------------------------------- 1 | function Get-MAUCacheDownloadJobs { 2 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")] 3 | [OutputType('System.Object[]')] 4 | [CmdletBinding()] 5 | param ( 6 | [Parameter(Mandatory = $true)] 7 | [PSCustomObject[]] 8 | $MAUApps, 9 | [Parameter()] 10 | [string[]] 11 | $DeltaToBuildLimiter = @(), 12 | [Parameter()] 13 | [string[]] 14 | $DeltaFromBuildLimiter = @(), 15 | [switch] 16 | $IncludeHistoricDeltas, 17 | [switch] 18 | $IncludeHistoricVersions 19 | ) 20 | 21 | $logPrefix = "$($MyInvocation.MyCommand):" 22 | Write-Verbose "$logPrefix Getting download jobs for $($MAUApps.Count) Collaterals" 23 | 24 | # Set http client for the function 25 | $httpClient = [System.Net.Http.HttpClient]::new((Get-HttpClientHandler), $false) 26 | 27 | Write-Progress -Id 0 -Activity "Getting Download Jobs" 28 | $colPos = 0 29 | $downloadJobs = @(foreach ($MAUApp in $MAUApps) { 30 | Write-Verbose "$logPrefix Getting download jobs for $($MAUApp.AppName)" 31 | Write-Progress -Id 0 -Activity "Getting Download Jobs" -Status "$($MAUApp.AppName) - $($colPos + 1) of $($MAUApps.Count)" -PercentComplete $(if ($colPos -eq 0) {0} else {($colPos / $MAUApps.Count * 100)}) 32 | $colPos++ 33 | 34 | $packageUris = @(($MAUApp.Packages.Location + $MAUApp.Packages.BinaryUpdaterLocation + $MAUApp.Packages.FullUpdaterLocation) | Sort-Object -Unique) 35 | 36 | if ($IncludeHistoricVersions -and $MAUApp.HistoricPackages.Count -gt 0) { 37 | $historicURIs = @($MAUApp.HistoricPackages.GetEnumerator() | ForEach-Object { 38 | ($_.Value.Location + $_.Value.BinaryUpdaterLocation + $_.Value.FullUpdaterLocation) 39 | } | Sort-Object -Unique) | Where-Object {$_ -notlike "*_to_*"} 40 | $packageUris = ($packageUris + $historicURIs) | Sort-Object -Unique 41 | } 42 | 43 | if ($IncludeHistoricDeltas -and $MAUApp.HistoricPackages.Count -gt 0) { 44 | $historicURIs = @($MAUApp.HistoricPackages.GetEnumerator() | ForEach-Object { 45 | ($_.Value.Location + $_.Value.BinaryUpdaterLocation + $_.Value.FullUpdaterLocation) 46 | } | Sort-Object -Unique) | Where-Object {$_ -like "*_to_*"} 47 | $packageUris = ($packageUris + $historicURIs) | Sort-Object -Unique 48 | } 49 | 50 | if ($DeltaToBuildLimiter.Count -gt 0 -or $DeltaFromBuildLimiter -gt 0) { 51 | # Filter delta packages by provided builds 52 | $pattern = '.*?([\d.]+)_to_([\d.]+).*' 53 | 54 | $packageUris = $packageUris | Where-Object { 55 | $_ -notmatch $pattern -or ($_ -match $pattern -and $DeltaFromBuildLimiter.Contains($Matches[1]) -or $DeltaToBuildLimiter.Contains($Matches[2])) 56 | } 57 | } 58 | 59 | # Convert URI strings into URI Objects 60 | $uris = @($packageUris | ForEach-Object {[uri]::new($_)}) 61 | 62 | $dlPos = 0 63 | foreach ($uri in $uris) { 64 | Write-Verbose "$logPrefix Processing URI ($uri)" 65 | Write-Progress -Id 1 -ParentId 0 -Activity "Processing URIs - $($dlPos + 1) of $($uris.Count)" -Status "$uri" -PercentComplete $(if ($dlPos -eq 0) {0} else {($dlPos / $uris.Count * 100)}) 66 | $dlPos++ 67 | 68 | # Create and Send the head request 69 | $headRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Head, $uri) 70 | $response = $httpClient.SendAsync($headRequest).GetAwaiter().GetResult() 71 | # Dispose of the head request 72 | $headRequest.Dispose() 73 | 74 | [PSCustomObject]@{ 75 | AppName = $MAUApp.AppName 76 | LocationUri = $uri 77 | Payload = $uri.Segments[-1] 78 | SizeBytes = $response.Content.Headers.ContentLength 79 | LastModified = $response.Content.Headers.LastModified.DateTime 80 | } 81 | } 82 | Write-Progress -Id 1 -ParentId 0 -Activity "Processing URIs" -Completed 83 | 84 | }) 85 | 86 | Write-Progress -Id 0 -Activity "Getting Download Jobs" -Completed 87 | 88 | # Dispose of the http client 89 | $httpClient.Dispose() 90 | 91 | return $downloadJobs 92 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Public/Get-MAUProductionBuilds.ps1: -------------------------------------------------------------------------------- 1 | function Get-MAUProductionBuilds { 2 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")] 3 | [CmdletBinding()] 4 | param ( 5 | ) 6 | 7 | $logPrefix = "$($MyInvocation.MyCommand):" 8 | 9 | # At the time of writting this, only the production CDN has a builds.txt file. 10 | $buildsURI = [Uri]::new("https://officecdnmac.microsoft.com/pr/C1297A47-86C4-4C1F-97FA-950631F94777/MacAutoupdate/builds.txt") 11 | 12 | Write-Verbose "$logPrefix Getting builds from $buildsURI" 13 | 14 | # Create a http client then get use the GetStringAsync method to get the build.txt content as a string 15 | $httpClient = [System.Net.Http.HttpClient]::new((Get-HttpClientHandler), $false) 16 | $builds = ($httpClient.GetStringAsync($buildsURI).GetAwaiter().GetResult() | FixLineBreaks).Split([System.Environment]::NewLine) # Unify line breaks for consistent splitting into a string array. 17 | $httpClient.Dispose() 18 | 19 | return $builds 20 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Public/Invoke-MAUCacheDownload.ps1: -------------------------------------------------------------------------------- 1 | function Invoke-MAUCacheDownload { 2 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] 3 | [CmdletBinding()] 4 | param ( 5 | [Parameter(Mandatory=$true)] 6 | [PSCustomObject[]] 7 | $MAUCacheDownloadJobs, 8 | [Parameter(Mandatory=$true)] 9 | [string] 10 | $CachePath, 11 | [Parameter(Mandatory=$true)] 12 | [string] 13 | $ScratchPath, 14 | [switch] 15 | $Force, 16 | [switch] 17 | $Mirror, 18 | [switch] 19 | $CompareLastModified 20 | ) 21 | 22 | $logPrefix = "$($MyInvocation.MyCommand):" 23 | 24 | # Validate provided paths exist 25 | if (-not (Test-Path -Path $CachePath)) { 26 | throw "The target Cache Path does not exist ($CachePath)" 27 | } 28 | if (-not (Test-Path -Path $ScratchPath)) { 29 | throw "The target Scratch Path does not exist ($ScratchPath)" 30 | } 31 | 32 | # Make sure scratch path is clear 33 | $scratchItems = Get-ChildItem -Path $ScratchPath -Recurse 34 | if (-not $Force -and $scratchItems.Count -gt 0) { 35 | throw "$($scratchItems.Count) items found in scratch directory, run with -Force to automatically clear the scratch path" 36 | } 37 | $scratchItems | Remove-Item -Force -Recurse 38 | 39 | # Validate we have at least 1 valid download job 40 | if ($MAUCacheDownloadJobs.Count -lt 1) { 41 | throw "No MAUCacheDownloadJobs provided" 42 | } 43 | if ($null -eq $MAUCacheDownloadJobs[0].LocationUri) { 44 | throw "Unable to validate Download Job object" 45 | } 46 | 47 | $cachedItems = @() 48 | 49 | $count = 0 50 | foreach ($dlJob in $MAUCacheDownloadJobs) { 51 | $count++ 52 | $statusString = "$count of $($MAUCacheDownloadJobs.Count)" 53 | Write-Host "$('=' * (25 - $statusString.Length / 2))$statusString$('=' * (25 - $statusString.Length / 2))" 54 | Write-Host "Application: $($dlJob.AppName)" 55 | Write-Host "Package: $($dlJob.Payload)" 56 | Write-Host "Size: $(ConvertFrom-BytesToString -Bytes $dlJob.SizeBytes)" 57 | Write-Host "URL: $($dlJob.LocationUri)" 58 | 59 | $targetCacheItem = [System.IO.FileInfo]::new($(Join-Path -Path $CachePath -ChildPath $dlJob.Payload)) 60 | $targetScratchItem = [System.IO.FileInfo]::new($(Join-Path -Path $ScratchPath -ChildPath $dlJob.Payload)) 61 | 62 | $cachedItems += $targetCacheItem.FullName 63 | 64 | $cacheIsValid = $true 65 | 66 | if (-not $targetCacheItem.Exists) { 67 | Write-Verbose "$logPrefix $($dlJob.Payload) not found in the cache" 68 | $cacheIsValid = $false 69 | } 70 | 71 | if ($cacheIsValid -and $targetCacheItem.Length -ne $dlJob.SizeBytes) { 72 | Write-Warning "Package $($dlJob.Payload) exists in the cache but the file size does not match... Will redownload" 73 | $cacheIsValid = $false 74 | } 75 | 76 | if ($cacheIsValid -and $CompareLastModified -and $targetCacheItem.LastWriteTimeUtc -ne $dlJob.LastModified) { 77 | Write-Warning "Package $($dlJob.Payload) exists in the cache but the Last Modified does not match... Will redownload" 78 | $cacheIsValid = $false 79 | } 80 | 81 | if (-not $cacheIsValid) { 82 | Write-Host "Downloading $($dlJob.Payload) to $($targetScratchItem.FullName)" -ForegroundColor Cyan 83 | $dlAttempt = 0 84 | while ($dlAttempt -lt 2) { 85 | try { 86 | Invoke-HttpClientDownload -Uri $dlJob.LocationUri -OutFile $targetScratchItem.FullName -UseRemoteLastModified -Force 87 | break 88 | } 89 | catch { 90 | # Sometimes the download will timeout, suspect some kind of throtteling on the CDN 91 | $message = $_.Exception.Message 92 | if ($message -notmatch "The response ended prematurely") { 93 | throw 94 | break 95 | } 96 | Write-Verbose "$logPrefix retrying download ($dlAttempt times)" 97 | $dlAttempt++ 98 | } 99 | } 100 | $targetScratchItem.Refresh() 101 | Move-Item -Path $targetScratchItem.FullName -Destination $targetCacheItem.FullName -Force 102 | } else { 103 | Write-Host "$($dlJob.Payload) is in the cache and is healthy" -ForegroundColor Green 104 | } 105 | } 106 | 107 | if ($Mirror) { 108 | # Get excess files in cache dir and delete them 109 | $excessCacheFiles = @(Get-ChildItem -Path $CachePath -File | Where-Object {-not $cachedItems.Contains($_.FullName)}) 110 | Write-Verbose "$logPrefix Removing $($excessCacheFiles.Count) excess files from the cache" 111 | $excessCacheFiles | Remove-Item -Force 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Public/Save-MAUCollaterals.ps1: -------------------------------------------------------------------------------- 1 | function Save-MAUCollaterals { 2 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")] 3 | [CmdletBinding()] 4 | param ( 5 | [Parameter(Mandatory = $true)] 6 | [PSCustomObject[]] 7 | $MAUApps, 8 | [Parameter(Mandatory=$true)] 9 | [string] 10 | $CachePath 11 | ) 12 | 13 | $logPrefix = "$($MyInvocation.MyCommand):" 14 | 15 | # Validate provided paths exist 16 | if (-not (Test-Path -Path $CachePath)) { 17 | throw "The target Cache Path does not exist ($CachePath)" 18 | } 19 | 20 | $collateralPath = Join-Path -Path $CachePath -ChildPath "collateral" 21 | $null = New-Item -Path $collateralPath -ItemType Directory -Force 22 | 23 | foreach ($mauApp in $MAUApps) { 24 | $ver = $mauApp.VersionInfo.Version 25 | $verDir = Join-Path -Path $collateralPath -ChildPath $ver 26 | $null = New-Item -Path $verDir -ItemType Directory -Force 27 | 28 | Write-Verbose "$logPrefix Saving $($mauApp.AppID) collaterals to $verDir" 29 | 30 | $collateralURIs = @($mauApp.CollateralURIs.AppXML, $mauApp.CollateralURIs.CAT, $mauApp.CollateralURIs.ChkXml) | Where-Object {$null -ne $_} 31 | $collateralURIs | Foreach-Object {Invoke-HttpClientDownload -Uri $_ -Path $verDir -UseRemoteLastModified -Force} 32 | } 33 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/Public/Set-MAUCacheAdminHttpClientHandler.ps1: -------------------------------------------------------------------------------- 1 | function Set-MAUCacheAdminHttpClientHandler { 2 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] 3 | [CmdletBinding()] 4 | param ( 5 | [Parameter(Mandatory=$true)] 6 | [System.Net.Http.HttpClientHandler] 7 | $Handler 8 | ) 9 | 10 | $Script:HttpClientHandler = $Handler 11 | } -------------------------------------------------------------------------------- /PSModule/MAUCacheAdmin/README.md: -------------------------------------------------------------------------------- 1 | # MAUCacheAdmin PowerShell Module 2 | 3 | This module was designed to mimic the MAUCacheAdmin shell script but in PowerShell. 4 | Clearly things got out of hand and it turned into the module you see today. 5 | 6 | The module is compatible with PowerShell for Windows as well as PowerShell Core. 7 | Its been tested with PS 5.1 on Windows, PS 7.3.4 on Windows and macOS. 8 | 9 | [`HttpClient`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient) has been used in place of `Invoke-WebRequest` due to memory and performance issues when dealing with lots of small requests as well as large files ( this module does both ). 10 | 11 | ## Examples 12 | ### Mimics the same behaviour of `MAUCacheAdmin --CachePath:/Volumes/web/MAU/cache` 13 | ```PowerShell 14 | # Get the current builds 15 | $builds = Get-MAUProductionBuilds 16 | 17 | # Get the production apps 18 | $apps = Get-MAUApps -Channel Production 19 | 20 | # Get the download jobs for the apps limited by the builds 21 | $dlJobs = Get-MAUCacheDownloadJobs -MAUApps $apps -DeltaFromBuildLimiter $builds 22 | 23 | # Download the packages to the cache path 24 | Invoke-MAUCacheDownload -MAUCacheDownloadJobs $dlJobs -CachePath "/Volumes/web/MAU/cache" -ScratchPath "/tmp/MAUCache" -Force 25 | 26 | # Save the collateral files to the cache path 27 | Save-MAUCollaterals -MAUApps $apps -CachePath "/Volumes/web/MAU/cache" 28 | ``` 29 | ### Append a version to the builds that is no longer in the production builds/collaterals. 30 | Imagine You are deploying Microsoft_Office_16.67.22111300_Installer.pkg to all new mac and 16.67.22111300 is no longer in the "base" MAU collaterals. 31 | MAU will be trying to download files such as `Word_16.67.22111300_to_16.69.23011600_Delta.pkg` to bring the current version up to the latest production build. 32 | ```PowerShell 33 | # Get the current builds 34 | $builds = Get-MAUProductionBuilds 35 | 36 | # Get the production apps 37 | $apps = Get-MAUApps -Channel Production 38 | 39 | # Get the download jobs for the apps limited by the builds 40 | $dlJobs = Get-MAUCacheDownloadJobs -MAUApps $apps -DeltaFromBuildLimiter ($builds + "16.67.22111300") -IncludeHistoricDeltas 41 | 42 | # Download the packages to the cache path 43 | Invoke-MAUCacheDownload -MAUCacheDownloadJobs $dlJobs -CachePath "/Volumes/web/MAU/cache" -ScratchPath "/tmp/MAUCache" -Force 44 | ``` 45 | ### CACHE ALL THE THINGS!! 46 | This example will cache everything based on the packages in each apps collateral as well as the history xmls. 47 | Warning, this will download 1340 files totalling ~266GB of content at the time of writing this! 48 | ```PowerShell 49 | # Get the production apps 50 | $apps = Get-MAUApps -Channel Production 51 | 52 | # Get the download jobs for the apps limited by the builds (This may take a while) 53 | $dlJobs = Get-MAUCacheDownloadJobs -MAUApps $apps -IncludeHistoricDeltas -IncludeHistoricVersions 54 | 55 | # Download the packages to the cache path (This may take a while) 56 | Invoke-MAUCacheDownload -MAUCacheDownloadJobs $dlJobs -CachePath "/Volumes/web/MAU/cache" -ScratchPath "/tmp/MAUCache" -Force 57 | ``` 58 | 59 | ## Cmdlets 60 | ### `Get-MAUProductionBuilds` 61 | Returns a string array containing the current production build versions. This array of builds can be used to scope down the delta files from the cache. 62 | #### Arguments 63 | - NA 64 | 65 | 66 | ### `Get-MAUApps` 67 | Returns an array of objects that represents each app with its various Collateral URIs and Packages 68 | #### Arguments 69 | - Channel 70 | - Set the update channel, valid values: `Production`, `Preview`, `Beta` 71 | 72 | 73 | ### `Get-MAUCacheDownloadJobs` 74 | Returns an array of objects that represents the download jobs for all of the provided MAU Apps. 75 | Optionally you can provide an array of builds that will be used to filter the delta packages 76 | #### Arguments 77 | - MAUApps 78 | - Mandatory 79 | - Array of MAUApp objects 80 | - DeltaFromBuildLimiter 81 | - Array of build strings to be used to limit the deltas 82 | - EG `*[build]_to_*` 83 | - DeltaToBuildLimiter 84 | - Array of build strings to be used to limit the deltas 85 | - EG `*_to_[build]*` 86 | - IncludeHistoricVersions 87 | - Switch to optionally include historic packages in the download jobs 88 | - Warning this will generate a lot of download jobs 89 | 90 | 91 | ### `Invoke-MAUCacheDownload` 92 | Downloads the provided download jobs to the provided folder. 93 | Optionally it can "Mirror" the cache directory to automatically cleanup items in the cache that are not defined in the download jobs 94 | #### Arguments 95 | - MAUCacheDownloadJobs 96 | - Mandatory 97 | - Array of MAU Cache Download Job objects 98 | - CachePath 99 | - Mandatory 100 | - Target path for the cache items 101 | - ScratchPath 102 | - Mandatory 103 | - Target scratch path for the cache items 104 | - Force 105 | - Switch to automatically clear the scratch path if items already exist 106 | - Mirror 107 | - Switch to remote and existing items in the CachePath that are not defined in the input Download Jobs 108 | - Similar to `Robocopy /MIR` 109 | - CompareLastModified 110 | - Switch to also compare the last modified as well as length 111 | - This currently has issues due to the MAU CDN returning inconsistent Last Modified dates for certain files 112 | 113 | 114 | ### `Save-MAUCollaterals` 115 | Saves the various Collateral files to the `collateral/{version}` subfolder of the cache path. 116 | - MAUApps 117 | - Mandatory 118 | - Array of MAUApp objects 119 | - CachePath 120 | - Mandatory 121 | - Target path for the cache items 122 | 123 | 124 | ### `Set-MAUCacheAdminHttpClientHandler` 125 | Allows you to inject a custom [`HttpClientHandler`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclienthandler) 126 | Useful if you need to configure a WebProxy for use in your environment. The `HttpClientHandler` provided to this function will be used for all subsuquent web requests by the module. 127 | #### Arguments 128 | - Handler 129 | - Mandatory 130 | - System.Net.Http.HttpClientHandler object -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MAUCacheAdmin 2 | Microsoft AutoUpdate Cache Admin 3 | 4 | Purpose: Downloads MAU collateral and packages from the Office CDN to a local web server
5 | Usage: `MAUCacheAdmin --CachePath: [--CheckInterval:] [--CleanUp] [--HTTPOnly] [--NoPackages] [--NoCollateral] [--ShowCollateral] [--CopyCollateralFrom:] [--CopyCollateralTo:] [--InsiderSupport]`
6 | Example: `MAUCacheAdmin --CachePath:/Volumes/web/MAU/cache --CheckInterval:60`
7 | Example: `MAUCacheAdmin --CachePath:/Volumes/web/MAU/cache --ShowCollateral`
8 | Example: `MAUCacheAdmin --CachePath:/Volumes/web/MAU/cache --CopyCollateralFrom:15.27.16101000 --CopyCollateralTo:Production`
9 | 10 | ## maucache.service 11 | 12 | A simple systemd server to launch MAUCacheAdmin at boot with a 15 minute interval and auto-restart upon a failure. 13 | 14 | This service was written and tested on Ubuntu 16.04 using Nginx. The MAUCacheAdmin script is assumed to be located in `/usr/local/`. Update these values accordingly. 15 | 16 | The `ExecStopPost` line has an optional mail command to notify an email address if the service stops. Remove this line if you do not wish to use this option. 17 | 18 | To use this service write the `maucache.service` file to `/lib/systemd/system/` and run the following commands: 19 | 20 | ``` 21 | sudo systemctl enable maucache.service 22 | sudo systemctl daemon-reload 23 | sudo systemctl start maucache.service 24 | sudo systemctl status maucache.service 25 | ``` 26 | ## psMacUpdatesOFFICE.ps1 27 | 28 | A PowerShell port of the MAUCacheAdmin script from Adam and David from The University of Newcastle in Australia 29 | `PowerShell.exe -executionpolicy bypass -file psMacUpdatesOFFICE.ps1 -channel Production -IISRoot C:\inetpub\wwwroot -IisFolder maucache -TempShare C:\Temp` 30 | -------------------------------------------------------------------------------- /config_profile_examples/com.microsoft.autoupdate2-prod.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AppExitGraceful 6 | 7 | Applications 8 | 9 | /Applications/Microsoft Excel.app 10 | 11 | Application ID 12 | XCEL15 13 | LCID 14 | 1033 15 | 16 | /Applications/Microsoft Outlook.app 17 | 18 | Application ID 19 | OPIM15 20 | LCID 21 | 1033 22 | 23 | /Applications/Microsoft PowerPoint.app 24 | 25 | Application ID 26 | PPT315 27 | LCID 28 | 1033 29 | 30 | /Applications/Microsoft Word.app 31 | 32 | Application ID 33 | MSWD15 34 | LCID 35 | 1033 36 | 37 | /Applications/Skype for Business.app 38 | 39 | Application ID 40 | MSFB16 41 | LCID 42 | 1033 43 | 44 | /Library/Application Support/Microsoft/MAU2.0/Microsoft AutoUpdate.app 45 | 46 | Application ID 47 | MSau03 48 | LCID 49 | 1033 50 | 51 | /Library/Internet Plug-Ins/Silverlight.plugin 52 | 53 | Application ID 54 | SLVT 55 | LCID 56 | 1033 57 | 58 | 59 | ChannelName 60 | Custom 61 | DisableInsiderCheckbox 62 | 63 | HowToCheck 64 | AutomaticDownload 65 | ManifestServer 66 | http://manifest-server-url/MAU/cache/collateral/Production/ 67 | OSLocale 68 | en_US 69 | SendAllTelemetryEnabled 70 | 71 | SendCrashReportsEvenWithTelemetryDisabled 72 | 73 | StartDaemonOnAppLaunch 74 | 75 | UpdateCache 76 | http://cache-server-url/MAU/cache/ 77 | 78 | 79 | -------------------------------------------------------------------------------- /config_profile_examples/com.microsoft.autoupdate2-test.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AppExitGraceful 6 | 7 | Applications 8 | 9 | /Applications/Microsoft Excel.app 10 | 11 | Application ID 12 | XCEL15 13 | LCID 14 | 1033 15 | 16 | /Applications/Microsoft Outlook.app 17 | 18 | Application ID 19 | OPIM15 20 | LCID 21 | 1033 22 | 23 | /Applications/Microsoft PowerPoint.app 24 | 25 | Application ID 26 | PPT315 27 | LCID 28 | 1033 29 | 30 | /Applications/Microsoft Word.app 31 | 32 | Application ID 33 | MSWD15 34 | LCID 35 | 1033 36 | 37 | /Applications/Skype for Business.app 38 | 39 | Application ID 40 | MSFB16 41 | LCID 42 | 1033 43 | 44 | /Library/Application Support/Microsoft/MAU2.0/Microsoft AutoUpdate.app 45 | 46 | Application ID 47 | MSau03 48 | LCID 49 | 1033 50 | 51 | /Library/Internet Plug-Ins/Silverlight.plugin 52 | 53 | Application ID 54 | SLVT 55 | LCID 56 | 1033 57 | 58 | 59 | ChannelName 60 | Custom 61 | DisableInsiderCheckbox 62 | 63 | HowToCheck 64 | AutomaticDownload 65 | ManifestServer 66 | http://manifest-server-url/MAU/cache/collateral/Testing/ 67 | OSLocale 68 | en_US 69 | SendAllTelemetryEnabled 70 | 71 | SendCrashReportsEvenWithTelemetryDisabled 72 | 73 | StartDaemonOnAppLaunch 74 | 75 | UpdateCache 76 | http://cache-server-url/MAU/cache/ 77 | 78 | 79 | -------------------------------------------------------------------------------- /maucache.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Microsoft AutoUpdate Cache Admin Runner 3 | 4 | [Service] 5 | Type=simple 6 | ExecStart=/bin/sh -c "/usr/local/MAUCacheAdmin --CachePath:/var/www/html --CheckInterval:15" 7 | ExecStopPost=/bin/sh -c 'echo "The MAUCacheAdmin service has stopped: Automatic restart in 5 minutes." | /usr/bin/mail -s "MAUCacheAdmin Stopped" -a "From: MAUCacheAdmin " you@your.org' 8 | Restart=always 9 | RestartSec=300 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /psMacUpdatesOFFICE.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Download MAU update for mac 4 | .DESCRIPTION 5 | Authors - Adam Martin, David Coupe 6 | Based on Bash script by pbowden@microsoft.com 7 | Needs to run as administrator to allow file rights 8 | Creates directory structure for IIS 9 | Downloads updates to temporary structure 10 | Deletes original files from IIS 11 | moves new downloads to IIS structure 12 | .PARAMETER Channel 13 | Must supply channel. Values acceptable are "Production", "External", "InsiderFast" 14 | .PARAMETER IISRoot 15 | Must supply iisBase. The path to default IIS eg C:\inetpub\wwwroot 16 | .PARAMETER IisFolder 17 | Must supply channel. The Folder name of the Share to publish in iis. Eg MAUCache 18 | .PARAMETER Channel 19 | Must supply TempShare. Path For working folder. Everything is downloaded then moved from this location. Eg c:\temp 20 | .EXAMPLE 21 | powershell.exe .\psMacUpdatesOFFICE.ps1 -channel Production -IISRoot C:\inetpub\wwwroot -IisFolder maucache -TempShare C:\temp 22 | .EXAMPLE 23 | powershell.exe .\psMacUpdatesOFFICE.ps1 -channel Production -IISRoot C:\inetpub\wwwroot -IisFolder maucache -TempShare C:\temp -verbose 24 | #> 25 | [cmdletbinding()] 26 | Param( 27 | [Parameter(Mandatory=$true,HelpMessage='Must supply channel. Values acceptable are "Production", "External", "InsiderFast"')] 28 | [ValidateSet("Production", "External", "InsiderFast")] 29 | [string] 30 | $channel, 31 | [Parameter(Mandatory=$true,HelpMessage='Default IIS LOcation EG c:\iinetpub\wwwroot')] 32 | [Validatescript({if (test-path ($_)){$true} Else {Throw "$_ doesnt exist. Must be a valid Path"}})] 33 | [string] 34 | $IISRoot, 35 | [Parameter(Mandatory=$true,HelpMessage='IIS Shared Folder Name. Also used in the temp folder eg. MAUCache')] 36 | [string] 37 | $IisFolder, 38 | [Parameter(Mandatory=$true,HelpMessage='Path to Temp working space eg c:\temp')] 39 | [Validatescript({if (test-path ($_)){$true} Else {Throw "$_ doesnt exist. Must be a valid Path"}})] 40 | [string] 41 | $TempShare 42 | ) 43 | 44 | If (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) 45 | { 46 | Write-Warning "You do not have Administrator rights to run this script!`nPlease re-run this script as an Administrator!" 47 | Break 48 | } 49 | 50 | 51 | $null = New-Object System.Net.webclient 52 | 53 | #$PublishFolderName = "MAUCache" 54 | #$publishBasePath = "C:\inetpub\wwwroot" 55 | #$tempfolderLocation = "C:\temp" 56 | 57 | $PublishFolderName = $IisFolder 58 | $publishBasePath = $IISRoot 59 | $tempfolderLocation = $TempShare 60 | 61 | #Test iis shared folder exists if not make it 62 | if (!(test-path "$publishBasePath\$PublishFolderName")){ 63 | New-Item -ItemType Directory -Path "$publishBasePath" -Name "$PublishFolderName" 64 | } 65 | 66 | 67 | $PublishFolder = "$publishBasePath\$PublishFolderName" 68 | $tempFolder = "$tempfolderLocation\$PublishFolderName" 69 | 70 | $collarteralFolder = $tempFolder 71 | 72 | #setup TEmp environment 73 | If (Test-path -Path $tempFolder) 74 | { 75 | Remove-Item $tempfolder -Recurse -Force 76 | 77 | } 78 | #Create TEmp Structure 79 | New-Item -ItemType Directory -Path $tempfolderlocation -Name "$PublishFolderName" 80 | New-Item -ItemType Directory -Path "$tempfolder" -Name "Collateral" 81 | 82 | 83 | $starturl = "https://officecdn-microsoft-com.akamaized.net" 84 | switch ($channel) 85 | { 86 | "Production"{$webUrlDownload = "$starturl/pr/C1297A47-86C4-4C1F-97FA-950631F94777/MacAutoupdate/"} 87 | "External"{$webUrlDownload = "$starturl/pr/1ac37578-5a24-40fb-892e-b89d85b6dfaa/MacAutoupdate/"} 88 | "InsiderFast"{$webUrlDownload = "$starturl/pr/4B2D7701-0A4F-49C8-B4CB-0C2D4043F51F/MacAutoupdate/"} 89 | } 90 | [io.file]::WriteAllbytes("$collarteralFolder\builds.txt",(Invoke-WebRequest -URI "$webUrlDownload/builds.txt").content) 91 | 92 | #compare temp build.txt with prod build.txt 93 | #Initalise $origcontent array in case of first run 94 | #if change detected then continue else stop 95 | $origContent = @("") 96 | if (test-path "$PublishFolder\builds.txt"){$origContent = Get-Content "$PublishFolder\builds.txt"} 97 | $newContent = Get-Content "$collarteralFolder\builds.txt" 98 | If ((compare-object $origContent $newContent).count -eq 0){ 99 | Write-Verbose "No Change" 100 | Break 101 | } 102 | 103 | $MAUID_MAU3X="0409MSAU03" 104 | $MAUID_MAU4X="0409MSAU04" 105 | $MAUID_WORD2019="0409MSWD2019" 106 | $MAUID_EXCEL2019="0409XCEL2019" 107 | $MAUID_POWERPOINT2019="0409PPT32019" 108 | $MAUID_OUTLOOK2019="0409OPIM2019" 109 | $MAUID_ONENOTE2019="0409ONMC2019" 110 | $MAUID_WORD2016="0409MSWD15" 111 | $MAUID_EXCEL2016="0409XCEL15" 112 | $MAUID_POWERPOINT2016="0409PPT315" 113 | $MAUID_OUTLOOK2016="0409OPIM15" 114 | $MAUID_ONENOTE2016="0409ONMC15" 115 | $MAUID_OFFICE2011="0409MSOF14" 116 | $MAUID_LYNC2011="0409UCCP14" 117 | $MAUID_SKYPE2016="0409MSFB16" 118 | $MAUID_INTUNECP="0409IMCP01" 119 | $MAUID_REMOTEDESKTOP10="0409MSRD10" 120 | 121 | function BuildApplicationArray() { 122 | # Builds an array of all the MAU-enabled applications that we care about 123 | $MAUAPP=@() 124 | $MAUAPP+="$MAUID_MAU3X" 125 | $MAUAPP+="$MAUID_MAU4X" 126 | $MAUAPP+="$MAUID_WORD2019" 127 | $MAUAPP+="$MAUID_EXCEL2019" 128 | $MAUAPP+="$MAUID_POWERPOINT2019" 129 | $MAUAPP+="$MAUID_OUTLOOK2019" 130 | $MAUAPP+="$MAUID_ONENOTE2019" 131 | $MAUAPP+="$MAUID_WORD2016" 132 | $MAUAPP+="$MAUID_EXCEL2016" 133 | $MAUAPP+="$MAUID_POWERPOINT2016" 134 | $MAUAPP+="$MAUID_OUTLOOK2016" 135 | $MAUAPP+="$MAUID_ONENOTE2016" 136 | $MAUAPP+="$MAUID_OFFICE2011" 137 | $MAUAPP+="$MAUID_LYNC2011" 138 | $MAUAPP+="$MAUID_SKYPE2016" 139 | $MAUAPP+="$MAUID_INTUNECP" 140 | $MAUAPP+="$MAUID_REMOTEDESKTOP10" 141 | return $MAUAPP 142 | } 143 | function DownloadUPdate ([Parameter(Mandatory=$true)]$Payload, [Parameter(Mandatory=$true)]$location) 144 | { 145 | #Test-WritePath 146 | Write-Verbose "Starting $location - $collateral\$payload" 147 | 148 | #DOwnload to correct path 149 | 150 | #TEST BASELINE 151 | $collateral = "$collarteralFolder" 152 | $wc = New-Object System.Net.WebClient 153 | $wc.DownloadFile($($location), "$collateral\$payload") 154 | 155 | } 156 | 157 | 158 | 159 | function DownloadCollateralFiles ([Parameter(Mandatory=$true)]$downloadarray,[Parameter(Mandatory=$true)]$weburldown){ 160 | # Downloads XML/CAT collateral files 161 | foreach ($Down in $DownloadArray){ 162 | $payload ="" 163 | $locationstring = "" 164 | $UpdateVersions = "" 165 | Write-Verbose "$down" 166 | [io.file]::WriteAllbytes("$collarteralFolder\$down.xml",(Invoke-WebRequest -URI "$weburldown$down.xml").content) 167 | [io.file]::WriteAllbytes("$collarteralFolder\$down-chk.xml",(Invoke-WebRequest -URI "$weburldown$down-chk.xml").content) 168 | [io.file]::WriteAllbytes("$collarteralFolder\$down.cat",(Invoke-WebRequest -URI "$weburldown$down.cat").content) 169 | 170 | 171 | #get xml and find updateversion 172 | $log = "$collarteralFolder\$down.xml" 173 | $collateral = $collarteralFolder 174 | $patt = "Update Version" 175 | $indx = Select-String $patt $log | ForEach-Object {$_.LineNumber} 176 | if ($indx.count -ge 2){ 177 | $UpdateVersions= @((Get-Content $log)[$indx]) 178 | $UpdateVersions=$UpdateVersions -replace "
", "" 179 | $UpdateVersions=$UpdateVersions -replace "", "" 180 | $UpdateVersions=$UpdateVersions.trim() 181 | $pathtoput = "$($updateversions[0])" 182 | } 183 | elseif ($indx.count -eq 1){ 184 | $UpdateVersions= @((Get-Content $log)[$indx]) 185 | $UpdateVersions=$UpdateVersions -replace "", "" 186 | $UpdateVersions=$UpdateVersions -replace "", "" 187 | $UpdateVersions=$UpdateVersions.trim() 188 | $pathtoput="$($UpdateVersions)" 189 | } 190 | else { 191 | $pathtoput="Legacy" 192 | } 193 | 194 | 195 | 196 | #TEST COLLATERAL PATH EXISTS 197 | if (!(Test-Path "$collateral\$pathtoput")){ 198 | new-item -ItemType Directory -Path $collateral -Name $pathtoput -Verbose 199 | } 200 | write-verbose "$collateral\$pathtoput\$down.xml" 201 | Copy-Item -Path "$collarteralFolder\$down.xml" -Destination "$collateral\$pathtoput\$down.xml" -Verbose 202 | Copy-Item -Path "$collarteralFolder\$down-chk.xml" -Destination "$collateral\$pathtoput\$down-chk.xml" -Verbose 203 | Copy-Item -Path "$collarteralFolder\$down.cat" -Destination "$collateral\$pathtoput\$down.cat" -Verbose 204 | 205 | 206 | #PAYLOAD NAME 207 | $log = "$collarteralFolder\$down.xml" 208 | $patt = "Payload" 209 | $indxp = Select-String $patt $log | ForEach-Object {$_.LineNumber} 210 | write-verbose "$indx $($down)" 211 | $payload=@((Get-Content $log)[$indxp]) 212 | $payload=$payload -replace "", "" 213 | $payload=$payload -replace "", "" 214 | $payload=$payload.trim() 215 | 216 | #DOWNLOAD FILE 217 | $patt = "Location" 218 | $indx = Select-String $patt $log | ForEach-Object {$_.LineNumber} 219 | $locationstring= @((Get-Content $log)[$indx]) 220 | $locationstring=$locationstring -replace "", "" 221 | $locationstring=$locationstring -replace "", "" 222 | $locationstring=$locationstring.trim() 223 | 224 | 225 | if ($indxp.count -le 1){ 226 | write-verbose "One Detected $payload $locationstring" 227 | DownloadUPdate -Payload $payload -location $locationstring 228 | } 229 | else 230 | { 231 | for ($x = 0; $x -le ($($indxp.count)-1); $x += 1) 232 | { 233 | write-verbose "One Detected $x" 234 | $pay = $($payload[$x]) 235 | $loc = $($locationstring[$x]) 236 | DownloadUPdate -Payload $pay -location $loc 237 | } 238 | 239 | 240 | } 241 | 242 | } 243 | } 244 | 245 | $mauApp = BuildApplicationArray 246 | 247 | DownloadCollateralFiles -downloadarray $mauApp -weburldown $webUrlDownload 248 | 249 | #rename Folders 250 | #Sanity check of folder before renaming 251 | if ((Get-ChildItem $tempfolder).count -ge 10){ 252 | 253 | Remove-Item $PublishFolder -recurse -Force 254 | start-sleep -Seconds 30 255 | Move-Item -Path $tempfolder -Destination "$publishBasePath" 256 | 257 | } 258 | --------------------------------------------------------------------------------