454 lines
18 KiB
Python
454 lines
18 KiB
Python
|
#!/usr/bin/env python3
|
||
|
# encoding: utf-8
|
||
|
#
|
||
|
# https://github.com/munki/macadmin-scripts/blob/master/installinstallmacos.py
|
||
|
#
|
||
|
# Copyright 2017 Greg Neagle.
|
||
|
#
|
||
|
# 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.
|
||
|
#
|
||
|
# Updated in May of 2019 by Dhiru Kholia.
|
||
|
|
||
|
'''installinstallmacos.py
|
||
|
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'''
|
||
|
|
||
|
# https://github.com/foxlet/macOS-Simple-KVM/blob/master/tools/FetchMacOS/fetch-macos.py
|
||
|
# is pretty similar.
|
||
|
|
||
|
|
||
|
# Bad hack
|
||
|
import warnings
|
||
|
|
||
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||
|
|
||
|
import os
|
||
|
import gzip
|
||
|
import argparse
|
||
|
import plistlib
|
||
|
import subprocess
|
||
|
|
||
|
from xml.dom import minidom
|
||
|
from xml.parsers.expat import ExpatError
|
||
|
|
||
|
|
||
|
import sys
|
||
|
|
||
|
if sys.version_info[0] < 3:
|
||
|
import urlparse as urlstuff
|
||
|
else:
|
||
|
import urllib.parse as urlstuff
|
||
|
# Quick fix for python 3.9 and above
|
||
|
if sys.version_info[0] == 3 and sys.version_info[1] >= 9:
|
||
|
from types import MethodType
|
||
|
|
||
|
def readPlist(self,filepath):
|
||
|
with open(filepath, 'rb') as f:
|
||
|
p = plistlib._PlistParser(dict)
|
||
|
rootObject = p.parse(f)
|
||
|
return rootObject
|
||
|
# adding the method readPlist() to plistlib
|
||
|
plistlib.readPlist = MethodType(readPlist, plistlib)
|
||
|
|
||
|
# https://github.com/foxlet/macOS-Simple-KVM/blob/master/tools/FetchMacOS/fetch-macos.py (unused)
|
||
|
# https://github.com/munki/macadmin-scripts
|
||
|
catalogs = {
|
||
|
"CustomerSeed": "https://swscan.apple.com/content/catalogs/others/index-10.16customerseed-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog",
|
||
|
"DeveloperSeed": "https://swscan.apple.com/content/catalogs/others/index-10.16seed-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog",
|
||
|
"PublicSeed": "https://swscan.apple.com/content/catalogs/others/index-10.16beta-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog",
|
||
|
"PublicRelease": "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",
|
||
|
"20": "https://swscan.apple.com/content/catalogs/others/index-11-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog"
|
||
|
}
|
||
|
|
||
|
|
||
|
def get_default_catalog():
|
||
|
'''Returns the default softwareupdate catalog for the current OS'''
|
||
|
return catalogs["20"]
|
||
|
# return catalogs["PublicRelease"]
|
||
|
# return catalogs["DeveloperSeed"]
|
||
|
|
||
|
|
||
|
class ReplicationError(Exception):
|
||
|
'''A custom error when replication fails'''
|
||
|
pass
|
||
|
|
||
|
|
||
|
def cmd_exists(cmd):
|
||
|
return subprocess.call("type " + cmd, shell=True,
|
||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0
|
||
|
|
||
|
|
||
|
def replicate_url(full_url,
|
||
|
root_dir='/tmp',
|
||
|
show_progress=False,
|
||
|
ignore_cache=False,
|
||
|
attempt_resume=False, installer=False, product_title=""):
|
||
|
'''Downloads a URL and stores it in the same relative path on our
|
||
|
filesystem. Returns a path to the replicated file.'''
|
||
|
|
||
|
# hack
|
||
|
print("[+] Fetching %s" % full_url)
|
||
|
if installer and "BaseSystem.dmg" not in full_url and "Big Sur" not in product_title:
|
||
|
return
|
||
|
if "Big Sur" in product_title and "InstallAssistant.pkg" not in full_url:
|
||
|
return
|
||
|
attempt_resume = True
|
||
|
# path = urllib.parse.urlsplit(full_url)[2]
|
||
|
path = urlstuff.urlsplit(full_url)[2]
|
||
|
relative_url = path.lstrip('/')
|
||
|
relative_url = os.path.normpath(relative_url)
|
||
|
# local_file_path = os.path.join(root_dir, relative_url)
|
||
|
local_file_path = relative_url
|
||
|
# print("Downloading %s..." % full_url)
|
||
|
|
||
|
if cmd_exists('wget'):
|
||
|
if not installer:
|
||
|
download_cmd = ['wget', "-c", "--quiet", "-x", "-nH", full_url]
|
||
|
# this doesn't work as there are multiple metadata files with the same name!
|
||
|
# download_cmd = ['wget', "-c", "--quiet", full_url]
|
||
|
else:
|
||
|
download_cmd = ['wget', "-c", full_url]
|
||
|
else:
|
||
|
if not installer:
|
||
|
download_cmd = ['curl', "--silent", "--show-error", "-o", local_file_path, "--create-dirs", full_url]
|
||
|
else:
|
||
|
local_file_path = os.path.basename(local_file_path)
|
||
|
download_cmd = ['curl', "-o", local_file_path, full_url]
|
||
|
|
||
|
try:
|
||
|
subprocess.check_call(download_cmd)
|
||
|
except subprocess.CalledProcessError as err:
|
||
|
raise ReplicationError(err)
|
||
|
return local_file_path
|
||
|
|
||
|
|
||
|
def parse_server_metadata(filename):
|
||
|
'''Parses a softwareupdate server metadata file, looking for information
|
||
|
of interest.
|
||
|
Returns a dictionary containing title, version, and description.'''
|
||
|
title = ''
|
||
|
vers = ''
|
||
|
try:
|
||
|
md_plist = plistlib.readPlist(filename)
|
||
|
except (OSError, IOError, ExpatError) as err:
|
||
|
print('Error reading %s: %s' % (filename, err), file=sys.stderr)
|
||
|
return {}
|
||
|
vers = md_plist.get('CFBundleShortVersionString', '')
|
||
|
localization = md_plist.get('localization', {})
|
||
|
preferred_localization = (localization.get('English') or
|
||
|
localization.get('en'))
|
||
|
if preferred_localization:
|
||
|
title = preferred_localization.get('title', '')
|
||
|
|
||
|
metadata = {}
|
||
|
metadata['title'] = title
|
||
|
metadata['version'] = vers
|
||
|
|
||
|
"""
|
||
|
{'title': 'macOS Mojave', 'version': '10.14.5'}
|
||
|
{'title': 'macOS Mojave', 'version': '10.14.6'}
|
||
|
"""
|
||
|
return metadata
|
||
|
|
||
|
|
||
|
def get_server_metadata(catalog, product_key, workdir, ignore_cache=False):
|
||
|
'''Replicate ServerMetaData'''
|
||
|
try:
|
||
|
url = catalog['Products'][product_key]['ServerMetadataURL']
|
||
|
try:
|
||
|
smd_path = replicate_url(
|
||
|
url, root_dir=workdir, ignore_cache=ignore_cache)
|
||
|
return smd_path
|
||
|
except ReplicationError as err:
|
||
|
print('Could not replicate %s: %s' % (url, err), file=sys.stderr)
|
||
|
return None
|
||
|
except KeyError:
|
||
|
# print('Malformed catalog.', file=sys.stderr)
|
||
|
return None
|
||
|
|
||
|
|
||
|
def parse_dist(filename):
|
||
|
'''Parses a softwareupdate dist file, returning a dict of info of
|
||
|
interest'''
|
||
|
dist_info = {}
|
||
|
try:
|
||
|
dom = minidom.parse(filename)
|
||
|
except ExpatError:
|
||
|
print('Invalid XML in %s' % filename, file=sys.stderr)
|
||
|
return dist_info
|
||
|
except IOError as err:
|
||
|
print('Error reading %s: %s' % (filename, err), file=sys.stderr)
|
||
|
return dist_info
|
||
|
|
||
|
titles = dom.getElementsByTagName('title')
|
||
|
if titles:
|
||
|
dist_info['title_from_dist'] = titles[0].firstChild.wholeText
|
||
|
|
||
|
auxinfos = dom.getElementsByTagName('auxinfo')
|
||
|
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':
|
||
|
key = node.firstChild.wholeText
|
||
|
if node.nodeType == node.ELEMENT_NODE and node.tagName == 'string':
|
||
|
value = node.firstChild.wholeText
|
||
|
if key and value:
|
||
|
dist_info[key] = value
|
||
|
key = None
|
||
|
value = None
|
||
|
return dist_info
|
||
|
|
||
|
|
||
|
def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False):
|
||
|
'''Downloads and returns a parsed softwareupdate catalog'''
|
||
|
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)
|
||
|
exit(-1)
|
||
|
if os.path.splitext(localcatalogpath)[1] == '.gz':
|
||
|
with gzip.open(localcatalogpath) as the_file:
|
||
|
content = the_file.read()
|
||
|
try:
|
||
|
catalog = plistlib.readPlistFromString(content)
|
||
|
return catalog
|
||
|
except ExpatError as err:
|
||
|
print('Error reading %s: %s' % (localcatalogpath, err), file=sys.stderr)
|
||
|
exit(-1)
|
||
|
else:
|
||
|
try:
|
||
|
catalog = plistlib.readPlist(localcatalogpath)
|
||
|
return catalog
|
||
|
except (OSError, IOError, ExpatError) as err:
|
||
|
print('Error reading %s: %s' % (localcatalogpath, err), file=sys.stderr)
|
||
|
exit(-1)
|
||
|
|
||
|
|
||
|
def find_mac_os_installers(catalog):
|
||
|
'''Return a list of product identifiers for what appear to be macOS
|
||
|
installers'''
|
||
|
mac_os_installer_products = []
|
||
|
if 'Products' in catalog:
|
||
|
for product_key in catalog['Products'].keys():
|
||
|
product = catalog['Products'][product_key]
|
||
|
try:
|
||
|
if product['ExtendedMetaInfo'][
|
||
|
'InstallAssistantPackageIdentifiers']:
|
||
|
mac_os_installer_products.append(product_key)
|
||
|
except KeyError:
|
||
|
continue
|
||
|
|
||
|
return mac_os_installer_products
|
||
|
|
||
|
|
||
|
def os_installer_product_info(catalog, workdir, ignore_cache=False):
|
||
|
'''Returns a dict of info about products that look like macOS installers'''
|
||
|
product_info = {}
|
||
|
installer_products = find_mac_os_installers(catalog)
|
||
|
for product_key in installer_products:
|
||
|
product_info[product_key] = {}
|
||
|
filename = get_server_metadata(catalog, product_key, workdir)
|
||
|
if filename:
|
||
|
product_info[product_key] = parse_server_metadata(filename)
|
||
|
else:
|
||
|
# print('No server metadata for %s' % product_key)
|
||
|
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')
|
||
|
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
|
||
|
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')
|
||
|
|
||
|
return product_info
|
||
|
|
||
|
|
||
|
def replicate_product(catalog, product_id, workdir, ignore_cache=False, product_title=""):
|
||
|
'''Downloads all the packages for a product'''
|
||
|
product = catalog['Products'][product_id]
|
||
|
for package in product.get('Packages', []):
|
||
|
# TO-DO: Check 'Size' attribute and make sure
|
||
|
# we have enough space on the target
|
||
|
# filesystem before attempting to download
|
||
|
if 'URL' in package:
|
||
|
try:
|
||
|
replicate_url(
|
||
|
package['URL'], root_dir=workdir,
|
||
|
show_progress=True, ignore_cache=ignore_cache,
|
||
|
attempt_resume=(not ignore_cache), installer=True, product_title=product_title)
|
||
|
except ReplicationError as err:
|
||
|
print('Could not replicate %s: %s' % (package['URL'], err), file=sys.stderr)
|
||
|
exit(-1)
|
||
|
if 'MetadataURL' in package:
|
||
|
try:
|
||
|
replicate_url(package['MetadataURL'], root_dir=workdir,
|
||
|
ignore_cache=ignore_cache, installer=True)
|
||
|
except ReplicationError as err:
|
||
|
print('Could not replicate %s: %s' % (package['MetadataURL'], err), file=sys.stderr)
|
||
|
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
|
||
|
|
||
|
|
||
|
def determine_version(version, title, product_info):
|
||
|
if version:
|
||
|
if version == 'latest':
|
||
|
from distutils.version import StrictVersion
|
||
|
latest_version = StrictVersion('0.0.0')
|
||
|
for index, product_id in enumerate(product_info):
|
||
|
if not title or product_info[product_id]['title'] == title:
|
||
|
d = product_info[product_id]['version']
|
||
|
if d > latest_version:
|
||
|
latest_version = d
|
||
|
|
||
|
if latest_version == StrictVersion("0.0.0"):
|
||
|
print("Could not find latest version {}")
|
||
|
exit(1)
|
||
|
|
||
|
version = str(latest_version)
|
||
|
|
||
|
for index, product_id in enumerate(product_info):
|
||
|
v = product_info[product_id]['version']
|
||
|
if v == version:
|
||
|
return product_id, product_info[product_id]['title']
|
||
|
|
||
|
print("Could not find version {}. Versions available are:".format(version))
|
||
|
for _, pid in enumerate(product_info):
|
||
|
print("- {}".format(product_info[pid]['version']))
|
||
|
|
||
|
exit(1)
|
||
|
|
||
|
# display a menu of choices (some seed catalogs have multiple installers)
|
||
|
print('%2s %12s %10s %11s %s' % ('#', 'ProductID', 'Version',
|
||
|
'Post Date', 'Title'))
|
||
|
for index, product_id in enumerate(product_info):
|
||
|
print('%2s %12s %10s %11s %s' % (
|
||
|
index + 1,
|
||
|
product_id,
|
||
|
product_info[product_id]['version'],
|
||
|
product_info[product_id]['PostDate'].strftime('%Y-%m-%d'),
|
||
|
product_info[product_id]['title']
|
||
|
))
|
||
|
|
||
|
answer = 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]
|
||
|
return product_id, product_info[product_id]['title']
|
||
|
except (ValueError, IndexError):
|
||
|
pass
|
||
|
|
||
|
print('Invalid input provided.')
|
||
|
exit(0)
|
||
|
|
||
|
|
||
|
def main():
|
||
|
'''Do the main thing here'''
|
||
|
"""
|
||
|
if os.getuid() != 0:
|
||
|
sys.exit('This command requires root (to install packages), so please '
|
||
|
'run again with sudo or as root.')
|
||
|
"""
|
||
|
parser = argparse.ArgumentParser()
|
||
|
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('--version', metavar='version',
|
||
|
default=None,
|
||
|
help='The version to download in the format of '
|
||
|
'"$major.$minor.$patch", e.g. "10.15.4". Can '
|
||
|
'be "latest" to download the latest version.')
|
||
|
parser.add_argument('--title', metavar='title',
|
||
|
default=None,
|
||
|
help='When using "--version latest", you can use '
|
||
|
'this to filter the search to only products with '
|
||
|
'this title')
|
||
|
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.')
|
||
|
args = parser.parse_args()
|
||
|
|
||
|
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)
|
||
|
|
||
|
# 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)
|
||
|
product_info = os_installer_product_info(
|
||
|
catalog, args.workdir, ignore_cache=args.ignore_cache)
|
||
|
|
||
|
if not product_info:
|
||
|
print('No macOS installer products found in the sucatalog.', file=sys.stderr)
|
||
|
exit(-1)
|
||
|
|
||
|
product_id, product_title = determine_version(args.version, args.title, product_info)
|
||
|
print(product_id, product_title)
|
||
|
|
||
|
# download all the packages for the selected product
|
||
|
replicate_product(catalog, product_id, args.workdir, ignore_cache=args.ignore_cache, product_title=product_title)
|
||
|
|
||
|
|
||
|
if __name__ == '__main__':
|
||
|
main()
|