├── .gitignore ├── LICENSE.md ├── docs ├── createbootvolfromautonbi.md └── installinstallmacos.md ├── munki_bundle_pkg_finder.py ├── README.md ├── getmacosipsws.py └── installinstallmacos.py /.gitignore: -------------------------------------------------------------------------------- 1 | # .DS_Store 2 | .DS_Store 3 | 4 | # disk images 5 | *.dmg 6 | *.sparseimage 7 | 8 | # .pyc and .pyo files 9 | *.pyc 10 | *.pyo 11 | 12 | # our content directory 13 | content/ 14 | 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Licensed under the Apache License, Version 2.0 (the "License"); 2 | you may not use this source code except in compliance with the License. 3 | You may obtain a copy of the License at 4 | 5 | https://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | -------------------------------------------------------------------------------- /docs/createbootvolfromautonbi.md: -------------------------------------------------------------------------------- 1 | ### createbootvolfromautonbi.py 2 | 3 | A tool to make bootable disk volumes from the output of autonbi. Especially 4 | useful to make bootable disks containing Imagr and the 'SIP-ignoring' kernel, 5 | which allows Imagr to run scripts that affect SIP state, set UAKEL options, and 6 | run the `startosinstall` component, all of which might otherwise require network 7 | booting from a NetInstall-style nbi. 8 | 9 | Imagr (https://github.com/grahamgilbert/imagr) is a nice tool for automating Mac setup workflows. It is/was originally designed to be run from a Netboot volume, especially one created with the AutoNBI tool (https://github.com/bruienne/autonbi/). When run from a Netboot image created this way, Imagr runs as root, and SIP is ignored, enabling Imagr to do many of the needed tasks around setting up a machine for initial use. 10 | 11 | But Netboot might not be available in your environment. And the new iMac Pro does not support Netboot. `createbootvolfromautonbi.py` allows you to create a bootable external drive (USB/Firewire/Thunderbolt) from the output of autonbi, and more specifically, the output of the `make nbi` Makefile target included with Imagr. 12 | 13 | This would allow you to create an external boot drive with Imagr that can do what you can do with Imagr from an AutoNBI image. 14 | 15 | #### Usage 16 | 17 | ```./createbootvolfromautonbi.py --nbi /path/to/Imagr.nbi --volume /Volumes/SomeEmptyExternalHFSPlusVolume``` 18 | 19 | -------------------------------------------------------------------------------- /munki_bundle_pkg_finder.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/munki/munki-python 2 | 3 | import os 4 | import plistlib 5 | import sys 6 | 7 | sys.path.append("/usr/local/munki") 8 | 9 | from munkilib import dmgutils 10 | from munkilib import pkgutils 11 | 12 | if len(sys.argv) != 2: 13 | print('Need exactly one parameter: path to a munki repo!', file=sys.stderr) 14 | sys.exit(-1) 15 | 16 | repo_path = sys.argv[1] 17 | 18 | all_catalog = os.path.join(repo_path, "catalogs/all") 19 | 20 | with open(all_catalog, mode="rb") as FILE: 21 | all_items = plistlib.load(FILE) 22 | 23 | dmg_items = [{"name": item["name"], 24 | "version": item["version"], 25 | "location": item["installer_item_location"], 26 | "package_path": item.get("package_path", "")} 27 | for item in all_items 28 | if item.get("installer_item_location", "").endswith(".dmg") and 29 | item.get("installer_type") is None] 30 | 31 | items_with_bundle_style_pkgs = [] 32 | for item in dmg_items: 33 | full_path = os.path.join(repo_path, "pkgs", item["location"]) 34 | print("Checking %s..." % full_path) 35 | mountpoints = dmgutils.mountdmg(full_path) 36 | if mountpoints: 37 | pkg_path = item["package_path"] 38 | if pkg_path: 39 | itempath = os.path.join(mountpoints[0], pkg_path) 40 | if os.path.isdir(itempath): 41 | print("***** %s--%s has a bundle-style pkg" 42 | % (item["name"], item["version"])) 43 | items_with_bundle_style_pkgs.append(item) 44 | else: 45 | for file_item in os.listdir(mountpoints[0]): 46 | if pkgutils.hasValidInstallerItemExt(file_item): 47 | itempath = os.path.join(mountpoints[0], file_item) 48 | if os.path.isdir(itempath): 49 | print("***** %s--%s has a bundle-style pkg" 50 | % (item["name"], item["version"])) 51 | items_with_bundle_style_pkgs.append(item) 52 | break 53 | dmgutils.unmountdmg(mountpoints[0]) 54 | else: 55 | print("No filesystems mounted from %s" % full_path) 56 | continue 57 | 58 | print("Found %s items with bundle-style pkgs." 59 | % len(items_with_bundle_style_pkgs)) 60 | for item in sorted(items_with_bundle_style_pkgs, key=lambda d: d["name"]): 61 | print("%s--%s"% (item["name"], item["version"])) 62 | print(" %s" % item["location"]) 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### macadmin-scripts 2 | 3 | Some scripts that might be of use to macOS admins. Might be related to Munki; 4 | might not. 5 | 6 | These are only supported on macOS. There is no support for running these on Windows or Linux. 7 | 8 | In macOS 12.3, Apple stopped providung Python as part of macOS. You'll need to provide your own Python to use these scripts. You may also need to install additional Python modules. 9 | 10 | #### getmacosipsws.py 11 | 12 | Quick-and-dirty tool to download the macOS IPSW files currently advertised by Apple in the https://mesu.apple.com/assets/macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml feed. 13 | 14 | #### installinstallmacos.py 15 | 16 | This script can create disk images containing macOS Installer applications available via Apple's softwareupdate catalogs. 17 | 18 | Run `python ./installinstallmacos.py --help` to see the available options. 19 | 20 | The tool assembles "Install macOS" applications by downloading the packages from Apple's softwareupdate servers and then installing them into a new empty disk image. 21 | 22 | If `/usr/bin/installer` returns errors during this process, it can be useful to examine `/var/log/install.log` for clues. 23 | 24 | Since it is using Apple's installer, any install check or volume check scripts are run. This means that you can only use this tool to create a diskimage containing the versions of macOS that will run on the exact machine you are running the script on. 25 | 26 | For example, to create a diskimage containing the version 10.13.6 that runs on 2018 MacBook Pros, you must run this script on a 2018 MacBook Pro, and choose the proper version. 27 | 28 | Typically "forked" OS build numbers are 4 digits, so when this document was last updated, build 17G2208 was the correct build for 2018 MacBook Pros; 17G65 was the correct build for all other Macs that support High Sierra. 29 | 30 | If you attempt to install an incompatible version of macOS, you'll see an error similar to the following: 31 | 32 | ``` 33 | Making empty sparseimage... 34 | installer: Error - ERROR_B14B14D9B7 35 | Command '['/usr/sbin/installer', '-pkg', './content/downloads/07/20/091-95774/awldiototubemmsbocipx0ic9lj2kcu0pt/091-95774.English.dist', '-target', '/private/tmp/dmg.Hf0PHy']' returned non-zero exit status 1 36 | Product installation failed. 37 | ``` 38 | 39 | Use a compatible Mac or select a different build compatible with your current hardware and try again. You may also have success running the script in a VM; the InstallationCheck script in versions of the macOS installer to date skips the checks (and returns success) when run on a VM. 40 | 41 | ##### Important note for Catalina+ 42 | macOS privacy protections might interfere with the operation of this tool if you run it from ~/Desktop, ~/Documents, ~/Downloads or other directories protected in macOS Catalina or later. Consider using /Users/Shared (or subdirectory) as the "working space" for this tool. 43 | 44 | 45 | ##### Alternate implementations 46 | Graham Pugh has a fork with a lot more features and bells and whistles. Check it out if your needs aren't met by this tool. 47 | https://github.com/grahampugh/macadmin-scripts 48 | 49 | -------------------------------------------------------------------------------- /docs/installinstallmacos.md: -------------------------------------------------------------------------------- 1 | ### installinstallmacos.py 2 | 3 | A script to download the components for a macOS installer from Apple's softwareupdate servers and then install those components as a working "Install macOS High Sierra.app" onto a disk image. 4 | 5 | The install logic within Apple's packages will be evaluated by Apple's installer, so you must run this on hardware compatible with the version of macOS for which you are attempting to obtain an installer. In other words, this script will fail when run on hardware that does not support High Sierra, and should High Sierra be "forked" as it was when the iMac Pro was first shipped, you may only be able to successfully install a hardware-specific version of the installer on the hardware supported by that specific build. 6 | 7 | You'll need roughly twice the ultimate storage space; IOW if the High Sierra installer is 6GB you'll need at least 12GB free. If you use the --compress option you may need up to three times the space. 8 | 9 | This tool must be run as root or with `sudo`. 10 | 11 | 12 | #### Options 13 | 14 | `--catalogurl` Software Update catalog URL used by the tool. Defaults to the default softwareupdate catalog for the current OS if you run this tool under macOS 10.13-10.15.x. 15 | 16 | `--seedprogram SEEDPROGRAMNAME` Attempt to find and use the Seed catalog for the current OS. Use `installinstallmacos.py --help` to see the valid SeedProgram names for the current OS. 17 | 18 | `--workdir` Path to working directory on a volume with over 10G of available space. Defaults to current working directory. 19 | 20 | `--compress` Output a read-only compressed disk image with the Install macOS app at the root. This is slower and requires much more working disk space than the default, but the end product is more useful with tools like Munki and Imagr. 21 | 22 | `--ignore-cache` Ignore any previously cached files. All needed files will be re-downloaded from the softwareupdate server. 23 | 24 | 25 | #### Example operation 26 | 27 | ``` 28 | % sudo ./installinstallmacos.py 29 | Downloading https://swscan.apple.com/content/catalogs/others/index-10.13seed-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog... 30 | Downloading http://swcdn.apple.com/content/downloads/16/14/091-62779/frfttxz116hdm02ajg89z3cubtiv64r39s/InstallAssistantAuto.smd... 31 | Downloading https://swdist.apple.com/content/downloads/16/14/091-62779/frfttxz116hdm02ajg89z3cubtiv64r39s/091-62779.English.dist... 32 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/InstallAssistantAuto.smd... 33 | Downloading https://swdist.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/091-76233.English.dist... 34 | Downloading http://swcdn.apple.com/content/downloads/45/61/091-71284/77pnhgsj5oza9h28y7vjjtby8s1binimnj/InstallAssistantAuto.smd... 35 | Downloading https://swdist.apple.com/content/downloads/45/61/091-71284/77pnhgsj5oza9h28y7vjjtby8s1binimnj/091-71284.English.dist... 36 | # ProductID Version Build Title 37 | 1 091-76233 10.13.4 17E199 Install macOS High Sierra 38 | 2 091-62779 10.13.3 17D2047 Install macOS High Sierra 39 | 3 091-71284 10.13.4 17E160g Install macOS High Sierra Beta 40 | 41 | Choose a product to download (1-3): 1 42 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/BaseSystem.chunklist... 43 | % Total % Received % Xferd Average Speed Time Time Time Current 44 | Dload Upload Total Spent Left Speed 45 | 100 1984 100 1984 0 0 65636 0 --:--:-- --:--:-- --:--:-- 66133 46 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/InstallESDDmg.pkg... 47 | % Total % Received % Xferd Average Speed Time Time Time Current 48 | Dload Upload Total Spent Left Speed 49 | 100 4501M 100 4501M 0 0 30.9M 0 0:02:25 0:02:25 --:--:-- 30.7M 50 | Downloading https://swdist.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/InstallESDDmg.pkm... 51 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/InstallInfo.plist... 52 | % Total % Received % Xferd Average Speed Time Time Time Current 53 | Dload Upload Total Spent Left Speed 54 | 100 1584 100 1584 0 0 78025 0 --:--:-- --:--:-- --:--:-- 79200 55 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/RecoveryHDMetaDmg.pkg... 56 | % Total % Received % Xferd Average Speed Time Time Time Current 57 | Dload Upload Total Spent Left Speed 58 | 100 464M 100 464M 0 0 25.3M 0 0:00:18 0:00:18 --:--:-- 31.2M 59 | Downloading https://swdist.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/RecoveryHDMetaDmg.pkm... 60 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/AppleDiagnostics.chunklist... 61 | % Total % Received % Xferd Average Speed Time Time Time Current 62 | Dload Upload Total Spent Left Speed 63 | 100 328 100 328 0 0 16419 0 --:--:-- --:--:-- --:--:-- 17263 64 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/BaseSystem.dmg... 65 | % Total % Received % Xferd Average Speed Time Time Time Current 66 | Dload Upload Total Spent Left Speed 67 | 100 462M 100 462M 0 0 34.7M 0 0:00:13 0:00:13 --:--:-- 38.7M 68 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/AppleDiagnostics.dmg... 69 | % Total % Received % Xferd Average Speed Time Time Time Current 70 | Dload Upload Total Spent Left Speed 71 | 100 2586k 100 2586k 0 0 10.6M 0 --:--:-- --:--:-- --:--:-- 10.6M 72 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/InstallAssistantAuto.pkg... 73 | % Total % Received % Xferd Average Speed Time Time Time Current 74 | Dload Upload Total Spent Left Speed 75 | 100 11.2M 100 11.2M 0 0 19.5M 0 --:--:-- --:--:-- --:--:-- 19.5M 76 | Downloading https://swdist.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/InstallAssistantAuto.pkm... 77 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/InstallESDDmg.chunklist... 78 | % Total % Received % Xferd Average Speed Time Time Time Current 79 | Dload Upload Total Spent Left Speed 80 | 100 16528 100 16528 0 0 493k 0 --:--:-- --:--:-- --:--:-- 504k 81 | Downloading http://swcdn.apple.com/content/downloads/10/62/091-76233/v27a64q1zvxd2lbw4gbej9c2s5gxk6zb1l/OSInstall.mpkg... 82 | % Total % Received % Xferd Average Speed Time Time Time Current 83 | Dload Upload Total Spent Left Speed 84 | 100 658k 100 658k 0 0 4481k 0 --:--:-- --:--:-- --:--:-- 4509k 85 | Making empty sparseimage... 86 | installer: Package name is Install macOS High Sierra 87 | installer: Installing at base path /private/tmp/dmg.7Znuzg 88 | installer: The install was successful. 89 | Product downloaded and installed to /Users/Shared/munki-git/macadmin-scripts/Install_macOS_10.13.4-17E199.sparseimage 90 | ``` -------------------------------------------------------------------------------- /getmacosipsws.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright 2021-2022 Greg Neagle. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | '''Parses Apple's feed of macOS IPSWs and lets you download one''' 18 | 19 | from __future__ import ( 20 | absolute_import, division, print_function, unicode_literals) 21 | 22 | import os 23 | import plistlib 24 | import subprocess 25 | import sys 26 | try: 27 | # python 2 28 | from urllib.parse import urlsplit 29 | except ImportError: 30 | # python 3 31 | from urlparse import urlsplit 32 | from xml.parsers.expat import ExpatError 33 | 34 | 35 | class ReplicationError(Exception): 36 | '''A custom error when replication fails''' 37 | pass 38 | 39 | 40 | def get_url(url, 41 | download_dir='/tmp', 42 | show_progress=False, 43 | attempt_resume=False): 44 | '''Downloads a URL and stores it in the download_dir. 45 | Returns a path to the replicated file.''' 46 | 47 | path = urlsplit(url)[2] 48 | filename = os.path.basename(path) 49 | local_file_path = os.path.join(download_dir, filename) 50 | if show_progress: 51 | options = '-fL' 52 | else: 53 | options = '-sfL' 54 | need_download = True 55 | while need_download: 56 | curl_cmd = ['/usr/bin/curl', options, 57 | '--create-dirs', 58 | '-o', local_file_path, 59 | '-w', '%{http_code}'] 60 | if not url.endswith(".gz"): 61 | # stupid hack for stupid Apple behavior where it sometimes returns 62 | # compressed files even when not asked for 63 | curl_cmd.append('--compressed') 64 | resumed = False 65 | if os.path.exists(local_file_path): 66 | if not attempt_resume: 67 | curl_cmd.extend(['-z', local_file_path]) 68 | else: 69 | resumed = True 70 | curl_cmd.extend(['-z', '-' + local_file_path, '-C', '-']) 71 | curl_cmd.append(url) 72 | print("Downloading %s..." % url) 73 | need_download = False 74 | try: 75 | _ = subprocess.check_output(curl_cmd) 76 | except subprocess.CalledProcessError as err: 77 | if not resumed or not err.output.isdigit(): 78 | raise ReplicationError(err) 79 | # HTTP error 416 on resume: the download is already complete and the 80 | # file is up-to-date 81 | # HTTP error 412 on resume: the file was updated server-side 82 | if int(err.output) == 412: 83 | print("Removing %s and retrying." % local_file_path) 84 | os.unlink(local_file_path) 85 | need_download = True 86 | elif int(err.output) != 416: 87 | raise ReplicationError(err) 88 | return local_file_path 89 | 90 | 91 | def get_input(prompt=None): 92 | '''Python 2 and 3 wrapper for raw_input/input''' 93 | try: 94 | return raw_input(prompt) 95 | except NameError: 96 | # raw_input doesn't exist in Python 3 97 | return input(prompt) 98 | 99 | 100 | def read_plist(filepath): 101 | '''Wrapper for the differences between Python 2 and Python 3's plistlib''' 102 | try: 103 | with open(filepath, "rb") as fileobj: 104 | return plistlib.load(fileobj) 105 | except AttributeError: 106 | # plistlib module doesn't have a load function (as in Python 2) 107 | return plistlib.readPlist(filepath) 108 | 109 | 110 | def read_plist_from_string(bytestring): 111 | '''Wrapper for the differences between Python 2 and Python 3's plistlib''' 112 | try: 113 | return plistlib.loads(bytestring) 114 | except AttributeError: 115 | # plistlib module doesn't have a load function (as in Python 2) 116 | return plistlib.readPlistFromString(bytestring) 117 | 118 | IPSW_DATA = None 119 | def get_ipsw_data(): 120 | '''Return data from com_apple_macOSIPSW.xml (which is actually a plist)''' 121 | global IPSW_DATA 122 | IPSW_FEED = "https://mesu.apple.com/assets/macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml" 123 | 124 | if not IPSW_DATA: 125 | try: 126 | ipsw_plist = get_url(IPSW_FEED) 127 | IPSW_DATA = read_plist(ipsw_plist) 128 | except (OSError, IOError, ExpatError, ReplicationError) as err: 129 | print(err, file=sys.stderr) 130 | exit(1) 131 | 132 | return IPSW_DATA 133 | 134 | def getMobileDeviceSoftwareVersionsByVersion(): 135 | '''return the MobileDeviceSoftwareVersionsByVersion dict''' 136 | ipsw_data = get_ipsw_data() 137 | return ipsw_data.get("MobileDeviceSoftwareVersionsByVersion", {}) 138 | 139 | 140 | def getMobileDeviceSoftwareVersions(version=1): 141 | '''Return the dict under the version number key. Current xml has only "1"''' 142 | return getMobileDeviceSoftwareVersionsByVersion().get("%s" % version, {}) 143 | 144 | 145 | def getMachineModelsForMobileDeviceSoftwareVersions(version=1): 146 | '''Get the model keys''' 147 | versions = getMobileDeviceSoftwareVersions(version=version).get( 148 | "MobileDeviceSoftwareVersions", {}) 149 | return versions.keys() 150 | 151 | 152 | def getSoftwareVersionsForMachineModel(model, version=1): 153 | '''Get the dict for a specific model''' 154 | versions = getMobileDeviceSoftwareVersions(version=version).get( 155 | "MobileDeviceSoftwareVersions", {}) 156 | return versions[model] 157 | 158 | 159 | def getIPSWInfoForMachineModel(model, version=1): 160 | '''Build and return a list of dict describing the available 161 | ipsw file for a specific model''' 162 | model_info_list = [] 163 | model_versions = getSoftwareVersionsForMachineModel(model, version=version) 164 | for key in model_versions: 165 | if key == "Unknown": 166 | build_dict = model_versions["Unknown"].get("Universal", {}) 167 | else: 168 | build_dict = model_versions[key] 169 | restore_info = build_dict.get("Restore") 170 | if restore_info: 171 | model_info = {"model": model} 172 | model_info.update(restore_info) 173 | model_info_list.append(model_info) 174 | return model_info_list 175 | 176 | 177 | def getAllModelInfo(version=1): 178 | '''Build and return a list of all available ipsws''' 179 | all_model_info = [] 180 | available_models = getMachineModelsForMobileDeviceSoftwareVersions( 181 | version=version) 182 | for model in available_models: 183 | model_info = getIPSWInfoForMachineModel(model, version=version) 184 | all_model_info.extend(model_info) 185 | return all_model_info 186 | 187 | 188 | def main(): 189 | '''Our main thing to do''' 190 | all_model_info = getAllModelInfo() 191 | # display a menu of choices 192 | print('%2s %16s %10s %8s %11s' 193 | % ('#', 'Model', 'Version', 'Build', 'Checksum')) 194 | for index, item in enumerate(all_model_info): 195 | print('%2s %16s %10s %8s %11s' % ( 196 | index + 1, 197 | item["model"], 198 | item.get('ProductVersion', 'UNKNOWN'), 199 | item.get('BuildVersion', 'UNKNOWN'), 200 | item.get('FirmwareSHA1', 'UNKNOWN')[-6:])) 201 | 202 | answer = get_input( 203 | '\nChoose a product to download (1-%s): ' % len(all_model_info)) 204 | try: 205 | index = int(answer) - 1 206 | if index < 0: 207 | raise ValueError 208 | except (ValueError, IndexError): 209 | print('Exiting.') 210 | exit(0) 211 | 212 | download_url = getAllModelInfo()[index].get("FirmwareURL") 213 | if download_url: 214 | try: 215 | filepath = get_url(download_url, 216 | download_dir=".", show_progress=True, attempt_resume=True) 217 | print("IPSW downloaded to: %s" % filepath) 218 | except (ReplicationError, IOError, OSError) as err: 219 | print(err, file=sys.stderr) 220 | exit(1) 221 | else: 222 | print("No valid download URL for that item.", file=sys.stderr) 223 | exit(1) 224 | 225 | 226 | if __name__ == '__main__': 227 | main() 228 | -------------------------------------------------------------------------------- /installinstallmacos.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright 2017-2022 Greg Neagle. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # Thanks to Tim Sutton for ideas, suggestions, and sample code. 19 | # 20 | 21 | '''installinstallmacos.py 22 | A tool to download the parts for an Install macOS app from Apple's 23 | softwareupdate servers and install a functioning Install macOS app onto an 24 | empty disk image''' 25 | 26 | # Python 3 compatibility shims 27 | from __future__ import ( 28 | absolute_import, division, print_function, unicode_literals) 29 | 30 | import argparse 31 | import gzip 32 | import os 33 | import plistlib 34 | import subprocess 35 | import sys 36 | import platform 37 | 38 | try: 39 | # python 2 40 | from urllib.parse import urlsplit 41 | except ImportError: 42 | # python 3 43 | from urlparse import urlsplit 44 | from xml.dom import minidom 45 | from xml.parsers.expat import ExpatError 46 | 47 | # disable pylint's suggestions about using f-strings 48 | # pylint: disable=C0209 49 | 50 | try: 51 | import xattr 52 | except ImportError: 53 | print("This tool requires the Python xattr module. " 54 | "Perhaps run `pip install xattr` to install it.") 55 | sys.exit(-1) 56 | 57 | 58 | DEFAULT_SUCATALOGS = { 59 | '17': 'https://swscan.apple.com/content/catalogs/others/' 60 | 'index-10.13-10.12-10.11-10.10-10.9' 61 | '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', 62 | '18': 'https://swscan.apple.com/content/catalogs/others/' 63 | 'index-10.14-10.13-10.12-10.11-10.10-10.9' 64 | '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', 65 | '19': 'https://swscan.apple.com/content/catalogs/others/' 66 | 'index-10.15-10.14-10.13-10.12-10.11-10.10-10.9' 67 | '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', 68 | '20': 'https://swscan.apple.com/content/catalogs/others/' 69 | 'index-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' 70 | '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', 71 | '21': 'https://swscan.apple.com/content/catalogs/others/' 72 | 'index-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' 73 | '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', 74 | '22': 'https://swscan.apple.com/content/catalogs/others/' 75 | 'index-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' 76 | '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', 77 | '23': 'https://swscan.apple.com/content/catalogs/others/' 78 | 'index-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' 79 | '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', 80 | '24': 'https://swscan.apple.com/content/catalogs/others/' 81 | 'index-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' 82 | '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', 83 | '25': 'https://swscan.apple.com/content/catalogs/others/' 84 | 'index-26-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' 85 | '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog' 86 | } 87 | 88 | SEED_CATALOGS_PLIST = ( 89 | '/System/Library/PrivateFrameworks/Seeding.framework/Versions/Current/' 90 | 'Resources/SeedCatalogs.plist' 91 | ) 92 | 93 | 94 | def get_input(prompt=None): 95 | '''Python 2 and 3 wrapper for raw_input/input''' 96 | try: 97 | return raw_input(prompt) 98 | except NameError: 99 | # raw_input doesn't exist in Python 3 100 | return input(prompt) 101 | 102 | 103 | def read_plist(filepath): 104 | '''Wrapper for the differences between Python 2 and Python 3's plistlib''' 105 | try: 106 | with open(filepath, "rb") as fileobj: 107 | return plistlib.load(fileobj) 108 | except AttributeError: 109 | # plistlib module doesn't have a load function (as in Python 2) 110 | # pylint: disable=E1101 111 | return plistlib.readPlist(filepath) 112 | # pylint: enable=E1101 113 | 114 | 115 | def read_plist_from_string(bytestring): 116 | '''Wrapper for the differences between Python 2 and Python 3's plistlib''' 117 | try: 118 | return plistlib.loads(bytestring) 119 | except AttributeError: 120 | # plistlib module doesn't have a load function (as in Python 2) 121 | # pylint: disable=E1101 122 | return plistlib.readPlistFromString(bytestring) 123 | # pylint: enable=E1101 124 | 125 | 126 | def get_seeding_program(sucatalog_url): 127 | '''Returns a seeding program name based on the sucatalog_url''' 128 | try: 129 | seed_catalogs = read_plist(SEED_CATALOGS_PLIST) 130 | for key, value in seed_catalogs.items(): 131 | if sucatalog_url == value: 132 | return key 133 | return '' 134 | except (OSError, IOError, ExpatError, AttributeError, KeyError) as err: 135 | print(err, file=sys.stderr) 136 | return '' 137 | 138 | 139 | def get_seed_catalog(seedname='DeveloperSeed'): 140 | '''Returns the developer seed sucatalog''' 141 | try: 142 | seed_catalogs = read_plist(SEED_CATALOGS_PLIST) 143 | return seed_catalogs.get(seedname) 144 | except (OSError, IOError, ExpatError, AttributeError, KeyError) as err: 145 | print(err, file=sys.stderr) 146 | return '' 147 | 148 | 149 | def get_seeding_programs(): 150 | '''Returns the list of seeding program names''' 151 | try: 152 | seed_catalogs = read_plist(SEED_CATALOGS_PLIST) 153 | return list(seed_catalogs.keys()) 154 | except (OSError, IOError, ExpatError, AttributeError, KeyError) as err: 155 | print(err, file=sys.stderr) 156 | return '' 157 | 158 | 159 | def get_default_catalog(): 160 | '''Returns the default softwareupdate catalog for the current OS''' 161 | darwin_major = os.uname()[2].split('.')[0] 162 | return DEFAULT_SUCATALOGS.get(darwin_major) 163 | 164 | 165 | def make_sparse_image(volume_name, output_path): 166 | '''Make a sparse disk image we can install a product to''' 167 | # note: for macOS 26 Tahoe we needed to increase the size 168 | cmd = ['/usr/bin/hdiutil', 'create', '-size', '20g', '-fs', 'HFS+', 169 | '-volname', volume_name, '-type', 'SPARSE', '-plist', output_path] 170 | try: 171 | output = subprocess.check_output(cmd) 172 | except subprocess.CalledProcessError as err: 173 | print(err, file=sys.stderr) 174 | sys.exit(-1) 175 | try: 176 | output = read_plist_from_string(output)[0] 177 | except IndexError: 178 | print('Unexpected output from hdiutil: %s' % output, file=sys.stderr) 179 | sys.exit(-1) 180 | except ExpatError as err: 181 | print('Malformed output from hdiutil: %s' % output, file=sys.stderr) 182 | print(err, file=sys.stderr) 183 | sys.exit(-1) 184 | return output 185 | 186 | 187 | def make_compressed_dmg(app_path, diskimagepath): 188 | """Returns path to newly-created compressed r/o disk image containing 189 | Install macOS.app""" 190 | 191 | print('Making read-only compressed disk image containing %s...' 192 | % os.path.basename(app_path)) 193 | cmd = ['/usr/bin/hdiutil', 'create', '-fs', 'HFS+', 194 | '-srcfolder', app_path, diskimagepath] 195 | try: 196 | subprocess.check_call(cmd) 197 | except subprocess.CalledProcessError as err: 198 | print(err, file=sys.stderr) 199 | else: 200 | print('Disk image created at: %s' % diskimagepath) 201 | 202 | 203 | def mountdmg(dmgpath): 204 | """ 205 | Attempts to mount the dmg at dmgpath and returns first mountpoint 206 | """ 207 | mountpoints = [] 208 | dmgname = os.path.basename(dmgpath) 209 | cmd = ['/usr/bin/hdiutil', 'attach', dmgpath, 210 | '-mountRandom', '/tmp', '-nobrowse', '-plist', 211 | '-owners', 'on'] 212 | proc = subprocess.Popen(cmd, bufsize=-1, 213 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 214 | (pliststr, err) = proc.communicate() 215 | if proc.returncode: 216 | print('Error: "%s" while mounting %s.' % (err, dmgname), 217 | file=sys.stderr) 218 | return None 219 | if pliststr: 220 | plist = read_plist_from_string(pliststr) 221 | for entity in plist['system-entities']: 222 | if 'mount-point' in entity: 223 | mountpoints.append(entity['mount-point']) 224 | 225 | return mountpoints[0] 226 | 227 | 228 | def unmountdmg(mountpoint): 229 | """ 230 | Unmounts the dmg at mountpoint 231 | """ 232 | proc = subprocess.Popen(['/usr/bin/hdiutil', 'detach', mountpoint], 233 | bufsize=-1, stdout=subprocess.PIPE, 234 | stderr=subprocess.PIPE) 235 | (dummy_output, err) = proc.communicate() 236 | if proc.returncode: 237 | print('Polite unmount failed: %s' % err, file=sys.stderr) 238 | print('Attempting to force unmount %s' % mountpoint, file=sys.stderr) 239 | # try forcing the unmount 240 | retcode = subprocess.call(['/usr/bin/hdiutil', 'detach', mountpoint, 241 | '-force']) 242 | if retcode: 243 | print('Failed to unmount %s' % mountpoint, file=sys.stderr) 244 | 245 | 246 | def is_legacy(title): 247 | """Returns a boolean to tell us if this is a pre-Big Sur installer""" 248 | return "Sierra" in title or "Mojave" in title or "Catalina" in title 249 | 250 | 251 | def macOsVersion(only_major_minor=True): 252 | """Returns a macOS version as a tuple of integers 253 | 254 | Args: 255 | only_major_minor: Boolean. If True, only include major/minor versions. 256 | """ 257 | # platform.mac_ver() returns 10.16-style version info on Big Sur 258 | # and is likely to do so until Python is compiled with the macOS 11 SDK 259 | # which may not happen for a while. And Apple's odd tricks mean that even 260 | # reading /System/Library/CoreServices/SystemVersion.plist is unreliable. 261 | # So let's use a different method. 262 | try: 263 | os_version_tuple = subprocess.check_output( 264 | ('/usr/bin/sw_vers', '-productVersion'), 265 | env={'SYSTEM_VERSION_COMPAT': '0'} 266 | ).decode('UTF-8').rstrip().split('.') 267 | except subprocess.CalledProcessError: 268 | # fall back to platform.mac_ver() 269 | os_version_tuple = platform.mac_ver()[0].split(".") 270 | if only_major_minor: 271 | os_version_tuple = os_version_tuple[0:2] 272 | return tuple(map(int, os_version_tuple)) 273 | 274 | 275 | def install_product(dist_path, target_vol): 276 | '''Install a product to a target volume. 277 | Returns a boolean to indicate success or failure.''' 278 | # set CM_BUILD env var to make Installer bypass eligibilty checks 279 | # when installing packages (for machine-specific OS builds) 280 | os.environ["CM_BUILD"] = "CM_BUILD" 281 | # check if running on Sequoia 15.6+ 282 | if macOsVersion() >= (15,6): 283 | # work around a change in macOS 15.6+ since installing a .dist 284 | # file no longer works 285 | # find InstallAssistant.pkg and install that instead 286 | dist_dir = os.path.dirname(dist_path) 287 | install_asst_pkg = os.path.join(dist_dir, "InstallAssistant.pkg") 288 | if not os.path.exists(install_asst_pkg): 289 | print("*** Error: InstallAssistant.pkg not found.", file=sys.stderr) 290 | return False 291 | cmd = ['/usr/sbin/installer', '-pkg', install_asst_pkg, '-target', target_vol] 292 | else: 293 | # pre-macOS 15.6 install method 294 | # (install the .dist, which acts like installing a distribution pkg) 295 | cmd = ['/usr/sbin/installer', '-pkg', dist_path, '-target', target_vol] 296 | 297 | try: 298 | subprocess.check_call(cmd) 299 | except subprocess.CalledProcessError as err: 300 | print(err, file=sys.stderr) 301 | return False 302 | 303 | # Apple postinstall script bug ends up copying files to a path like 304 | # /tmp/dmg.T9ak1HApplications 305 | path = target_vol + 'Applications' 306 | if os.path.exists(path): 307 | print('*********************************************************') 308 | print('*** Working around a very dumb Apple bug in a package ***') 309 | print('*** postinstall script that fails to correctly target ***') 310 | print('*** the Install macOS.app when installed to a volume ***') 311 | print('*** other than the current boot volume. ***') 312 | print('*** Please file feedback with Apple! ***') 313 | print('*********************************************************') 314 | subprocess.check_call( 315 | ['/usr/bin/ditto', 316 | path, 317 | os.path.join(target_vol, 'Applications')] 318 | ) 319 | subprocess.check_call(['/bin/rm', '-r', path]) 320 | return True 321 | 322 | 323 | class ReplicationError(Exception): 324 | '''A custom error when replication fails''' 325 | #pass 326 | 327 | 328 | def replicate_url(full_url, 329 | root_dir='/tmp', 330 | show_progress=False, 331 | ignore_cache=False, 332 | attempt_resume=False): 333 | '''Downloads a URL and stores it in the same relative path on our 334 | filesystem. Returns a path to the replicated file.''' 335 | 336 | path = urlsplit(full_url)[2] 337 | relative_url = path.lstrip('/') 338 | relative_url = os.path.normpath(relative_url) 339 | local_file_path = os.path.join(root_dir, relative_url) 340 | if show_progress: 341 | options = '-fL' 342 | else: 343 | options = '-sfL' 344 | need_download = True 345 | while need_download: 346 | curl_cmd = ['/usr/bin/curl', options, 347 | '--create-dirs', 348 | '-o', local_file_path, 349 | '-w', '%{http_code}'] 350 | if not full_url.endswith(".gz"): 351 | # stupid hack for stupid Apple behavior where it sometimes returns 352 | # compressed files even when not asked for 353 | curl_cmd.append('--compressed') 354 | resumed = False 355 | if not ignore_cache and os.path.exists(local_file_path): 356 | if not attempt_resume: 357 | curl_cmd.extend(['-z', local_file_path]) 358 | else: 359 | resumed = True 360 | curl_cmd.extend(['-z', '-' + local_file_path, '-C', '-']) 361 | curl_cmd.append(full_url) 362 | print("Downloading %s..." % full_url) 363 | need_download = False 364 | try: 365 | _ = subprocess.check_output(curl_cmd) 366 | except subprocess.CalledProcessError as err: 367 | if not resumed or not err.output.isdigit(): 368 | raise ReplicationError(err) 369 | # HTTP error 416 on resume: the download is already complete and the 370 | # file is up-to-date 371 | # HTTP error 412 on resume: the file was updated server-side 372 | if int(err.output) == 412: 373 | print("Removing %s and retrying." % local_file_path) 374 | os.unlink(local_file_path) 375 | need_download = True 376 | elif int(err.output) != 416: 377 | raise ReplicationError(err) 378 | return local_file_path 379 | 380 | 381 | def parse_server_metadata(filename): 382 | '''Parses a softwareupdate server metadata file, looking for information 383 | of interest. 384 | Returns a dictionary containing title, version, and description.''' 385 | title = '' 386 | vers = '' 387 | try: 388 | md_plist = read_plist(filename) 389 | except (OSError, IOError, ExpatError) as err: 390 | print('Error reading %s: %s' % (filename, err), file=sys.stderr) 391 | return {} 392 | vers = md_plist.get('CFBundleShortVersionString', '') 393 | localization = md_plist.get('localization', {}) 394 | preferred_localization = (localization.get('English') or 395 | localization.get('en')) 396 | if preferred_localization: 397 | title = preferred_localization.get('title', '') 398 | 399 | metadata = {} 400 | metadata['title'] = title 401 | metadata['version'] = vers 402 | return metadata 403 | 404 | 405 | def get_server_metadata(catalog, product_key, workdir, ignore_cache=False): 406 | '''Replicate ServerMetaData''' 407 | try: 408 | url = catalog['Products'][product_key]['ServerMetadataURL'] 409 | try: 410 | smd_path = replicate_url( 411 | url, root_dir=workdir, ignore_cache=ignore_cache) 412 | return smd_path 413 | except ReplicationError as err: 414 | print('Could not replicate %s: %s' % (url, err), file=sys.stderr) 415 | return None 416 | except KeyError: 417 | #print('Malformed catalog.', file=sys.stderr) 418 | return None 419 | 420 | 421 | def parse_dist(filename): 422 | '''Parses a softwareupdate dist file, returning a dict of info of 423 | interest''' 424 | dist_info = {} 425 | try: 426 | dom = minidom.parse(filename) 427 | except ExpatError: 428 | print('Invalid XML in %s' % filename, file=sys.stderr) 429 | return dist_info 430 | except IOError as err: 431 | print('Error reading %s: %s' % (filename, err), file=sys.stderr) 432 | return dist_info 433 | 434 | titles = dom.getElementsByTagName('title') 435 | if titles: 436 | dist_info['title_from_dist'] = titles[0].firstChild.wholeText 437 | 438 | auxinfos = dom.getElementsByTagName('auxinfo') 439 | if not auxinfos: 440 | return dist_info 441 | auxinfo = auxinfos[0] 442 | key = None 443 | value = None 444 | children = auxinfo.childNodes 445 | # handle the possibility that keys from auxinfo may be nested 446 | # within a 'dict' element 447 | dict_nodes = [n for n in auxinfo.childNodes 448 | if n.nodeType == n.ELEMENT_NODE and 449 | n.tagName == 'dict'] 450 | if dict_nodes: 451 | children = dict_nodes[0].childNodes 452 | for node in children: 453 | if node.nodeType == node.ELEMENT_NODE and node.tagName == 'key': 454 | key = node.firstChild.wholeText 455 | if node.nodeType == node.ELEMENT_NODE and node.tagName == 'string': 456 | value = node.firstChild.wholeText 457 | if key and value: 458 | dist_info[key] = value 459 | key = None 460 | value = None 461 | return dist_info 462 | 463 | 464 | def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False): 465 | '''Downloads and returns a parsed softwareupdate catalog''' 466 | try: 467 | localcatalogpath = replicate_url( 468 | sucatalog, root_dir=workdir, ignore_cache=ignore_cache) 469 | except ReplicationError as err: 470 | print('Could not replicate %s: %s' % (sucatalog, err), file=sys.stderr) 471 | sys.exit(-1) 472 | if os.path.splitext(localcatalogpath)[1] == '.gz': 473 | with gzip.open(localcatalogpath) as the_file: 474 | content = the_file.read() 475 | try: 476 | catalog = read_plist_from_string(content) 477 | return catalog 478 | except ExpatError as err: 479 | print('Error reading %s: %s' % (localcatalogpath, err), 480 | file=sys.stderr) 481 | sys.exit(-1) 482 | else: 483 | try: 484 | catalog = read_plist(localcatalogpath) 485 | return catalog 486 | except (OSError, IOError, ExpatError) as err: 487 | print('Error reading %s: %s' % (localcatalogpath, err), 488 | file=sys.stderr) 489 | sys.exit(-1) 490 | 491 | 492 | def find_mac_os_installers(catalog): 493 | '''Return a list of product identifiers for what appear to be macOS 494 | installers''' 495 | mac_os_installer_products = [] 496 | if 'Products' in catalog: 497 | for product_key in catalog['Products'].keys(): 498 | product = catalog['Products'][product_key] 499 | try: 500 | if product['ExtendedMetaInfo'][ 501 | 'InstallAssistantPackageIdentifiers']: 502 | mac_os_installer_products.append(product_key) 503 | except KeyError: 504 | continue 505 | return mac_os_installer_products 506 | 507 | 508 | def os_installer_product_info(catalog, workdir, ignore_cache=False): 509 | '''Returns a dict of info about products that look like macOS installers''' 510 | product_info = {} 511 | installer_products = find_mac_os_installers(catalog) 512 | for product_key in installer_products: 513 | product_info[product_key] = {} 514 | filename = get_server_metadata(catalog, product_key, workdir) 515 | if filename: 516 | product_info[product_key] = parse_server_metadata(filename) 517 | else: 518 | print('No server metadata for %s' % product_key) 519 | product_info[product_key]['title'] = None 520 | product_info[product_key]['version'] = None 521 | 522 | product = catalog['Products'][product_key] 523 | product_info[product_key]['PostDate'] = product['PostDate'] 524 | distributions = product['Distributions'] 525 | dist_url = distributions.get('English') or distributions.get('en') 526 | try: 527 | dist_path = replicate_url( 528 | dist_url, root_dir=workdir, ignore_cache=ignore_cache) 529 | except ReplicationError as err: 530 | print('Could not replicate %s: %s' % (dist_url, err), 531 | file=sys.stderr) 532 | else: 533 | dist_info = parse_dist(dist_path) 534 | product_info[product_key]['DistributionPath'] = dist_path 535 | product_info[product_key].update(dist_info) 536 | if not product_info[product_key]['title']: 537 | product_info[product_key]['title'] = dist_info.get('title_from_dist') 538 | if not product_info[product_key]['version']: 539 | product_info[product_key]['version'] = dist_info.get('VERSION') 540 | 541 | return product_info 542 | 543 | 544 | def replicate_product(catalog, product_id, workdir, ignore_cache=False): 545 | '''Downloads all the packages for a product''' 546 | product = catalog['Products'][product_id] 547 | for package in product.get('Packages', []): 548 | # TO-DO: Check 'Size' attribute and make sure 549 | # we have enough space on the target 550 | # filesystem before attempting to download 551 | if 'URL' in package: 552 | try: 553 | replicate_url( 554 | package['URL'], 555 | root_dir=workdir, 556 | show_progress=True, 557 | ignore_cache=ignore_cache, 558 | attempt_resume=not ignore_cache 559 | ) 560 | except ReplicationError as err: 561 | print('Could not replicate %s: %s' % (package['URL'], err), 562 | file=sys.stderr) 563 | sys.exit(-1) 564 | if 'MetadataURL' in package: 565 | try: 566 | replicate_url(package['MetadataURL'], root_dir=workdir, 567 | ignore_cache=ignore_cache) 568 | except ReplicationError as err: 569 | print('Could not replicate %s: %s' 570 | % (package['MetadataURL'], err), file=sys.stderr) 571 | sys.exit(-1) 572 | 573 | 574 | def find_installer_app(mountpoint): 575 | '''Returns the path to the Install macOS app on the mountpoint''' 576 | applications_dir = os.path.join(mountpoint, 'Applications') 577 | for item in os.listdir(applications_dir): 578 | if item.endswith('.app'): 579 | return os.path.join(applications_dir, item) 580 | return None 581 | 582 | 583 | def main(): 584 | '''Do the main thing here''' 585 | parser = argparse.ArgumentParser() 586 | parser.add_argument('--seedprogram', default='', 587 | help='Which Seed Program catalog to use. Valid values ' 588 | 'are %s.' % ', '.join(get_seeding_programs())) 589 | parser.add_argument('--catalogurl', default='', 590 | help='Software Update catalog URL. This option ' 591 | 'overrides any seedprogram option.') 592 | parser.add_argument('--workdir', metavar='path_to_working_dir', 593 | default='.', 594 | help='Path to working directory on a volume with over ' 595 | '10G of available space. Defaults to current working ' 596 | 'directory.') 597 | parser.add_argument('--compress', action='store_true', 598 | help='Output a read-only compressed disk image with ' 599 | 'the Install macOS app at the root. This is now the ' 600 | 'default. Use --raw to get a read-write sparse image ' 601 | 'with the app in the Applications directory.') 602 | parser.add_argument('--raw', action='store_true', 603 | help='Output a read-write sparse image ' 604 | 'with the app in the Applications directory. Requires ' 605 | 'less available disk space and is faster.') 606 | parser.add_argument('--ignore-cache', action='store_true', 607 | help='Ignore any previously cached files.') 608 | args = parser.parse_args() 609 | 610 | if os.getuid() != 0: 611 | sys.exit('This command requires root (to install packages), so please ' 612 | 'run again with sudo or as root.') 613 | 614 | current_dir = os.getcwd() 615 | if os.path.expanduser("~") in current_dir: 616 | bad_dirs = ['Documents', 'Desktop', 'Downloads', 'Library'] 617 | for bad_dir in bad_dirs: 618 | if bad_dir in os.path.split(current_dir): 619 | print('Running this script from %s may not work as expected. ' 620 | 'If this does not run as expected, please run again from ' 621 | 'somewhere else, such as /Users/Shared.' 622 | % current_dir, file=sys.stderr) 623 | 624 | if args.catalogurl: 625 | su_catalog_url = args.catalogurl 626 | elif args.seedprogram: 627 | su_catalog_url = get_seed_catalog(args.seedprogram) 628 | if not su_catalog_url: 629 | print('Could not find a catalog url for seed program %s' 630 | % args.seedprogram, file=sys.stderr) 631 | print('Valid seeding programs are: %s' 632 | % ', '.join(get_seeding_programs()), file=sys.stderr) 633 | sys.exit(-1) 634 | else: 635 | su_catalog_url = get_default_catalog() 636 | if not su_catalog_url: 637 | print('Could not find a default catalog url for this OS version.', 638 | file=sys.stderr) 639 | sys.exit(-1) 640 | 641 | # download sucatalog and look for products that are for macOS installers 642 | catalog = download_and_parse_sucatalog( 643 | su_catalog_url, args.workdir, ignore_cache=args.ignore_cache) 644 | product_info = os_installer_product_info( 645 | catalog, args.workdir, ignore_cache=args.ignore_cache) 646 | 647 | if not product_info: 648 | print('No macOS installer products found in the sucatalog.', 649 | file=sys.stderr) 650 | sys.exit(-1) 651 | 652 | # display a menu of choices (some seed catalogs have multiple installers) 653 | print('%2s %14s %10s %8s %11s %s' 654 | % ('#', 'ProductID', 'Version', 'Build', 'Post Date', 'Title')) 655 | for index, product_id in enumerate(product_info): 656 | print('%2s %14s %10s %8s %11s %s' % ( 657 | index + 1, 658 | product_id, 659 | product_info[product_id].get('version', 'UNKNOWN'), 660 | product_info[product_id].get('BUILD', 'UNKNOWN'), 661 | product_info[product_id]['PostDate'].strftime('%Y-%m-%d'), 662 | product_info[product_id]['title'] 663 | )) 664 | 665 | answer = get_input( 666 | '\nChoose a product to download (1-%s): ' % len(product_info)) 667 | try: 668 | index = int(answer) - 1 669 | if index < 0: 670 | raise ValueError 671 | product_id = list(product_info.keys())[index] 672 | except (ValueError, IndexError): 673 | print('Exiting.') 674 | sys.exit(0) 675 | 676 | if is_legacy(product_info[product_id].get('title','')): 677 | # Catalina and earlier do not have InstallAssistant.pkg, and 678 | # InstallAssistantAuto.pkg (which they do have) do not work for 679 | # installing the Install macOS.app 680 | print( 681 | '*** Error: building High Sierra, Mojave, and Catalina installer images\n' 682 | '*** is unsupported on macOS Sequoia 15.6 and later due to breaking changes\n' 683 | '*** in /usr/sbin/installer by Apple.', file=sys.stderr 684 | ) 685 | sys.exit(1) 686 | 687 | # download all the packages for the selected product 688 | replicate_product( 689 | catalog, product_id, args.workdir, ignore_cache=args.ignore_cache) 690 | 691 | # generate a name for the sparseimage 692 | volname = ('Install_macOS_%s-%s' 693 | % (product_info[product_id]['version'], 694 | product_info[product_id]['BUILD'])) 695 | sparse_diskimage_path = os.path.join(args.workdir, volname + '.sparseimage') 696 | if os.path.exists(sparse_diskimage_path): 697 | os.unlink(sparse_diskimage_path) 698 | 699 | # make an empty sparseimage and mount it 700 | print('Making empty sparseimage...') 701 | sparse_diskimage_path = make_sparse_image(volname, sparse_diskimage_path) 702 | mountpoint = mountdmg(sparse_diskimage_path) 703 | if mountpoint: 704 | # install the product to the mounted sparseimage volume 705 | success = install_product( 706 | product_info[product_id]['DistributionPath'], 707 | mountpoint) 708 | if not success: 709 | print('Product installation failed.', file=sys.stderr) 710 | unmountdmg(mountpoint) 711 | sys.exit(-1) 712 | # add the seeding program xattr to the app if applicable 713 | seeding_program = get_seeding_program(su_catalog_url) 714 | if seeding_program: 715 | installer_app = find_installer_app(mountpoint) 716 | if installer_app: 717 | print("Adding seeding program %s extended attribute to app" 718 | % seeding_program) 719 | xattr.setxattr(installer_app, 'SeedProgram', 720 | seeding_program.encode("UTF-8")) 721 | print('Product downloaded and installed to %s' % sparse_diskimage_path) 722 | if args.raw: 723 | unmountdmg(mountpoint) 724 | else: 725 | # if --raw option not given, create a r/o compressed diskimage 726 | # containing the Install macOS app 727 | compressed_diskimagepath = os.path.join( 728 | args.workdir, volname + '.dmg') 729 | if os.path.exists(compressed_diskimagepath): 730 | os.unlink(compressed_diskimagepath) 731 | app_path = find_installer_app(mountpoint) 732 | if app_path: 733 | make_compressed_dmg(app_path, compressed_diskimagepath) 734 | # unmount sparseimage 735 | unmountdmg(mountpoint) 736 | # delete sparseimage since we don't need it any longer 737 | os.unlink(sparse_diskimage_path) 738 | 739 | 740 | if __name__ == '__main__': 741 | main() 742 | --------------------------------------------------------------------------------