From 7b7f68453a4c7ab14c8b51832bb412415c391393 Mon Sep 17 00:00:00 2001 From: Mykola Grymalyuk Date: Sat, 11 Mar 2023 08:41:45 -0700 Subject: [PATCH] macos_installer_handler.py: Reworked from installer.py module --- CHANGELOG.md | 1 + resources/gui/gui_main.py | 18 +- resources/installer.py | 440 -------------------- resources/macos_installer_handler.py | 590 +++++++++++++++++++++++++++ 4 files changed, 601 insertions(+), 448 deletions(-) delete mode 100644 resources/installer.py create mode 100644 resources/macos_installer_handler.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 59f3ed5a4..2c50933a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Allows for more reliable network calls and downloads - Better supports network timeouts and disconnects - Dramatically less noise in console during downloads + - Implemented new macOS Installer handler - Removed unused modules: - sys_patch_downloader.py - run.py diff --git a/resources/gui/gui_main.py b/resources/gui/gui_main.py index a76c03516..8a4d383d9 100644 --- a/resources/gui/gui_main.py +++ b/resources/gui/gui_main.py @@ -31,14 +31,14 @@ from resources import ( constants, defaults, install, - installer, utilities, generate_smbios, updates, integrity_verification, global_settings, kdk_handler, - network_handler + network_handler, + macos_installer_handler ) from resources.sys_patch import sys_patch_detect, sys_patch @@ -1652,7 +1652,9 @@ class wx_python_gui: # Download installer catalog if ias is None: def ia(): - self.available_installers = installer.list_downloadable_macOS_installers(self.constants.payload_path, "DeveloperSeed") + remote_obj = macos_installer_handler.RemoteInstallerCatalog(seed_override=macos_installer_handler.SeedType.DeveloperSeed) + self.available_installers = remote_obj.available_apps + self.available_installers_latest = remote_obj.available_apps_latest logging.info("- Downloading installer catalog...") thread_ia = threading.Thread(target=ia) @@ -1690,7 +1692,7 @@ class wx_python_gui: i = -20 if available_installers: if ias is None: - available_installers = installer.only_list_newest_installers(available_installers) + available_installers = self.available_installers_latest for app in available_installers: logging.info(f"macOS {available_installers[app]['Version']} ({available_installers[app]['Build']}):\n - Size: {utilities.human_fmt(available_installers[app]['Size'])}\n - Source: {available_installers[app]['Source']}\n - Variant: {available_installers[app]['Variant']}\n - Link: {available_installers[app]['Link']}\n") if available_installers[app]['Variant'] in ["DeveloperSeed" , "PublicSeed"]: @@ -1964,7 +1966,7 @@ class wx_python_gui: self.header.Centre(wx.HORIZONTAL) self.verifying_chunk_label.SetLabel("Installing into Applications folder") self.verifying_chunk_label.Centre(wx.HORIZONTAL) - thread_install = threading.Thread(target=installer.install_macOS_installer, args=(self.constants.payload_path,)) + thread_install = threading.Thread(target=macos_installer_handler.InstallerCreation().install_macOS_installer, args=(self.constants.payload_path,)) thread_install.start() self.progress_bar.Pulse() while thread_install.is_alive(): @@ -2001,7 +2003,7 @@ class wx_python_gui: # Spawn thread to get list of installers def get_installers(): - self.available_installers = installer.list_local_macOS_installers() + self.available_installers = macos_installer_handler.LocalInstallerCatalog().available_apps thread_get_installers = threading.Thread(target=get_installers) thread_get_installers.start() @@ -2105,7 +2107,7 @@ class wx_python_gui: self.usb_selection_label.Centre(wx.HORIZONTAL) i = -15 - available_disks = installer.list_disk_to_format() + available_disks = macos_installer_handler.InstallerCreation().list_disk_to_format() if available_disks: logging.info("Disks found") for disk in available_disks: @@ -2305,7 +2307,7 @@ class wx_python_gui: self.return_to_main_menu.Enable() def prepare_script(self, installer_path, disk): - self.prepare_result = installer.generate_installer_creation_script(self.constants.payload_path, installer_path, disk) + self.prepare_result = macos_installer_handler.InstallerCreation().generate_installer_creation_script(self.constants.payload_path, installer_path, disk) def start_script(self): utilities.disable_sleep_while_running() diff --git a/resources/installer.py b/resources/installer.py deleted file mode 100644 index aa5986bed..000000000 --- a/resources/installer.py +++ /dev/null @@ -1,440 +0,0 @@ -# Creates a macOS Installer -from pathlib import Path -import plistlib -import subprocess -import tempfile -import logging -from resources import utilities, network_handler - -def list_local_macOS_installers(): - # Finds all applicable macOS installers - # within a user's /Applications folder - # Returns a list of installers - application_list = {} - - for application in Path("/Applications").iterdir(): - # Verify whether application has createinstallmedia - try: - if (Path("/Applications") / Path(application) / Path("Contents/Resources/createinstallmedia")).exists(): - plist = plistlib.load((Path("/Applications") / Path(application) / Path("Contents/Info.plist")).open("rb")) - try: - # Doesn't reflect true OS build, but best to report SDK in the event multiple installers are found with same version - app_version = plist["DTPlatformVersion"] - clean_name = plist["CFBundleDisplayName"] - try: - app_sdk = plist["DTSDKBuild"] - except KeyError: - app_sdk = "Unknown" - - # app_version can sometimes report GM instead of the actual version - # This is a workaround to get the actual version - if app_version.startswith("GM"): - try: - app_version = int(app_sdk[:2]) - if app_version < 20: - app_version = f"10.{app_version - 4}" - else: - app_version = f"{app_version - 9}.0" - except ValueError: - app_version = "Unknown" - # Check if App Version is High Sierra or newer - can_add = False - if app_version.startswith("10."): - app_sub_version = app_version.split(".")[1] - if int(app_sub_version) >= 13: - can_add = True - else: - can_add = False - else: - can_add = True - - # Check SharedSupport.dmg's data - results = parse_sharedsupport_version(Path("/Applications") / Path(application)/ Path("Contents/SharedSupport/SharedSupport.dmg")) - if results[0] is not None: - app_sdk = results[0] - if results[1] is not None: - app_version = results[1] - - if can_add is True: - application_list.update({ - application: { - "Short Name": clean_name, - "Version": app_version, - "Build": app_sdk, - "Path": application, - } - }) - except KeyError: - pass - except PermissionError: - pass - # Sort Applications by version - application_list = {k: v for k, v in sorted(application_list.items(), key=lambda item: item[1]["Version"])} - return application_list - -def parse_sharedsupport_version(sharedsupport_path): - detected_build = None - detected_os = None - sharedsupport_path = Path(sharedsupport_path) - - if not sharedsupport_path.exists(): - return (detected_build, detected_os) - - if not sharedsupport_path.name.endswith(".dmg"): - return (detected_build, detected_os) - - - # Create temporary directory to extract SharedSupport.dmg to - with tempfile.TemporaryDirectory() as tmpdir: - output = subprocess.run( - [ - "hdiutil", "attach", "-noverify", sharedsupport_path, - "-mountpoint", tmpdir, - "-nobrowse", - ], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT - ) - if output.returncode != 0: - return (detected_build, detected_os) - - ss_info = Path("SFR/com_apple_MobileAsset_SFRSoftwareUpdate/com_apple_MobileAsset_SFRSoftwareUpdate.xml") - - if Path(tmpdir / ss_info).exists(): - plist = plistlib.load((tmpdir / ss_info).open("rb")) - if "Build" in plist["Assets"][0]: - detected_build = plist["Assets"][0]["Build"] - if "OSVersion" in plist["Assets"][0]: - detected_os = plist["Assets"][0]["OSVersion"] - - # Unmount SharedSupport.dmg - output = subprocess.run(["hdiutil", "detach", tmpdir], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - - return (detected_build, detected_os) - - -def create_installer(installer_path, volume_name): - # Creates a macOS installer - # Takes a path to the installer and the Volume - # Returns boolean on success status - - createinstallmedia_path = Path("/Applications") / Path(installer_path) / Path("Contents/Resources/createinstallmedia") - - # Sanity check in the event the user somehow deleted it between the time we found it and now - if (createinstallmedia_path).exists(): - utilities.cls() - utilities.header(["Starting createinstallmedia"]) - logging.info("This will take some time, recommend making some coffee while you wait\n") - utilities.elevated([createinstallmedia_path, "--volume", f"/Volumes/{volume_name}", "--nointeraction"]) - return True - else: - logging.info("- Failed to find createinstallmedia") - return False - -def download_install_assistant(download_path, ia_link): - # Downloads InstallAssistant.pkg - ia_download = network_handler.DownloadObject(ia_link, (Path(download_path) / Path("InstallAssistant.pkg"))) - ia_download.download(display_progress=True, spawn_thread=False) - - if ia_download.download_complete is True: - return True - return False - -def install_macOS_installer(download_path): - logging.info("- Extracting macOS installer from InstallAssistant.pkg\n This may take some time") - args = [ - "osascript", - "-e", - f'''do shell script "installer -pkg {Path(download_path)}/InstallAssistant.pkg -target /"''' - ' with prompt "OpenCore Legacy Patcher needs administrator privileges to add InstallAssistant."' - " with administrator privileges" - " without altering line endings", - ] - - result = subprocess.run(args,stdout=subprocess.PIPE, stderr=subprocess.PIPE) - if result.returncode == 0: - logging.info("- InstallAssistant installed") - return True - else: - logging.info("- Failed to install InstallAssistant") - logging.info(f" Error Code: {result.returncode}") - return False - -def list_downloadable_macOS_installers(download_path, catalog): - available_apps = {} - if catalog == "DeveloperSeed": - link = "https://swscan.apple.com/content/catalogs/others/index-13seed-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" - elif catalog == "PublicSeed": - link = "https://swscan.apple.com/content/catalogs/others/index-13beta-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" - else: - link = "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" - - if network_handler.NetworkUtilities(link).verify_network_connection() is True: - try: - catalog_plist = plistlib.loads(network_handler.SESSION.get(link).content) - except plistlib.InvalidFileException: - return available_apps - - for item in catalog_plist["Products"]: - try: - # Check if entry has SharedSupport and BuildManifest - # Ensures only Big Sur and newer Installers are listed - catalog_plist["Products"][item]["ExtendedMetaInfo"]["InstallAssistantPackageIdentifiers"]["SharedSupport"] - catalog_plist["Products"][item]["ExtendedMetaInfo"]["InstallAssistantPackageIdentifiers"]["BuildManifest"] - - for bm_package in catalog_plist["Products"][item]["Packages"]: - if "Info.plist" in bm_package["URL"] and "InstallInfo.plist" not in bm_package["URL"]: - try: - build_plist = plistlib.loads(network_handler.SESSION.get(bm_package["URL"]).content) - except plistlib.InvalidFileException: - continue - # Ensure Apple Silicon specific Installers are not listed - if "VMM-x86_64" not in build_plist["MobileAssetProperties"]["SupportedDeviceModels"]: - continue - version = build_plist["MobileAssetProperties"]["OSVersion"] - build = build_plist["MobileAssetProperties"]["Build"] - try: - catalog_url = build_plist["MobileAssetProperties"]["BridgeVersionInfo"]["CatalogURL"] - if "beta" in catalog_url: - catalog_url = "PublicSeed" - elif "customerseed" in catalog_url: - catalog_url = "CustomerSeed" - elif "seed" in catalog_url: - catalog_url = "DeveloperSeed" - else: - catalog_url = "Public" - except KeyError: - # Assume Public if no catalog URL is found - catalog_url = "Public" - for ia_package in catalog_plist["Products"][item]["Packages"]: - if "InstallAssistant.pkg" in ia_package["URL"]: - download_link = ia_package["URL"] - size = ia_package["Size"] - integrity = ia_package["IntegrityDataURL"] - - available_apps.update({ - item: { - "Version": version, - "Build": build, - "Link": download_link, - "Size": size, - "integrity": integrity, - "Source": "Apple Inc.", - "Variant": catalog_url, - } - }) - except KeyError: - pass - available_apps = {k: v for k, v in sorted(available_apps.items(), key=lambda x: x[1]['Version'])} - return available_apps - -def only_list_newest_installers(available_apps): - # Takes a dictionary of available installers - # Returns a dictionary of only the newest installers - # This is used to avoid overwhelming the user with installer options - - # Only strip OSes that we know are supported - supported_versions = ["10.13", "10.14", "10.15", "11", "12", "13"] - - for version in supported_versions: - remote_version_minor = 0 - remote_version_security = 0 - os_builds = [] - - # First determine the largest version - for ia in available_apps: - if available_apps[ia]["Version"].startswith(version): - if available_apps[ia]["Variant"] not in ["CustomerSeed", "DeveloperSeed", "PublicSeed"]: - remote_version = available_apps[ia]["Version"].split(".") - if remote_version[0] == "10": - remote_version.pop(0) - remote_version.pop(0) - else: - remote_version.pop(0) - if int(remote_version[0]) > remote_version_minor: - remote_version_minor = int(remote_version[0]) - remote_version_security = 0 # Reset as new minor version found - if len(remote_version) > 1: - if int(remote_version[1]) > remote_version_security: - remote_version_security = int(remote_version[1]) - - # Now remove all versions that are not the largest - for ia in list(available_apps): - # Don't use Beta builds to determine latest version - if available_apps[ia]["Variant"] in ["CustomerSeed", "DeveloperSeed", "PublicSeed"]: - continue - - if available_apps[ia]["Version"].startswith(version): - remote_version = available_apps[ia]["Version"].split(".") - if remote_version[0] == "10": - remote_version.pop(0) - remote_version.pop(0) - else: - remote_version.pop(0) - if int(remote_version[0]) < remote_version_minor: - available_apps.pop(ia) - continue - if int(remote_version[0]) == remote_version_minor: - if len(remote_version) > 1: - if int(remote_version[1]) < remote_version_security: - available_apps.pop(ia) - continue - else: - if remote_version_security > 0: - available_apps.pop(ia) - continue - - # Remove duplicate builds - # ex. macOS 12.5.1 has 2 builds in the Software Update Catalog - # ref: https://twitter.com/classicii_mrmac/status/1560357471654379522 - if available_apps[ia]["Build"] in os_builds: - available_apps.pop(ia) - continue - - os_builds.append(available_apps[ia]["Build"]) - - # Final passthrough - # Remove Betas if there's a non-beta version available - for ia in list(available_apps): - if available_apps[ia]["Variant"] in ["CustomerSeed", "DeveloperSeed", "PublicSeed"]: - for ia2 in available_apps: - if available_apps[ia2]["Version"].split(".")[0] == available_apps[ia]["Version"].split(".")[0] and available_apps[ia2]["Variant"] not in ["CustomerSeed", "DeveloperSeed", "PublicSeed"]: - available_apps.pop(ia) - break - - return available_apps - -def format_drive(disk_id): - # Formats a disk for macOS install - # Takes a disk ID - # Returns boolean on success status - header = f"# Formatting disk{disk_id} for macOS installer #" - box_length = len(header) - utilities.cls() - logging.info("#" * box_length) - logging.info(header) - logging.info("#" * box_length) - logging.info("") - #logging.info(f"- Formatting disk{disk_id} for macOS installer") - format_process = utilities.elevated(["diskutil", "eraseDisk", "HFS+", "OCLP-Installer", f"disk{disk_id}"]) - if format_process.returncode == 0: - logging.info("- Disk formatted") - return True - else: - logging.info("- Failed to format disk") - logging.info(f" Error Code: {format_process.returncode}") - input("\nPress Enter to exit") - return False - - - -def list_disk_to_format(): - all_disks = {} - list_disks = {} - # TODO: AllDisksAndPartitions is not supported in Snow Leopard and older - try: - # High Sierra and newer - disks = plistlib.loads(subprocess.run("diskutil list -plist physical".split(), stdout=subprocess.PIPE).stdout.decode().strip().encode()) - except ValueError: - # Sierra and older - disks = plistlib.loads(subprocess.run("diskutil list -plist".split(), stdout=subprocess.PIPE).stdout.decode().strip().encode()) - for disk in disks["AllDisksAndPartitions"]: - disk_info = plistlib.loads(subprocess.run(f"diskutil info -plist {disk['DeviceIdentifier']}".split(), stdout=subprocess.PIPE).stdout.decode().strip().encode()) - try: - all_disks[disk["DeviceIdentifier"]] = {"identifier": disk_info["DeviceNode"], "name": disk_info["MediaName"], "size": disk_info["TotalSize"], "removable": disk_info["Internal"], "partitions": {}} - except KeyError: - # Avoid crashing with CDs installed - continue - for disk in all_disks: - # Strip disks that are under 14GB (15,032,385,536 bytes) - # createinstallmedia isn't great at detecting if a disk has enough space - if not any(all_disks[disk]['size'] > 15032385536 for partition in all_disks[disk]): - continue - # Strip internal disks as well (avoid user formatting their SSD/HDD) - # Ensure user doesn't format their boot drive - if not any(all_disks[disk]['removable'] is False for partition in all_disks[disk]): - continue - logging.info(f"disk {disk}: {all_disks[disk]['name']} ({utilities.human_fmt(all_disks[disk]['size'])})") - list_disks.update({ - disk: { - "identifier": all_disks[disk]["identifier"], - "name": all_disks[disk]["name"], - "size": all_disks[disk]["size"], - } - }) - return list_disks - -# Create global tmp directory -tmp_dir = tempfile.TemporaryDirectory() - -def generate_installer_creation_script(tmp_location, installer_path, disk): - # Creates installer.sh to be piped to OCLP-Helper and run as admin - # Goals: - # - Format provided disk as HFS+ GPT - # - Run createinstallmedia on provided disk - # Implementing this into a single installer.sh script allows us to only call - # OCLP-Helper once to avoid nagging the user about permissions - - additional_args = "" - script_location = Path(tmp_location) / Path("Installer.sh") - - # Due to a bug in createinstallmedia, running from '/Applications' may sometimes error: - # 'Failed to extract AssetData/boot/Firmware/Manifests/InstallerBoot/*' - # This affects native Macs as well even when manually invoking createinstallmedia - - # To resolve, we'll copy into our temp directory and run from there - - # Create a new tmp directory - # Our current one is a disk image, thus CoW will not work - global tmp_dir - ia_tmp = tmp_dir.name - - logging.info(f"Creating temporary directory at {ia_tmp}") - # Delete all files in tmp_dir - for file in Path(ia_tmp).glob("*"): - subprocess.run(["rm", "-rf", str(file)]) - - # Copy installer to tmp (use CoW to avoid extra disk writes) - args = ["cp", "-cR", installer_path, ia_tmp] - if utilities.check_filesystem_type() != "apfs": - # HFS+ disks do not support CoW - args[1] = "-R" - # Ensure we have enough space for the duplication - space_available = utilities.get_free_space() - space_needed = Path(ia_tmp).stat().st_size - if space_available < space_needed: - logging.info("Not enough free space to create installer.sh") - logging.info(f"{utilities.human_fmt(space_available)} available, {utilities.human_fmt(space_needed)} required") - return False - subprocess.run(args) - - # Adjust installer_path to point to the copied installer - installer_path = Path(ia_tmp) / Path(Path(installer_path).name) - if not Path(installer_path).exists(): - logging.info(f"Failed to copy installer to {ia_tmp}") - return False - - createinstallmedia_path = str(Path(installer_path) / Path("Contents/Resources/createinstallmedia")) - plist_path = str(Path(installer_path) / Path("Contents/Info.plist")) - if Path(plist_path).exists(): - plist = plistlib.load(Path(plist_path).open("rb")) - if "DTPlatformVersion" in plist: - platform_version = plist["DTPlatformVersion"] - platform_version = platform_version.split(".")[0] - if platform_version[0] == "10": - if int(platform_version[1]) < 13: - additional_args = f" --applicationpath '{installer_path}'" - - if script_location.exists(): - script_location.unlink() - script_location.touch() - - with script_location.open("w") as script: - script.write(f'''#!/bin/bash -erase_disk='diskutil eraseDisk HFS+ OCLP-Installer {disk}' -if $erase_disk; then - "{createinstallmedia_path}" --volume /Volumes/OCLP-Installer --nointeraction{additional_args} -fi - ''') - if Path(script_location).exists(): - return True - return False \ No newline at end of file diff --git a/resources/macos_installer_handler.py b/resources/macos_installer_handler.py new file mode 100644 index 000000000..8764b4a6a --- /dev/null +++ b/resources/macos_installer_handler.py @@ -0,0 +1,590 @@ +# Handler for macOS installers, both local and remote + +from pathlib import Path +import plistlib +import subprocess +import tempfile +import enum +import logging + +from data import os_data +from resources import network_handler, utilities + + +APPLICATION_SEARCH_PATH: str = "/Applications" +SFR_SOFTWARE_UPDATE_PATH: str = "SFR/com_apple_MobileAsset_SFRSoftwareUpdate/com_apple_MobileAsset_SFRSoftwareUpdate.xml" + +CATALOG_URL_BASE: str = "https://swscan.apple.com/content/catalogs/others/index" +CATALOG_URL_EXTENSION: str = "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" +CATALOG_URL_VERSION: str = "13" + +tmp_dir = tempfile.TemporaryDirectory() + + +class InstallerCreation(): + + def __init__(self): + pass + + + def install_macOS_installer(self, download_path: str): + """ + Installs InstallAssistant.pkg + """ + + logging.info("- Extracting macOS installer from InstallAssistant.pkg\n This may take some time") + args = [ + "osascript", + "-e", + f'''do shell script "installer -pkg {Path(download_path)}/InstallAssistant.pkg -target /"''' + ' with prompt "OpenCore Legacy Patcher needs administrator privileges to add InstallAssistant."' + " with administrator privileges" + " without altering line endings", + ] + + result = subprocess.run(args,stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode != 0: + logging.info("- Failed to install InstallAssistant") + logging.info(f" Error Code: {result.returncode}") + return False + + logging.info("- InstallAssistant installed") + return True + + + def generate_installer_creation_script(self, tmp_location, installer_path, disk): + """ + Creates installer.sh to be piped to OCLP-Helper and run as admin + + Script includes: + - Format provided disk as HFS+ GPT + - Run createinstallmedia on provided disk + + Implementing this into a single installer.sh script allows us to only call + OCLP-Helper once to avoid nagging the user about permissions + + Parameters: + tmp_location (str): Path to temporary directory + installer_path (str): Path to InstallAssistant.pkg + disk (str): Disk to install to + """ + + additional_args = "" + script_location = Path(tmp_location) / Path("Installer.sh") + + # Due to a bug in createinstallmedia, running from '/Applications' may sometimes error: + # 'Failed to extract AssetData/boot/Firmware/Manifests/InstallerBoot/*' + # This affects native Macs as well even when manually invoking createinstallmedia + + # To resolve, we'll copy into our temp directory and run from there + + # Create a new tmp directory + # Our current one is a disk image, thus CoW will not work + global tmp_dir + ia_tmp = tmp_dir.name + + logging.info(f"Creating temporary directory at {ia_tmp}") + # Delete all files in tmp_dir + for file in Path(ia_tmp).glob("*"): + subprocess.run(["rm", "-rf", str(file)]) + + # Copy installer to tmp (use CoW to avoid extra disk writes) + args = ["cp", "-cR", installer_path, ia_tmp] + if utilities.check_filesystem_type() != "apfs": + # HFS+ disks do not support CoW + args[1] = "-R" + + # Ensure we have enough space for the duplication + space_available = utilities.get_free_space() + space_needed = Path(ia_tmp).stat().st_size + if space_available < space_needed: + logging.info("Not enough free space to create installer.sh") + logging.info(f"{utilities.human_fmt(space_available)} available, {utilities.human_fmt(space_needed)} required") + return False + + subprocess.run(args) + + # Adjust installer_path to point to the copied installer + installer_path = Path(ia_tmp) / Path(Path(installer_path).name) + if not Path(installer_path).exists(): + logging.info(f"Failed to copy installer to {ia_tmp}") + return False + + createinstallmedia_path = str(Path(installer_path) / Path("Contents/Resources/createinstallmedia")) + plist_path = str(Path(installer_path) / Path("Contents/Info.plist")) + if Path(plist_path).exists(): + plist = plistlib.load(Path(plist_path).open("rb")) + if "DTPlatformVersion" in plist: + platform_version = plist["DTPlatformVersion"] + platform_version = platform_version.split(".")[0] + if platform_version[0] == "10": + if int(platform_version[1]) < 13: + additional_args = f" --applicationpath '{installer_path}'" + + if script_location.exists(): + script_location.unlink() + script_location.touch() + + with script_location.open("w") as script: + script.write(f'''#!/bin/bash +erase_disk='diskutil eraseDisk HFS+ OCLP-Installer {disk}' +if $erase_disk; then + "{createinstallmedia_path}" --volume /Volumes/OCLP-Installer --nointeraction{additional_args} +fi + ''') + if Path(script_location).exists(): + return True + return False + + def list_disk_to_format(self): + """ + List applicable disks for macOS installer creation + Only lists disks that are: + - 14GB or larger + - External + + Current limitations: + - Does not support PCIe based SD cards readers + + Returns: + dict: Dictionary of disks + """ + + all_disks: dict = {} + list_disks: dict = {} + + # TODO: AllDisksAndPartitions is not supported in Snow Leopard and older + try: + # High Sierra and newer + disks = plistlib.loads(subprocess.run("diskutil list -plist physical".split(), stdout=subprocess.PIPE).stdout.decode().strip().encode()) + except ValueError: + # Sierra and older + disks = plistlib.loads(subprocess.run("diskutil list -plist".split(), stdout=subprocess.PIPE).stdout.decode().strip().encode()) + + for disk in disks["AllDisksAndPartitions"]: + disk_info = plistlib.loads(subprocess.run(f"diskutil info -plist {disk['DeviceIdentifier']}".split(), stdout=subprocess.PIPE).stdout.decode().strip().encode()) + try: + all_disks[disk["DeviceIdentifier"]] = {"identifier": disk_info["DeviceNode"], "name": disk_info["MediaName"], "size": disk_info["TotalSize"], "removable": disk_info["Internal"], "partitions": {}} + except KeyError: + # Avoid crashing with CDs installed + continue + + for disk in all_disks: + # Strip disks that are under 14GB (15,032,385,536 bytes) + # createinstallmedia isn't great at detecting if a disk has enough space + if not any(all_disks[disk]['size'] > 15032385536 for partition in all_disks[disk]): + continue + # Strip internal disks as well (avoid user formatting their SSD/HDD) + # Ensure user doesn't format their boot drive + if not any(all_disks[disk]['removable'] is False for partition in all_disks[disk]): + continue + + logging.info(f"disk {disk}: {all_disks[disk]['name']} ({utilities.human_fmt(all_disks[disk]['size'])})") + list_disks.update({ + disk: { + "identifier": all_disks[disk]["identifier"], + "name": all_disks[disk]["name"], + "size": all_disks[disk]["size"], + } + }) + + return list_disks + + +class SeedType(enum.Enum): + """ + Enum for catalog types + """ + DeveloperSeed = 0 + PublicSeed = 1 + CustomerSeed = 2 + PublicRelease = 3 + + +class RemoteInstallerCatalog: + """ + Parses Apple's Software Update catalog and finds all macOS installers. + """ + + def __init__(self, seed_override: SeedType = SeedType.PublicRelease): + + self.catalog_url: str = self._construct_catalog_url(seed_override) + + self.available_apps: dict = self._parse_catalog() + self.available_apps_latest: dict = self._list_newest_installers_only() + + + def _construct_catalog_url(self, seed_type: SeedType): + """ + Constructs the catalog URL based on the seed type + + Args: + seed_type (SeedType): The seed type to use + """ + + + url: str = "" + + if seed_type == SeedType.DeveloperSeed: + url = f"{CATALOG_URL_BASE}-{CATALOG_URL_VERSION}seed-{CATALOG_URL_EXTENSION}" + elif seed_type == SeedType.PublicSeed: + url = f"{CATALOG_URL_BASE}-{CATALOG_URL_VERSION}beta-{CATALOG_URL_EXTENSION}" + elif seed_type == SeedType.CustomerSeed: + url = f"{CATALOG_URL_BASE}-{CATALOG_URL_VERSION}customerseed-{CATALOG_URL_EXTENSION}" + else: + url = f"{CATALOG_URL_BASE}-{CATALOG_URL_EXTENSION}" + + return url + + + def _fetch_catalog(self): + """ + Fetches the catalog from Apple's servers + + Returns: + dict: The catalog as a dictionary + """ + + catalog: dict = {} + + if network_handler.NetworkUtilities(self.catalog_url).verify_network_connection() is False: + return catalog + + try: + catalog = plistlib.loads(network_handler.SESSION.get(self.catalog_url).content) + except plistlib.InvalidFileException: + return {} + + return catalog + + def _parse_catalog(self): + available_apps: dict = {} + + catalog: dict = self._fetch_catalog() + if not catalog: + return available_apps + + if "Products" not in catalog: + return available_apps + + for product in catalog["Products"]: + if "ExtendedMetaInfo" not in catalog["Products"][product]: + continue + if "Packages" not in catalog["Products"][product]: + continue + if "InstallAssistantPackageIdentifiers" not in catalog["Products"][product]["ExtendedMetaInfo"]: + continue + if "SharedSupport" not in catalog["Products"][product]["ExtendedMetaInfo"]["InstallAssistantPackageIdentifiers"]: + continue + if "BuildManifest" not in catalog["Products"][product]["ExtendedMetaInfo"]["InstallAssistantPackageIdentifiers"]: + continue + + for bm_package in catalog["Products"][product]["Packages"]: + if "Info.plist" not in bm_package["URL"]: + continue + if "InstallInfo.plist" in bm_package["URL"]: + continue + + try: + build_plist = plistlib.loads(network_handler.SESSION.get(bm_package["URL"]).content) + except plistlib.InvalidFileException: + continue + + if "MobileAssetProperties" not in build_plist: + continue + if "SupportedDeviceModels" not in build_plist["MobileAssetProperties"]: + continue + if "OSVersion" not in build_plist["MobileAssetProperties"]: + continue + if "Build" not in build_plist["MobileAssetProperties"]: + continue + + # Ensure Apple Silicon specific Installers are not listed + if "VMM-x86_64" not in build_plist["MobileAssetProperties"]["SupportedDeviceModels"]: + continue + + version = build_plist["MobileAssetProperties"]["OSVersion"] + build = build_plist["MobileAssetProperties"]["Build"] + + try: + catalog_url = build_plist["MobileAssetProperties"]["BridgeVersionInfo"]["CatalogURL"] + if "beta" in catalog_url: + catalog_url = "PublicSeed" + elif "customerseed" in catalog_url: + catalog_url = "CustomerSeed" + elif "seed" in catalog_url: + catalog_url = "DeveloperSeed" + else: + catalog_url = "Public" + except KeyError: + # Assume Public if no catalog URL is found + catalog_url = "Public" + + download_link = None + integrity = None + size = None + + for ia_package in catalog["Products"][product]["Packages"]: + if "InstallAssistant.pkg" not in ia_package["URL"]: + continue + if "URL" not in ia_package: + continue + if "IntegrityDataURL" not in ia_package: + continue + if "Size" not in ia_package: + size = 0 + + download_link = ia_package["URL"] + integrity = ia_package["IntegrityDataURL"] + size = ia_package["Size"] + + + if any([version, build, download_link, size, integrity]) is None: + continue + + available_apps.update({ + product: { + "Version": version, + "Build": build, + "Link": download_link, + "Size": size, + "integrity": integrity, + "Source": "Apple Inc.", + "Variant": catalog_url, + } + }) + + available_apps = {k: v for k, v in sorted(available_apps.items(), key=lambda x: x[1]['Version'])} + return available_apps + + + def _list_newest_installers_only(self): + """ + Returns a dictionary of the newest macOS installers only. + Primarily used to avoid overwhelming the user with a list of + installers that are not the newest version. + + Returns: + dict: A dictionary of the newest macOS installers only. + """ + + if self.available_apps is None: + return {} + + newest_apps: dict = self.available_apps.copy() + supported_versions = ["10.13", "10.14", "10.15", "11", "12", "13"] + + + for version in supported_versions: + remote_version_minor = 0 + remote_version_security = 0 + os_builds = [] + + # First determine the largest version + for ia in newest_apps: + if newest_apps[ia]["Version"].startswith(version): + if newest_apps[ia]["Variant"] not in ["CustomerSeed", "DeveloperSeed", "PublicSeed"]: + remote_version = newest_apps[ia]["Version"].split(".") + if remote_version[0] == "10": + remote_version.pop(0) + remote_version.pop(0) + else: + remote_version.pop(0) + if int(remote_version[0]) > remote_version_minor: + remote_version_minor = int(remote_version[0]) + remote_version_security = 0 # Reset as new minor version found + if len(remote_version) > 1: + if int(remote_version[1]) > remote_version_security: + remote_version_security = int(remote_version[1]) + + # Now remove all versions that are not the largest + for ia in list(newest_apps): + # Don't use Beta builds to determine latest version + if newest_apps[ia]["Variant"] in ["CustomerSeed", "DeveloperSeed", "PublicSeed"]: + continue + + if newest_apps[ia]["Version"].startswith(version): + remote_version = newest_apps[ia]["Version"].split(".") + if remote_version[0] == "10": + remote_version.pop(0) + remote_version.pop(0) + else: + remote_version.pop(0) + if int(remote_version[0]) < remote_version_minor: + newest_apps.pop(ia) + continue + if int(remote_version[0]) == remote_version_minor: + if len(remote_version) > 1: + if int(remote_version[1]) < remote_version_security: + newest_apps.pop(ia) + continue + else: + if remote_version_security > 0: + newest_apps.pop(ia) + continue + + # Remove duplicate builds + # ex. macOS 12.5.1 has 2 builds in the Software Update Catalog + # ref: https://twitter.com/classicii_mrmac/status/1560357471654379522 + if newest_apps[ia]["Build"] in os_builds: + newest_apps.pop(ia) + continue + + os_builds.append(newest_apps[ia]["Build"]) + + # Final passthrough + # Remove Betas if there's a non-beta version available + for ia in list(newest_apps): + if newest_apps[ia]["Variant"] in ["CustomerSeed", "DeveloperSeed", "PublicSeed"]: + for ia2 in newest_apps: + if newest_apps[ia2]["Version"].split(".")[0] == newest_apps[ia]["Version"].split(".")[0] and newest_apps[ia2]["Variant"] not in ["CustomerSeed", "DeveloperSeed", "PublicSeed"]: + newest_apps.pop(ia) + break + + return newest_apps + + +class LocalInstallerCatalog: + """ + Finds all macOS installers on the local machine. + """ + + def __init__(self): + self.available_apps: dict = self._list_local_macOS_installers() + + + def _list_local_macOS_installers(self): + """ + Searches for macOS installers in /Applications + + Returns: + dict: A dictionary of macOS installers found on the local machine. + + Example: + "Install macOS Big Sur Beta.app": { + "Short Name": "Big Sur Beta", + "Version": "11.0", + "Build": "20A5343i", + "Path": "/Applications/Install macOS Big Sur Beta.app", + }, + etc... + """ + + application_list: dict = {} + + for application in Path(APPLICATION_SEARCH_PATH).iterdir(): + # Certain Microsoft Applications have strange permissions disabling us from reading them + try: + if not (Path(APPLICATION_SEARCH_PATH) / Path(application) / Path("Contents/Resources/createinstallmedia")).exists(): + continue + + if not (Path(APPLICATION_SEARCH_PATH) / Path(application) / Path("Contents/Info.plist")).exists(): + continue + except PermissionError: + continue + + try: + application_info_plist = plistlib.load((Path(APPLICATION_SEARCH_PATH) / Path(application) / Path("Contents/Info.plist")).open("rb")) + except (PermissionError, TypeError, plistlib.InvalidFileException): + continue + + if "DTPlatformVersion" not in application_info_plist: + continue + if "CFBundleDisplayName" not in application_info_plist: + continue + + app_version = application_info_plist["DTPlatformVersion"] + clean_name = application_info_plist["CFBundleDisplayName"] + + if "DTSDKBuild" in application_info_plist: + app_sdk = application_info_plist["DTSDKBuild"] + else: + app_sdk = "Unknown" + + # app_version can sometimes report GM instead of the actual version + # This is a workaround to get the actual version + if app_version.startswith("GM"): + try: + app_version = int(app_sdk[:2]) + if app_version < 20: + app_version = f"10.{app_version - 4}" + else: + app_version = f"{app_version - 9}.0" + except ValueError: + app_version = "Unknown" + + # Check if App Version is High Sierra or newer + if os_data.os_conversion.os_to_kernel(app_version) < os_data.os_data.high_sierra: + continue + + results = self._parse_sharedsupport_version(Path(APPLICATION_SEARCH_PATH) / Path(application)/ Path("Contents/SharedSupport/SharedSupport.dmg")) + if results[0] is not None: + app_sdk = results[0] + if results[1] is not None: + app_version = results[1] + + application_list.update({ + application: { + "Short Name": clean_name, + "Version": app_version, + "Build": app_sdk, + "Path": application, + } + }) + + # Sort Applications by version + application_list = {k: v for k, v in sorted(application_list.items(), key=lambda item: item[1]["Version"])} + return application_list + + + def _parse_sharedsupport_version(self, sharedsupport_path: Path): + """ + Determine true version of macOS installer by parsing SharedSupport.dmg + This is required due to Info.plist reporting the application version, not the OS version + + Parameters: + sharedsupport_path (Path): Path to SharedSupport.dmg + + Returns: + tuple: Tuple containing the build and OS version + """ + + detected_build: str = None + detected_os: str = None + + if not sharedsupport_path.exists(): + return (detected_build, detected_os) + + if not sharedsupport_path.name.endswith(".dmg"): + return (detected_build, detected_os) + + + # Create temporary directory to extract SharedSupport.dmg to + with tempfile.TemporaryDirectory() as tmpdir: + + output = subprocess.run( + [ + "hdiutil", "attach", "-noverify", sharedsupport_path, + "-mountpoint", tmpdir, + "-nobrowse", + ], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) + + if output.returncode != 0: + return (detected_build, detected_os) + + ss_info = Path(SFR_SOFTWARE_UPDATE_PATH) + + if Path(tmpdir / ss_info).exists(): + plist = plistlib.load((tmpdir / ss_info).open("rb")) + if "Assets" in plist: + if "Build" in plist["Assets"][0]: + detected_build = plist["Assets"][0]["Build"] + if "OSVersion" in plist["Assets"][0]: + detected_os = plist["Assets"][0]["OSVersion"] + + # Unmount SharedSupport.dmg + subprocess.run(["hdiutil", "detach", tmpdir], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + return (detected_build, detected_os) \ No newline at end of file