macadmin-scripts/installinstallmacos.py

1424 lines
51 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python
2017-11-09 14:12:03 -08:00
# encoding: utf-8
#
2022-02-01 14:13:28 -08:00
# Copyright 2017-2022 Greg Neagle.
2017-11-09 14:12:03 -08:00
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Thanks to Tim Sutton for ideas, suggestions, and sample code.
#
"""installinstallmacos.py
2017-11-09 14:12:03 -08:00
A tool to download the parts for an Install macOS app from Apple's
softwareupdate servers and install a functioning Install macOS app onto an
empty disk image"""
2017-11-09 14:12:03 -08:00
# Python 3 compatibility shims
from __future__ import absolute_import, division, print_function, unicode_literals
2017-11-09 14:12:03 -08:00
import argparse
import gzip
2017-11-09 14:12:03 -08:00
import os
import plistlib
import shutil
2017-11-09 14:12:03 -08:00
import subprocess
import sys
try:
# python 2
from urllib.parse import urlsplit
except ImportError:
# python 3
from urlparse import urlsplit
2017-11-09 14:12:03 -08:00
from xml.dom import minidom
from xml.parsers.expat import ExpatError
2023-01-24 21:20:34 +01:00
try:
from packaging.version import LegacyVersion
except ImportError:
# python <=3.9
2023-01-24 21:33:44 +01:00
from distutils.version import LooseVersion as LegacyVersion
2017-11-09 14:12:03 -08:00
try:
import xattr
except ImportError:
print(
2022-03-09 19:25:36 +01:00
"This tool requires the Python xattr module. "
"Perhaps run `pip install xattr` to install it."
)
sys.exit(-1)
2017-11-09 14:12:03 -08:00
DEFAULT_SUCATALOGS = {
2022-02-03 07:43:44 +01:00
"17": (
"https://swscan.apple.com/content/catalogs/others/"
"index-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog"
),
"18": (
"https://swscan.apple.com/content/catalogs/others/"
"index-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog"
),
"19": (
"https://swscan.apple.com/content/catalogs/others/"
"index-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog"
),
"20": (
"https://swscan.apple.com/content/catalogs/others/"
"index-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog"
),
"21": (
"https://swscan.apple.com/content/catalogs/others/"
"index-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog"
),
2022-10-24 23:49:53 +01:00
"22": (
"https://swscan.apple.com/content/catalogs/others/"
"index-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog"
),
2023-10-01 20:30:28 +10:00
"23": (
"https://swscan.apple.com/content/catalogs/others/"
"index-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog"
),
2024-11-18 15:10:56 +09:00
"24": (
"https://swscan.apple.com/content/catalogs/others/"
"index-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog"
),
2025-06-11 20:11:14 +02:00
"25": (
"https://swscan.apple.com/content/catalogs/others/"
"index-16seed-16-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog"
),
"22": "https://swscan.apple.com/content/catalogs/others/"
"index-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog",
"23": "https://swscan.apple.com/content/catalogs/others/"
"index-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog",
"24": "https://swscan.apple.com/content/catalogs/others/"
"index-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog",
"25": "https://swscan.apple.com/content/catalogs/others/"
"index-26-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9"
"-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog",
}
SEED_CATALOGS_PLIST = (
"/System/Library/PrivateFrameworks/Seeding.framework/Versions/Current/"
"Resources/SeedCatalogs.plist"
)
2020-12-15 00:06:09 +01:00
def get_device_id():
"""Gets the local device ID on Apple Silicon Macs or the board_id of older Macs"""
ioreg_cmd = ["/usr/sbin/ioreg", "-c", "IOPlatformExpertDevice", "-d", "2"]
try:
ioreg_output = subprocess.check_output(ioreg_cmd).splitlines()
2020-12-15 00:06:09 +01:00
board_id = ""
device_id = ""
for line in ioreg_output:
line_decoded = line.decode("utf8")
if "board-id" in line_decoded:
2020-05-06 17:15:41 +02:00
board_id = line_decoded.split("<")[-1]
board_id = board_id[
board_id.find('<"') + 2 : board_id.find('">') # noqa: E203
]
2020-12-15 00:06:09 +01:00
elif "compatible" in line_decoded:
device_details = line_decoded.split("<")[-1]
device_details = device_details[
device_details.find("<")
+ 2 : device_details.find(">") # noqa: E203
]
device_id = (
device_details.replace('","', ";").replace('"', "").split(";")[0]
)
if board_id:
device_id = ""
return board_id, device_id
2020-05-06 17:15:41 +02:00
except subprocess.CalledProcessError as err:
raise ReplicationError(err)
def is_a_vm():
"""Determines if the script is being run in a virtual machine"""
sysctl_cmd = ["/usr/sbin/sysctl", "-n", "machdep.cpu.features"]
try:
sysctl_output = subprocess.check_output(sysctl_cmd)
cpu_features = sysctl_output.decode("utf8").split(" ")
is_vm = False
for i in range(len(cpu_features)):
if cpu_features[i] == "VMM":
is_vm = True
2020-05-06 17:15:41 +02:00
except subprocess.CalledProcessError as err:
raise ReplicationError(err)
return is_vm
def get_hw_model():
"""Gets the local system ModelIdentifier"""
sysctl_cmd = ["/usr/sbin/sysctl", "-n", "hw.model"]
try:
sysctl_output = subprocess.check_output(sysctl_cmd)
hw_model = sysctl_output.decode("utf8")
2020-05-06 17:15:41 +02:00
except subprocess.CalledProcessError as err:
raise ReplicationError(err)
return hw_model
def get_bridge_id():
"""Gets the local system DeviceID for T2 Macs - note only works on 10.13+"""
if os.path.exists("/usr/libexec/remotectl"):
remotectl_cmd = [
"/usr/libexec/remotectl",
"get-property",
"localbridge",
"HWModel",
]
try:
remotectl_output = subprocess.check_output(
remotectl_cmd, stderr=subprocess.STDOUT
)
bridge_id = remotectl_output.decode("utf8").split(" ")[-1].split("\n")[0]
2020-09-18 21:51:29 +01:00
except subprocess.CalledProcessError:
return None
return bridge_id
def get_current_build_info():
"""Gets the local system build"""
build_info = []
sw_vers_cmd = ["/usr/bin/sw_vers"]
2018-09-23 14:17:45 +02:00
try:
sw_vers_output = subprocess.check_output(sw_vers_cmd).splitlines()
for line in sw_vers_output:
line_decoded = line.decode("utf8")
if "ProductVersion" in line_decoded:
2020-05-06 17:15:41 +02:00
build_info.insert(0, line_decoded.split("\t")[-1])
if "BuildVersion" in line_decoded:
2020-05-06 17:15:41 +02:00
build_info.insert(1, line_decoded.split("\t")[-1])
except subprocess.CalledProcessError as err:
2018-09-23 14:17:45 +02:00
raise ReplicationError(err)
return build_info
def get_input(prompt=None):
"""Python 2 and 3 wrapper for raw_input/input"""
try:
return raw_input(prompt)
except NameError:
# raw_input doesn't exist in Python 3
return input(prompt)
def read_plist(filepath):
"""Wrapper for the differences between Python 2 and Python 3's plistlib"""
try:
with open(filepath, "rb") as fileobj:
return plistlib.load(fileobj)
except AttributeError:
# plistlib module doesn't have a load function (as in Python 2)
return plistlib.readPlist(filepath)
def read_plist_from_string(bytestring):
"""Wrapper for the differences between Python 2 and Python 3's plistlib"""
try:
return plistlib.loads(bytestring)
except AttributeError:
# plistlib module doesn't have a load function (as in Python 2)
2020-07-31 13:45:05 +02:00
return plistlib.readPlistFromString(bytestring) # pylint: disable=no-member
2020-05-06 17:15:41 +02:00
def write_plist(plist_object, filepath):
"""Wrapper for the differences between Python 2 and Python 3's plistlib"""
2020-05-06 17:15:41 +02:00
try:
with open(filepath, "wb") as fileobj:
return plistlib.dump(plist_object, fileobj)
except AttributeError:
# plistlib module doesn't have a load function (as in Python 2)
return plistlib.writePlist(plist_object, filepath)
2018-09-23 16:02:28 +02:00
def get_seeding_program(sucatalog_url):
"""Returns a seeding program name based on the sucatalog_url"""
2018-09-23 16:02:28 +02:00
try:
seed_catalogs = read_plist(SEED_CATALOGS_PLIST)
2018-09-23 16:02:28 +02:00
for key, value in seed_catalogs.items():
if sucatalog_url == value:
return key
return ""
except (OSError, IOError, ExpatError, AttributeError, KeyError) as err:
print(err, file=sys.stderr)
return ""
2018-09-23 16:02:28 +02:00
def get_seed_catalog(seedname="DeveloperSeed"):
"""Returns the developer seed sucatalog"""
try:
seed_catalogs = read_plist(SEED_CATALOGS_PLIST)
return seed_catalogs.get(seedname)
except (OSError, IOError, ExpatError, AttributeError, KeyError) as err:
print(err, file=sys.stderr)
return ""
def get_seeding_programs():
"""Returns the list of seeding program names"""
try:
seed_catalogs = read_plist(SEED_CATALOGS_PLIST)
return list(seed_catalogs.keys())
except (OSError, IOError, ExpatError, AttributeError, KeyError) as err:
print(err, file=sys.stderr)
return ""
2022-02-03 07:43:44 +01:00
def get_default_catalog(darwin_major=None):
"""Returns the default softwareupdate catalog for the current OS"""
2022-02-03 07:43:44 +01:00
if not darwin_major:
darwin_major = os.uname()[2].split(".")[0]
return DEFAULT_SUCATALOGS.get(darwin_major)
2017-11-09 14:12:03 -08:00
def make_sparse_image(volume_name, output_path):
"""Make a sparse disk image we can install a product to"""
2025-06-24 09:52:55 -07:00
# note: for macOS 26 Tahoe we needed to increase the size
cmd = [
"/usr/bin/hdiutil",
"create",
"-size",
"20g",
"-fs",
"HFS+",
"-volname",
volume_name,
"-type",
"SPARSE",
"-plist",
output_path,
]
2017-11-09 14:12:03 -08:00
try:
output = subprocess.check_output(cmd)
except subprocess.CalledProcessError as err:
print(err, file=sys.stderr)
2017-11-09 14:12:03 -08:00
exit(-1)
try:
return read_plist_from_string(output)[0]
except IndexError:
print("Unexpected output from hdiutil: %s" % output, file=sys.stderr)
2017-11-09 14:12:03 -08:00
exit(-1)
except ExpatError as err:
print("Malformed output from hdiutil: %s" % output, file=sys.stderr)
print(err, file=sys.stderr)
2017-11-09 14:12:03 -08:00
exit(-1)
def make_compressed_dmg(app_path, diskimagepath, volume_name):
2017-11-09 14:12:03 -08:00
"""Returns path to newly-created compressed r/o disk image containing
Install macOS.app"""
print(
"Making read-only compressed disk image containing %s..."
% os.path.basename(app_path)
)
cmd = [
"/usr/bin/hdiutil",
"create",
"-fs",
"HFS+",
"-srcfolder",
app_path,
diskimagepath,
]
2017-11-09 14:12:03 -08:00
try:
subprocess.check_call(cmd)
except subprocess.CalledProcessError as err:
print(err, file=sys.stderr)
2017-11-09 14:12:03 -08:00
else:
print("Disk image created at: %s" % diskimagepath)
2017-11-09 14:12:03 -08:00
def mountdmg(dmgpath):
"""
Attempts to mount the dmg at dmgpath and returns first mountpoint
"""
mountpoints = []
dmgname = os.path.basename(dmgpath)
cmd = [
"/usr/bin/hdiutil",
"attach",
dmgpath,
"-mountRandom",
"/tmp",
"-nobrowse",
"-plist",
"-owners",
"on",
]
proc = subprocess.Popen(
cmd, bufsize=-1, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
2017-11-09 14:12:03 -08:00
(pliststr, err) = proc.communicate()
if proc.returncode:
print('Error: "%s" while mounting %s.' % (err, dmgname), file=sys.stderr)
2017-11-09 14:12:03 -08:00
return None
if pliststr:
plist = read_plist_from_string(pliststr)
for entity in plist["system-entities"]:
if "mount-point" in entity:
mountpoints.append(entity["mount-point"])
2017-11-09 14:12:03 -08:00
return mountpoints[0]
def unmountdmg(mountpoint):
"""
Unmounts the dmg at mountpoint
"""
proc = subprocess.Popen(
["/usr/bin/hdiutil", "detach", mountpoint],
bufsize=-1,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
2017-11-09 14:12:03 -08:00
(dummy_output, err) = proc.communicate()
if proc.returncode:
print("Polite unmount failed: %s" % err, file=sys.stderr)
print("Attempting to force unmount %s" % mountpoint, file=sys.stderr)
2017-11-09 14:12:03 -08:00
# try forcing the unmount
retcode = subprocess.call(["/usr/bin/hdiutil", "detach", mountpoint, "-force"])
2017-11-09 14:12:03 -08:00
if retcode:
print("Failed to unmount %s" % mountpoint, file=sys.stderr)
2017-11-09 14:12:03 -08:00
def install_product(dist_path, target_vol):
"""Install a product to a target volume.
Returns a boolean to indicate success or failure."""
# set CM_BUILD env var to make Installer bypass eligibilty checks
# when installing packages (for machine-specific OS builds)
os.environ["CM_BUILD"] = "CM_BUILD"
# cmd = ['/usr/sbin/installer', '-pkg', dist_path, '-target', target_vol]
# a hack to work around a change in macOS 15.6+ since installing a .dist
# file no longer works
dist_dir = os.path.dirname(dist_path)
install_asst_pkg = os.path.join(dist_dir, "InstallAssistant.pkg")
cmd = ["/usr/sbin/installer", "-pkg", install_asst_pkg, "-target", target_vol]
2017-11-09 14:12:03 -08:00
try:
subprocess.check_call(cmd)
except subprocess.CalledProcessError as err:
print(err, file=sys.stderr)
return False
else:
# Apple postinstall script bug ends up copying files to a path like
# /tmp/dmg.T9ak1HApplications
path = target_vol + "Applications"
if os.path.exists(path):
print("*********************************************************")
print("*** Working around a very dumb Apple bug in a package ***")
print("*** postinstall script that fails to correctly target ***")
print("*** the Install macOS.app when installed to a volume ***")
print("*** other than the current boot volume. ***")
print("*** Please file feedback with Apple! ***")
print("*********************************************************")
subprocess.check_call(
["/usr/bin/ditto", path, os.path.join(target_vol, "Applications")]
)
subprocess.check_call(["/bin/rm", "-r", path])
return True
2017-11-09 14:12:03 -08:00
2017-11-09 14:12:03 -08:00
class ReplicationError(Exception):
"""A custom error when replication fails"""
2017-11-09 14:12:03 -08:00
pass
def replicate_url(
full_url,
root_dir="/tmp",
show_progress=False,
ignore_cache=False,
attempt_resume=False,
):
"""Downloads a URL and stores it in the same relative path on our
filesystem. Returns a path to the replicated file."""
2017-11-09 14:12:03 -08:00
path = urlsplit(full_url)[2]
relative_url = path.lstrip("/")
2017-11-09 14:12:03 -08:00
relative_url = os.path.normpath(relative_url)
local_file_path = os.path.join(root_dir, relative_url)
if show_progress:
options = "-fL"
2017-11-09 14:12:03 -08:00
else:
2020-11-06 16:05:42 +01:00
options = "-sfL"
need_download = True
while need_download:
curl_cmd = [
"/usr/bin/curl",
options,
"--create-dirs",
"-o",
local_file_path,
"-w",
"%{http_code}",
]
if not full_url.endswith(".gz"):
# stupid hack for stupid Apple behavior where it sometimes returns
# compressed files even when not asked for
curl_cmd.append("--compressed")
resumed = False
if not ignore_cache and os.path.exists(local_file_path):
if not attempt_resume:
curl_cmd.extend(["-z", local_file_path])
else:
resumed = True
curl_cmd.extend(["-z", "-" + local_file_path, "-C", "-"])
curl_cmd.append(full_url)
# print("Downloading %s..." % full_url)
need_download = False
try:
_ = subprocess.check_output(curl_cmd)
except subprocess.CalledProcessError as err:
if not resumed or not err.output.isdigit():
raise ReplicationError(err)
# HTTP error 416 on resume: the download is already complete and the
# file is up-to-date
# HTTP error 412 on resume: the file was updated server-side
if int(err.output) == 412:
print("Removing %s and retrying." % local_file_path)
os.unlink(local_file_path)
need_download = True
elif int(err.output) != 416:
raise ReplicationError(err)
2017-11-09 14:12:03 -08:00
return local_file_path
def parse_server_metadata(filename):
"""Parses a softwareupdate server metadata file, looking for information
2017-11-09 14:12:03 -08:00
of interest.
Returns a dictionary containing title, version, and description."""
title = ""
vers = ""
2017-11-09 14:12:03 -08:00
try:
md_plist = read_plist(filename)
except (OSError, IOError, ExpatError) as err:
print("Error reading %s: %s" % (filename, err), file=sys.stderr)
2017-11-09 14:12:03 -08:00
return {}
vers = md_plist.get("CFBundleShortVersionString", "")
localization = md_plist.get("localization", {})
preferred_localization = localization.get("English") or localization.get("en")
2017-11-09 14:12:03 -08:00
if preferred_localization:
title = preferred_localization.get("title", "")
2017-11-09 14:12:03 -08:00
metadata = {}
metadata["title"] = title
metadata["version"] = vers
2017-11-09 14:12:03 -08:00
return metadata
def get_server_metadata(catalog, product_key, workdir, ignore_cache=False):
"""Replicate ServerMetaData"""
2017-11-09 14:12:03 -08:00
try:
url = catalog["Products"][product_key]["ServerMetadataURL"]
2017-11-09 14:12:03 -08:00
try:
smd_path = replicate_url(url, root_dir=workdir, ignore_cache=ignore_cache)
2017-11-09 14:12:03 -08:00
return smd_path
except ReplicationError as err:
print("Could not replicate %s: %s" % (url, err), file=sys.stderr)
2017-11-09 14:12:03 -08:00
return None
except KeyError:
# print("No metadata for %s.\n" % product_key, file=sys.stderr)
2017-11-09 14:12:03 -08:00
return None
def parse_dist(filename):
"""Parses a softwareupdate dist file, returning a dict of info of
interest"""
2017-11-09 14:12:03 -08:00
dist_info = {}
try:
dom = minidom.parse(filename)
except ExpatError:
print("Invalid XML in %s" % filename, file=sys.stderr)
2017-11-09 14:12:03 -08:00
return dist_info
except IOError as err:
print("Error reading %s: %s" % (filename, err), file=sys.stderr)
2017-11-09 14:12:03 -08:00
return dist_info
titles = dom.getElementsByTagName("title")
if titles:
dist_info["title_from_dist"] = titles[0].firstChild.wholeText
auxinfos = dom.getElementsByTagName("auxinfo")
2017-11-09 14:12:03 -08:00
if not auxinfos:
return dist_info
auxinfo = auxinfos[0]
key = None
value = None
children = auxinfo.childNodes
# handle the possibility that keys from auxinfo may be nested
# within a 'dict' element
dict_nodes = [
n
for n in auxinfo.childNodes
if n.nodeType == n.ELEMENT_NODE and n.tagName == "dict"
]
if dict_nodes:
children = dict_nodes[0].childNodes
for node in children:
if node.nodeType == node.ELEMENT_NODE and node.tagName == "key":
2017-11-09 14:12:03 -08:00
key = node.firstChild.wholeText
if node.nodeType == node.ELEMENT_NODE and node.tagName == "string":
2017-11-09 14:12:03 -08:00
value = node.firstChild.wholeText
if key and value:
dist_info[key] = value
key = None
value = None
return dist_info
def get_board_ids(filename):
"""Parses a softwareupdate dist file, returning a list of supported
Board IDs"""
supported_board_ids = ""
with open(filename) as search:
for line in search:
2020-11-06 16:05:42 +01:00
if type(line) == bytes:
line = line.decode("utf8")
line = line.rstrip() # remove '\n' at end of line
# dist files for macOS 10.* list boardIDs whereas dist files for
# macOS 11.* list supportedBoardIDs
if "boardIds" in line:
2020-12-15 00:06:09 +01:00
supported_board_ids = line.replace("var boardIDs = ", "")
elif "supportedBoardIDs" in line:
2020-12-15 00:06:09 +01:00
supported_board_ids = line.replace("var supportedBoardIDs = ", "")
return supported_board_ids
def get_device_ids(filename):
"""Parses a softwareupdate dist file, returning a list of supported
2020-12-15 00:06:09 +01:00
Device IDs. These are used for identifying T2 and Apple Silicon chips in the dist files of
macOS 11.* - not checked in older builds"""
supported_device_ids = ""
with open(filename) as search:
for line in search:
2020-11-06 16:05:42 +01:00
if type(line) == bytes:
line = line.decode("utf8")
line = line.rstrip() # remove '\n' at end of line
if "supportedDeviceIDs" in line:
2020-12-15 00:06:09 +01:00
supported_device_ids = line.replace("var supportedDeviceIDs = ", "")
return supported_device_ids
def get_unsupported_models(filename):
"""Parses a softwareupdate dist file, returning a list of non-supported
ModelIdentifiers. This is not used in macOS 11.*"""
unsupported_models = ""
with open(filename) as search:
for line in search:
2020-11-06 16:05:42 +01:00
if type(line) == bytes:
line = line.decode("utf8")
line = line.rstrip() # remove '\n' at end of line
if "nonSupportedModels" in line:
unsupported_models = line.lstrip("var nonSupportedModels = ")
return unsupported_models
2017-11-09 14:12:03 -08:00
def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False):
"""Downloads and returns a parsed softwareupdate catalog"""
2017-11-09 14:12:03 -08:00
try:
localcatalogpath = replicate_url(
sucatalog, root_dir=workdir, ignore_cache=ignore_cache
)
except ReplicationError as err:
print("Could not replicate %s: %s" % (sucatalog, err), file=sys.stderr)
2017-11-09 14:12:03 -08:00
exit(-1)
if os.path.splitext(localcatalogpath)[1] == ".gz":
with gzip.open(localcatalogpath) as the_file:
content = the_file.read()
try:
catalog = read_plist_from_string(content)
return catalog
except ExpatError as err:
print("Error reading %s: %s" % (localcatalogpath, err), file=sys.stderr)
exit(-1)
else:
try:
catalog = read_plist(localcatalogpath)
return catalog
except (OSError, IOError, ExpatError) as err:
print("Error reading %s: %s" % (localcatalogpath, err), file=sys.stderr)
exit(-1)
2017-11-09 14:12:03 -08:00
2020-11-18 23:23:23 +01:00
def find_mac_os_installers(catalog, installassistant_pkg_only=False):
"""Return a list of product identifiers for what appear to be macOS
installers"""
2017-11-09 14:12:03 -08:00
mac_os_installer_products = []
if "Products" in catalog:
for product_key in catalog["Products"].keys():
product = catalog["Products"][product_key]
2020-11-18 23:23:23 +01:00
# account for args.pkg
if installassistant_pkg_only:
try:
if product["ExtendedMetaInfo"][
"InstallAssistantPackageIdentifiers"
]["SharedSupport"]:
mac_os_installer_products.append(product_key)
2020-11-18 23:23:23 +01:00
except KeyError:
continue
else:
try:
if product["ExtendedMetaInfo"][
"InstallAssistantPackageIdentifiers"
]:
2020-11-18 23:23:23 +01:00
mac_os_installer_products.append(product_key)
except KeyError:
continue
2017-11-09 14:12:03 -08:00
return mac_os_installer_products
def os_installer_product_info(
catalog, workdir, installassistant_pkg_only, ignore_cache=False
):
"""Returns a dict of info about products that look like macOS installers"""
2017-11-09 14:12:03 -08:00
product_info = {}
2020-11-18 23:23:23 +01:00
installer_products = find_mac_os_installers(catalog, installassistant_pkg_only)
2017-11-09 14:12:03 -08:00
for product_key in installer_products:
product_info[product_key] = {}
# get the localized title (e.g. "macOS Catalina Beta") and version
# from ServerMetadataURL for macOS 10.*
# For macOS 11.* we get these directly from the dist file
2017-11-09 14:12:03 -08:00
filename = get_server_metadata(catalog, product_key, workdir)
if filename:
product_info[product_key] = parse_server_metadata(filename)
else:
product_info[product_key]["title"] = None
product_info[product_key]["version"] = None
product = catalog["Products"][product_key]
product_info[product_key]["PostDate"] = product["PostDate"]
distributions = product["Distributions"]
dist_url = distributions.get("English") or distributions.get("en")
2017-11-09 14:12:03 -08:00
try:
dist_path = replicate_url(
dist_url, root_dir=workdir, ignore_cache=ignore_cache
)
except ReplicationError as err:
print("Could not replicate %s: %s" % (dist_url, err), file=sys.stderr)
else:
dist_info = parse_dist(dist_path)
product_info[product_key]["DistributionPath"] = dist_path
unsupported_models = get_unsupported_models(dist_path)
product_info[product_key]["UnsupportedModels"] = unsupported_models
board_ids = get_board_ids(dist_path)
product_info[product_key]["BoardIDs"] = board_ids
device_ids = get_device_ids(dist_path)
product_info[product_key]["DeviceIDs"] = device_ids
product_info[product_key].update(dist_info)
if not product_info[product_key]["title"]:
product_info[product_key]["title"] = dist_info.get("title_from_dist")
if not product_info[product_key]["version"]:
product_info[product_key]["version"] = dist_info.get("VERSION")
2017-11-09 14:12:03 -08:00
return product_info
2019-07-12 18:42:44 +02:00
def get_latest_version(current_item, latest_item):
"""Compares versions between two values and returns the latest (highest) value"""
2022-02-03 07:43:44 +01:00
try:
2023-01-24 20:24:15 +01:00
if LegacyVersion(current_item) > LegacyVersion(latest_item):
2022-02-03 07:43:44 +01:00
return current_item
else:
return latest_item
except TypeError:
return current_item
2017-11-09 14:12:03 -08:00
def replicate_product(catalog, product_id, workdir, ignore_cache=False):
"""Downloads all the packages for a product"""
product = catalog["Products"][product_id]
for package in product.get("Packages", []):
2017-11-09 14:12:03 -08:00
# TO-DO: Check 'Size' attribute and make sure
# we have enough space on the target
# filesystem before attempting to download
if "URL" in package:
2017-11-09 14:12:03 -08:00
try:
replicate_url(
package["URL"],
root_dir=workdir,
show_progress=True,
ignore_cache=ignore_cache,
attempt_resume=(not ignore_cache),
)
except ReplicationError as err:
print(
"Could not replicate %s: %s" % (package["URL"], err),
file=sys.stderr,
)
2017-11-09 14:12:03 -08:00
exit(-1)
if "MetadataURL" in package:
2017-11-09 14:12:03 -08:00
try:
replicate_url(
package["MetadataURL"], root_dir=workdir, ignore_cache=ignore_cache
)
except ReplicationError as err:
print(
"Could not replicate %s: %s" % (package["MetadataURL"], err),
file=sys.stderr,
)
2017-11-09 14:12:03 -08:00
exit(-1)
def find_installer_app(mountpoint):
"""Returns the path to the Install macOS app on the mountpoint"""
applications_dir = os.path.join(mountpoint, "Applications")
for item in os.listdir(applications_dir):
if item.endswith(".app"):
return os.path.join(applications_dir, item)
return None
2017-11-09 14:12:03 -08:00
def main():
"""Do the main thing here"""
print(
"\n"
"installinstallmacos.py - get macOS installers "
"from the Apple software catalog"
"\n"
)
2017-11-09 14:12:03 -08:00
parser = argparse.ArgumentParser()
parser.add_argument(
"--seedprogram",
default="",
help="Which Seed Program catalog to use. Valid values "
"are %s." % ", ".join(get_seeding_programs()),
)
parser.add_argument(
"--catalogurl",
default="",
help="Software Update catalog URL. This option "
"overrides any seedprogram option.",
)
2022-02-03 07:43:44 +01:00
parser.add_argument(
"--catalog",
default="",
help="Software Update catalog for a specific macOS version. "
"This option overrides any seedprogram option.",
)
parser.add_argument(
"--workdir",
metavar="path_to_working_dir",
default=".",
help="Path to working directory on a volume with over "
"10G of available space. Defaults to current working "
"directory.",
)
parser.add_argument(
"--clear",
action="store_true",
help="Clear the working directory to ensure a new download.",
)
parser.add_argument(
"--compress",
action="store_true",
help="Output a read-only compressed disk image with "
"the Install macOS app at the root. This is now the "
"default. Use --raw to get a read-write sparse image "
"with the app in the Applications directory.",
)
parser.add_argument(
"--raw",
action="store_true",
help="Output a read-write sparse image "
"with the app in the Applications directory. Requires "
"less available disk space and is faster.",
)
parser.add_argument(
"--ignore-cache",
action="store_true",
help="Ignore any previously cached files.",
)
parser.add_argument(
"--build",
metavar="build_version",
default="",
help="Specify a specific build to search for and " "download.",
)
parser.add_argument(
"--list",
action="store_true",
help="Output the available updates to a plist " "and quit.",
)
parser.add_argument(
"--current",
action="store_true",
help="Automatically select the current installed " "build.",
)
parser.add_argument(
"--renew",
action="store_true",
help="Automatically select the appropriate valid build "
"for the current device, limited to versions newer "
"than the current installed build.",
)
parser.add_argument(
"--newer_than_version",
metavar="newer_than_version",
default="",
help="Specify a minimum version to check for newer " "versions to download.",
)
parser.add_argument(
"--validate",
action="store_true",
help="Validate builds for board ID and hardware model "
"and only show appropriate builds.",
)
parser.add_argument(
"--auto",
action="store_true",
help="Automatically select the appropriate valid build "
"for the current device.",
)
parser.add_argument(
2021-10-02 00:31:08 +02:00
"--warnings",
action="store_true",
help="Show warnings in the listed output",
)
parser.add_argument(
"--beta",
action="store_true",
help="Include beta versions in the selection "
"(in conjunction with --auto, --os or --version)",
)
parser.add_argument(
"--version",
metavar="match_version",
default="",
help="Selects the latest valid build ID matching "
"the selected version (e.g. 10.14.3).",
)
parser.add_argument(
"--os",
metavar="match_os",
default="",
help="Selects the latest valid build ID matching "
"the selected OS version (e.g. 10.14).",
)
2020-11-18 23:23:23 +01:00
parser.add_argument(
"--pkg",
action="store_true",
help="Search only for InstallAssistant packages (macOS Big Sur only)",
)
2017-11-09 14:12:03 -08:00
args = parser.parse_args()
# show this Mac's info
hw_model = get_hw_model()
2020-12-15 00:06:09 +01:00
board_id, device_id = get_device_id()
bridge_id = get_bridge_id()
build_info = get_current_build_info()
is_vm = is_a_vm()
print("This Mac:")
if is_vm is True:
print("Identified as a Virtual Machine")
2020-12-15 00:06:09 +01:00
if hw_model:
print("%-17s: %s" % ("Model Identifier", hw_model))
if bridge_id:
print("%-17s: %s" % ("Bridge ID", bridge_id))
if board_id:
print("%-17s: %s" % ("Board ID", board_id))
if device_id:
print("%-17s: %s" % ("Device ID", device_id))
print("%-17s: %s" % ("OS Version", build_info[0]))
print("%-17s: %s\n" % ("Build ID", build_info[1]))
if os.getuid() != 0:
sys.exit(
"This command requires root (to install packages), so please "
"run again with sudo or as root."
)
current_dir = os.getcwd()
if os.path.expanduser("~") in current_dir:
2020-09-18 21:51:29 +01:00
bad_dirs = ["Documents", "Desktop", "Downloads", "Library"]
for bad_dir in bad_dirs:
2020-09-18 21:51:29 +01:00
if bad_dir in os.path.split(current_dir):
2020-11-06 16:05:42 +01:00
print(
"Running this script from %s may not work as expected. "
"If this does not run as expected, please run again from "
"somewhere else, such as /Users/Shared." % current_dir,
file=sys.stderr,
)
if args.catalogurl:
su_catalog_url = args.catalogurl
elif args.seedprogram:
su_catalog_url = get_seed_catalog(args.seedprogram)
2022-02-03 07:43:44 +01:00
if su_catalog_url:
print("Using catalog for Seed Program {}.\n".format(args.seedprogram))
else:
print(
"Could not find a catalog url for seed program %s" % args.seedprogram,
file=sys.stderr,
)
print(
"Valid seeding programs are: %s" % ", ".join(get_seeding_programs()),
file=sys.stderr,
)
exit(-1)
2022-02-03 07:43:44 +01:00
elif args.catalog:
su_catalog_url = get_default_catalog(args.catalog)
if su_catalog_url:
print("Using catalog for Darwin v{}.\n".format(args.catalog))
else:
print(
"Could not find a default catalog url for this OS version.",
file=sys.stderr,
)
exit(-1)
else:
su_catalog_url = get_default_catalog()
if not su_catalog_url:
print(
"Could not find a default catalog url for this OS version.",
file=sys.stderr,
)
exit(-1)
2017-11-09 14:12:03 -08:00
# download sucatalog and look for products that are for macOS installers
catalog = download_and_parse_sucatalog(
su_catalog_url, args.workdir, ignore_cache=args.ignore_cache
)
2017-11-09 14:12:03 -08:00
product_info = os_installer_product_info(
2020-11-18 23:23:23 +01:00
catalog, args.workdir, args.pkg, ignore_cache=args.ignore_cache
)
2017-11-09 14:12:03 -08:00
if not product_info:
print("No macOS installer products found in the sucatalog.", file=sys.stderr)
2017-11-09 14:12:03 -08:00
exit(-1)
output_plist = "%s/softwareupdate.plist" % args.workdir
pl = {}
pl["result"] = []
valid_build_found = False
2017-11-09 14:12:03 -08:00
# display a menu of choices (some seed catalogs have multiple installers)
validity_header = ""
if args.warnings:
validity_header = "Notes/Warnings"
print(
"%2s %-15s %-10s %-8s %-11s %-30s %s"
% ("#", "ProductID", "Version", "Build", "Post Date", "Title", validity_header)
)
# this is where we do checks for validity based on model type and version
2017-11-09 14:12:03 -08:00
for index, product_id in enumerate(product_info):
not_valid = ""
if is_vm is False:
# first look for a BoardID (not present in modern hardware)
if board_id and product_info[product_id]["BoardIDs"]:
if board_id not in product_info[product_id]["BoardIDs"]:
not_valid = "Unsupported Board ID"
2020-12-15 00:06:09 +01:00
# if there's no Board ID then try BridgeID (T2):
elif bridge_id and product_info[product_id]["DeviceIDs"]:
if bridge_id not in product_info[product_id]["DeviceIDs"]:
not_valid = "Unsupported Bridge ID"
2020-12-15 00:06:09 +01:00
# if there's no BridgeID try DeviceID (Apple Silicon):
elif device_id and product_info[product_id]["DeviceIDs"]:
if device_id not in product_info[product_id]["DeviceIDs"]:
not_valid = "Unsupported Device ID"
# finally we fall back on ModelIdentifiers for T1 and older
elif hw_model and product_info[product_id]["UnsupportedModels"]:
if hw_model in product_info[product_id]["UnsupportedModels"]:
not_valid = "Unsupported Model Identifier"
# if we don't have any of those matches, then we can't do a comparison
else:
not_valid = "No supported model data"
if (
get_latest_version(build_info[0], product_info[product_id]["version"])
!= product_info[product_id]["version"]
):
not_valid = "Unsupported macOS version"
else:
valid_build_found = True
validity_info = ""
if args.warnings:
validity_info = not_valid
print(
"%2s %-15s %-10s %-8s %-11s %-30s %s"
% (
index + 1,
product_id,
product_info[product_id].get("version", "UNKNOWN"),
product_info[product_id].get("BUILD", "UNKNOWN"),
product_info[product_id]["PostDate"].strftime("%Y-%m-%d"),
product_info[product_id]["title"],
validity_info,
)
)
# go through various options for automatically determining the answer:
# skip if build is not suitable for current device
# and a validation parameter was chosen
if not_valid and (
args.validate
or (args.auto or args.version or args.os)
2021-04-18 14:46:22 +02:00
# and not args.beta # not needed now we have DeviceID check
):
continue
# skip if a version is selected and it does not match
if args.version and args.version != product_info[product_id]["version"]:
2018-09-23 14:17:45 +02:00
continue
# skip if an OS is selected and it does not match
if args.os:
os_version = product_info[product_id]["version"].split(".")[0]
try:
if int(os_version) == 10:
major = product_info[product_id]["version"].split(".", 2)[:2]
os_version = ".".join(major)
except ValueError:
2021-04-18 14:46:22 +02:00
# Account for when no version information is given
os_version = ""
if args.os != os_version:
continue
2019-07-12 18:42:44 +02:00
# determine the latest valid build ID and select this
# when using auto, os and version options
2023-01-24 20:32:28 +01:00
if args.auto or args.version or args.os or args.list:
if args.beta or "Beta" not in product_info[product_id]["title"]:
2019-07-12 18:42:44 +02:00
try:
latest_valid_build
except NameError:
latest_valid_build = product_info[product_id]["BUILD"]
# if using newer-than option, skip if not newer than the version
2021-04-18 14:46:22 +02:00
# we are checking against
if args.newer_than_version:
latest_valid_build = get_latest_version(
product_info[product_id]["version"], args.newer_than_version
)
if latest_valid_build == args.newer_than_version:
continue
# if using renew option, skip if the same as the current version
if (
args.renew
and build_info[0] == product_info[product_id]["version"]
):
continue
answer = index + 1
2019-07-12 18:42:44 +02:00
else:
latest_valid_build = get_latest_version(
product_info[product_id]["BUILD"], latest_valid_build
)
if latest_valid_build == product_info[product_id]["BUILD"]:
# if using newer-than option, skip if not newer than the version
2021-04-18 14:46:22 +02:00
# we are checking against
if args.newer_than_version:
latest_valid_build = get_latest_version(
product_info[product_id]["version"],
args.newer_than_version,
)
if latest_valid_build == args.newer_than_version:
continue
# if using renew option, skip if the same as the current version
if (
args.renew
and build_info[1] == product_info[product_id]["BUILD"]
):
continue
answer = index + 1
2018-09-23 14:17:45 +02:00
# Write this build info to plist
pl_index = {
"index": index + 1,
"product_id": product_id,
"version": product_info[product_id]["version"],
"build": product_info[product_id]["BUILD"],
"title": product_info[product_id]["title"],
}
pl["result"].append(pl_index)
if args.build:
# automatically select matching build ID if build option used
if args.build == product_info[product_id]["BUILD"]:
answer = index + 1
2018-09-23 14:17:45 +02:00
break
2018-09-23 14:17:45 +02:00
elif args.current:
# automatically select matching build ID if current option used
if build_info[0] == product_info[product_id]["version"]:
answer = index + 1
2018-09-23 14:17:45 +02:00
break
# Stop here if no valid builds found
if (
valid_build_found is False
and not args.build
and not args.current
2022-03-09 19:25:36 +01:00
and args.validate
and not args.list
):
2022-03-09 19:25:36 +01:00
print("No valid build found for this computer")
exit(0)
# clear content directory in workdir if requested
if args.clear:
print(
"Removing existing content from working directory '%s'...\n" % args.workdir
)
shutil.rmtree("%s/content" % args.workdir)
# Output a plist of available updates and quit if list option chosen
if args.list:
2023-01-24 20:32:28 +01:00
if args.os:
print("\nBuild checked against OS filter: %s" % (args.os))
pl["validity_filter"] = "OS: %s" % (args.os)
elif args.version:
print("\nBuild checked against version filter: %s" % (args.version))
pl["validity_filter"] = "Version: %s" % (args.version)
try:
print("\nLatest valid build: %s (# %s)" % (latest_valid_build, answer))
pl["latest_valid_build"] = latest_valid_build
except NameError:
pl["latest_valid_build"] = ""
print("\nNo valid build found")
2020-05-06 17:15:41 +02:00
write_plist(pl, output_plist)
print("\nValid seeding programs are: %s\n" % ", ".join(get_seeding_programs()))
exit(0)
# check for validity of specified build if argument supplied
if args.build:
try:
answer
except NameError:
print(
"\n"
2022-03-09 19:25:36 +01:00
"A valid installer for build %s is not available for this computer. "
"Run again without --build argument "
"to select a valid build to download "
"or run without --validate option to download anyway.\n" % args.build
)
2018-09-23 14:17:45 +02:00
exit(0)
else:
2022-03-09 19:25:36 +01:00
print("\n" "Build %s valid. Downloading #%s...\n" % (args.build, answer))
2018-09-23 14:17:45 +02:00
elif args.current:
try:
answer
except NameError:
print(
"\n"
2022-03-09 19:25:36 +01:00
"A valid installer for build %s is not available for this computer. "
"Run again without --current argument "
"to select a valid build to download.\n" % build_info[0]
)
2018-09-23 14:17:45 +02:00
exit(0)
else:
print(
"\n"
"Build %s available. Downloading #%s...\n" % (build_info[0], answer)
)
elif args.newer_than_version:
try:
answer
except NameError:
print(
"\n"
"No newer valid version than the specified version available. "
"Run again without --newer_than_version argument "
"to select a valid build to download.\n"
)
exit(0)
else:
print(
"\n"
"Build %s selected. Downloading #%s...\n" % (latest_valid_build, answer)
)
elif args.renew:
try:
answer
except NameError:
print(
"\n"
"No newer valid version than the current system version available. "
"Run again without --renew argument "
"to select a valid build to download.\n"
)
exit(0)
else:
print(
"\n"
"Build %s selected. Downloading #%s...\n" % (latest_valid_build, answer)
)
elif args.version:
2018-09-23 14:17:45 +02:00
try:
answer
except NameError:
print(
"\n"
2022-03-09 19:25:36 +01:00
"A valid installer for version %s is not available for this computer. "
"Run again without --version argument "
"to select a valid build to download.\n" % args.version
)
exit(0)
else:
print(
"\n"
"Build %s selected. Downloading #%s...\n" % (latest_valid_build, answer)
)
elif args.os:
try:
answer
except NameError:
print(
"\n"
2022-03-09 19:25:36 +01:00
"A valid installer for OS %s is not available for this computer. "
"Run again without --os argument "
"to select a valid build to download.\n" % args.os
)
exit(0)
else:
print(
"\n"
"Build %s selected. Downloading #%s...\n" % (latest_valid_build, answer)
)
elif args.auto:
try:
answer
except NameError:
print(
"\n"
2022-03-09 19:25:36 +01:00
"No valid version available for this computer. "
"Run again without --auto argument "
"to select a valid build to download.\n"
)
exit(0)
else:
print(
"\n"
"Build %s selected. Downloading #%s...\n" % (latest_valid_build, answer)
)
else:
# default option to interactively offer selection
answer = get_input(
"\nChoose a product to download (1-%s): " % len(product_info)
)
try:
index = int(answer) - 1
if index < 0:
raise ValueError
product_id = list(product_info.keys())[index]
except (ValueError, IndexError):
print("Exiting.")
exit(0)
2021-04-18 14:46:22 +02:00
# shortened workflow if we just want a macOS Big Sur+ package
# taken from @scriptingosx's Fetch-Installer-Pkg project
2020-11-18 23:23:23 +01:00
# (https://github.com/scriptingosx/fetch-installer-pkg)
if args.pkg:
product = catalog["Products"][product_id]
2020-11-18 23:23:23 +01:00
# determine the InstallAssistant pkg url
for package in product["Packages"]:
package_url = package["URL"]
if package_url.endswith("InstallAssistant.pkg"):
2020-11-18 23:23:23 +01:00
break
2020-11-18 23:23:23 +01:00
# print("Package URL is %s" % package_url)
download_pkg = replicate_url(
package_url, args.workdir, True, ignore_cache=args.ignore_cache
)
pkg_name = "InstallAssistant-%s-%s.pkg" % (
product_info[product_id]["version"],
product_info[product_id]["BUILD"],
)
2020-11-18 23:23:23 +01:00
# hard link the downloaded file to cwd
local_pkg = os.path.join(args.workdir, pkg_name)
os.link(download_pkg, local_pkg)
print("Package downloaded to: %s" % local_pkg)
2020-11-18 23:23:23 +01:00
else:
# download all the packages for the selected product
replicate_product(
catalog, product_id, args.workdir, ignore_cache=args.ignore_cache
)
2017-11-09 14:12:03 -08:00
2020-11-18 23:23:23 +01:00
# generate a name for the sparseimage
volname = "Install_macOS_%s-%s" % (
product_info[product_id]["version"],
product_info[product_id]["BUILD"],
)
2020-11-18 23:23:23 +01:00
sparse_diskimage_path = os.path.join(args.workdir, volname + ".sparseimage")
if os.path.exists(sparse_diskimage_path):
2017-11-09 14:12:03 -08:00
os.unlink(sparse_diskimage_path)
2020-11-18 23:23:23 +01:00
# make an empty sparseimage and mount it
print("Making empty sparseimage...")
sparse_diskimage_path = make_sparse_image(volname, sparse_diskimage_path)
mountpoint = mountdmg(sparse_diskimage_path)
if mountpoint:
# install the product to the mounted sparseimage volume
success = install_product(
product_info[product_id]["DistributionPath"], mountpoint
)
if not success:
print("Product installation failed.", file=sys.stderr)
unmountdmg(mountpoint)
exit(-1)
# add the seeding program xattr to the app if applicable
seeding_program = get_seeding_program(su_catalog_url)
if seeding_program:
installer_app = find_installer_app(mountpoint)
if installer_app:
print(
"Adding seeding program %s extended attribute to app"
% seeding_program
)
xattr.setxattr(
installer_app,
"SeedProgram",
seeding_program.encode("UTF-8").encode(),
)
2020-11-18 23:23:23 +01:00
print("Product downloaded and installed to %s" % sparse_diskimage_path)
if args.raw:
unmountdmg(mountpoint)
else:
# if --raw option not given, create a r/o compressed diskimage
# containing the Install macOS app
compressed_diskimagepath = os.path.join(args.workdir, volname + ".dmg")
if os.path.exists(compressed_diskimagepath):
os.unlink(compressed_diskimagepath)
app_path = find_installer_app(mountpoint)
if app_path:
make_compressed_dmg(app_path, compressed_diskimagepath, volname)
# unmount sparseimage
unmountdmg(mountpoint)
# delete sparseimage since we don't need it any longer
os.unlink(sparse_diskimage_path)
2017-11-09 14:12:03 -08:00
if __name__ == "__main__":
2017-11-09 14:12:03 -08:00
main()