mirror of
https://github.com/grahampugh/macadmin-scripts.git
synced 2025-12-17 09:46:00 +00:00
228 lines
7.6 KiB
Python
Executable File
228 lines
7.6 KiB
Python
Executable File
#!/usr/bin/python
|
|
# encoding: utf-8
|
|
#
|
|
# 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.
|
|
'''Parses Apple's feed of macOS IPSWs and lets you download one'''
|
|
|
|
from __future__ import (
|
|
absolute_import, division, print_function, unicode_literals)
|
|
|
|
import os
|
|
import plistlib
|
|
import subprocess
|
|
import sys
|
|
try:
|
|
# python 2
|
|
from urllib.parse import urlsplit
|
|
except ImportError:
|
|
# python 3
|
|
from urlparse import urlsplit
|
|
from xml.parsers.expat import ExpatError
|
|
|
|
|
|
class ReplicationError(Exception):
|
|
'''A custom error when replication fails'''
|
|
pass
|
|
|
|
|
|
def get_url(url,
|
|
download_dir='/tmp',
|
|
show_progress=False,
|
|
attempt_resume=False):
|
|
'''Downloads a URL and stores it in the download_dir.
|
|
Returns a path to the replicated file.'''
|
|
|
|
path = urlsplit(url)[2]
|
|
filename = os.path.basename(path)
|
|
local_file_path = os.path.join(download_dir, filename)
|
|
if show_progress:
|
|
options = '-fL'
|
|
else:
|
|
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 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 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(url)
|
|
print("Downloading %s..." % 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)
|
|
return local_file_path
|
|
|
|
|
|
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)
|
|
return plistlib.readPlistFromString(bytestring)
|
|
|
|
IPSW_DATA = None
|
|
def get_ipsw_data():
|
|
'''Return data from com_apple_macOSIPSW.xml (which is actually a plist)'''
|
|
global IPSW_DATA
|
|
IPSW_FEED = "https://mesu.apple.com/assets/macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml"
|
|
|
|
if not IPSW_DATA:
|
|
try:
|
|
ipsw_plist = get_url(IPSW_FEED)
|
|
IPSW_DATA = read_plist(ipsw_plist)
|
|
except (OSError, IOError, ExpatError, ReplicationError) as err:
|
|
print(err, file=sys.stderr)
|
|
exit(1)
|
|
|
|
return IPSW_DATA
|
|
|
|
def getMobileDeviceSoftwareVersionsByVersion():
|
|
'''return the MobileDeviceSoftwareVersionsByVersion dict'''
|
|
ipsw_data = get_ipsw_data()
|
|
return ipsw_data.get("MobileDeviceSoftwareVersionsByVersion", {})
|
|
|
|
|
|
def getMobileDeviceSoftwareVersions(version=1):
|
|
'''Return the dict under the version number key. Current xml has only "1"'''
|
|
return getMobileDeviceSoftwareVersionsByVersion().get("%s" % version, {})
|
|
|
|
|
|
def getMachineModelsForMobileDeviceSoftwareVersions(version=1):
|
|
'''Get the model keys'''
|
|
versions = getMobileDeviceSoftwareVersions(version=version).get(
|
|
"MobileDeviceSoftwareVersions", {})
|
|
return versions.keys()
|
|
|
|
|
|
def getSoftwareVersionsForMachineModel(model, version=1):
|
|
'''Get the dict for a specific model'''
|
|
versions = getMobileDeviceSoftwareVersions(version=version).get(
|
|
"MobileDeviceSoftwareVersions", {})
|
|
return versions[model]
|
|
|
|
|
|
def getIPSWInfoForMachineModel(model, version=1):
|
|
'''Build and return a list of dict describing the available
|
|
ipsw file for a specific model'''
|
|
model_info_list = []
|
|
model_versions = getSoftwareVersionsForMachineModel(model, version=version)
|
|
for key in model_versions:
|
|
if key == "Unknown":
|
|
build_dict = model_versions["Unknown"].get("Universal", {})
|
|
else:
|
|
build_dict = model_versions[key]
|
|
restore_info = build_dict.get("Restore")
|
|
if restore_info:
|
|
model_info = {"model": model}
|
|
model_info.update(restore_info)
|
|
model_info_list.append(model_info)
|
|
return model_info_list
|
|
|
|
|
|
def getAllModelInfo(version=1):
|
|
'''Build and return a list of all available ipsws'''
|
|
all_model_info = []
|
|
available_models = getMachineModelsForMobileDeviceSoftwareVersions(
|
|
version=version)
|
|
for model in available_models:
|
|
model_info = getIPSWInfoForMachineModel(model, version=version)
|
|
all_model_info.extend(model_info)
|
|
return all_model_info
|
|
|
|
|
|
def main():
|
|
'''Our main thing to do'''
|
|
all_model_info = getAllModelInfo()
|
|
# display a menu of choices
|
|
print('%2s %16s %10s %8s %11s'
|
|
% ('#', 'Model', 'Version', 'Build', 'Checksum'))
|
|
for index, item in enumerate(all_model_info):
|
|
print('%2s %16s %10s %8s %11s' % (
|
|
index + 1,
|
|
item["model"],
|
|
item.get('ProductVersion', 'UNKNOWN'),
|
|
item.get('BuildVersion', 'UNKNOWN'),
|
|
item.get('FirmwareSHA1', 'UNKNOWN')[-6:]))
|
|
|
|
answer = get_input(
|
|
'\nChoose a product to download (1-%s): ' % len(all_model_info))
|
|
try:
|
|
index = int(answer) - 1
|
|
if index < 0:
|
|
raise ValueError
|
|
except (ValueError, IndexError):
|
|
print('Exiting.')
|
|
exit(0)
|
|
|
|
download_url = getAllModelInfo()[index].get("FirmwareURL")
|
|
if download_url:
|
|
try:
|
|
filepath = get_url(download_url,
|
|
download_dir=".", show_progress=True, attempt_resume=True)
|
|
print("IPSW downloaded to: %s" % filepath)
|
|
except (ReplicationError, IOError, OSError) as err:
|
|
print(err, file=sys.stderr)
|
|
exit(1)
|
|
else:
|
|
print("No valid download URL for that item.", file=sys.stderr)
|
|
exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|