From 3ef6e4a85360fed5be0a697b7b27ffd9d68d6acf Mon Sep 17 00:00:00 2001 From: Mykola Grymalyuk Date: Sun, 7 May 2023 17:41:46 -0600 Subject: [PATCH] GUI: Implement download GUI class Unifies all download UIs --- resources/network_handler.py | 7 +- resources/utilities.py | 38 +++ resources/wx_gui/gui_download.py | 80 +++++++ resources/wx_gui/gui_macos_installer.py | 298 ++++++++++++++++++++++++ resources/wx_gui/gui_main_menu.py | 9 +- resources/wx_gui/gui_sys_patch.py | 52 ++--- 6 files changed, 445 insertions(+), 39 deletions(-) create mode 100644 resources/wx_gui/gui_download.py create mode 100644 resources/wx_gui/gui_macos_installer.py diff --git a/resources/network_handler.py b/resources/network_handler.py index f89bcc91d..d236a24b8 100644 --- a/resources/network_handler.py +++ b/resources/network_handler.py @@ -9,6 +9,7 @@ import threading import logging import enum import hashlib +import atexit from pathlib import Path from resources import utilities @@ -340,6 +341,7 @@ class DownloadObject: response = NetworkUtilities().get(self.url, stream=True, timeout=10) with open(self.filepath, 'wb') as file: + atexit.register(self.stop) for i, chunk in enumerate(response.iter_content(1024 * 1024 * 4)): if self.should_stop: raise Exception("Download stopped") @@ -407,7 +409,10 @@ class DownloadObject: if self.total_file_size == 0.0: logging.error("- File size is 0, cannot calculate time remaining") return -1 - return (self.total_file_size - self.downloaded_file_size) / self.get_speed() + speed = self.get_speed() + if speed <= 0: + return -1 + return (self.total_file_size - self.downloaded_file_size) / speed def get_file_size(self) -> float: diff --git a/resources/utilities.py b/resources/utilities.py index e2b4b903c..e1f25a17d 100644 --- a/resources/utilities.py +++ b/resources/utilities.py @@ -48,6 +48,44 @@ def human_fmt(num): return "%.1f %s" % (num, "EB") +def seconds_to_readable_time(seconds) -> str: + """ + Convert seconds to a readable time format + + Parameters: + seconds (int | float | str): Seconds to convert + + Returns: + str: Readable time format + """ + seconds = int(seconds) + time = "" + + if seconds == 0: + return "Done" + if seconds < 0: + return "Indeterminate" + + years, seconds = divmod(seconds, 31536000) + days, seconds = divmod(seconds, 86400) + hours, seconds = divmod(seconds, 3600) + minutes, seconds = divmod(seconds, 60) + + if years > 0: + return "Over a year" + if days > 0: + if days > 31: + return "Over a month" + time += f"{days}d " + if hours > 0: + time += f"{hours}h " + if minutes > 0: + time += f"{minutes}m " + if seconds > 0: + time += f"{seconds}s" + return time + + def header(lines): lines = [i for i in lines if i is not None] total_length = len(max(lines, key=len)) + 4 diff --git a/resources/wx_gui/gui_download.py b/resources/wx_gui/gui_download.py new file mode 100644 index 000000000..59a6b6b4f --- /dev/null +++ b/resources/wx_gui/gui_download.py @@ -0,0 +1,80 @@ + +import wx + +from resources import constants, network_handler, utilities + + +class DownloadFrame(wx.Frame): + """ + Update provided frame with download stats + """ + def __init__(self, parent: wx.Frame, title: str, global_constants: constants.Constants, download_obj: network_handler.DownloadObject, item_name: str, screen_location: tuple = None): + + self.constants: constants.Constants = global_constants + self.title: str = title + self.parent: wx.Frame = parent + self.download_obj: network_handler.DownloadObject = download_obj + self.item_name: str = item_name + + self.frame_modal = wx.Dialog(parent, title=title, size=(400, 200)) + + self._generate_elements(self.frame_modal) + + + + def _generate_elements(self, frame: wx.Frame = None) -> None: + + frame = self if not frame else frame + + title_label = wx.StaticText(frame, label=f"Downloading: {self.item_name}", pos=(-1,5)) + title_label.SetFont(wx.Font(19, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD, False, ".AppleSystemUIFont")) + title_label.Center(wx.HORIZONTAL) + + label_amount = wx.StaticText(frame, label="0.00 B downloaded of 0.00B (0.00%)", pos=(-1, title_label.GetPosition()[1] + title_label.GetSize()[1] + 5)) + label_amount.SetFont(wx.Font(13, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, ".AppleSystemUIFont")) + label_amount.Center(wx.HORIZONTAL) + + label_speed = wx.StaticText(frame, label="Average download speed: Unknown", pos=(-1, label_amount.GetPosition()[1] + label_amount.GetSize()[1] + 5)) + label_speed.SetFont(wx.Font(13, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, ".AppleSystemUIFont")) + label_speed.Center(wx.HORIZONTAL) + + label_est_time = wx.StaticText(frame, label="Estimated time remaining: Unknown", pos=(-1, label_speed.GetPosition()[1] + label_speed.GetSize()[1] + 5)) + label_est_time.SetFont(wx.Font(13, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, ".AppleSystemUIFont")) + label_est_time.Center(wx.HORIZONTAL) + + progress_bar = wx.Gauge(frame, range=100, pos=(-1, label_est_time.GetPosition()[1] + label_est_time.GetSize()[1] + 5), size=(300, 20)) + progress_bar.Center(wx.HORIZONTAL) + + # Set size of frame + frame.SetSize((-1, progress_bar.GetPosition()[1] + progress_bar.GetSize()[1] + 40)) + frame.ShowWindowModal() + + self.download_obj.download() + while self.download_obj.is_active(): + if self.download_obj.total_file_size == -1: + amount_str = f"{utilities.human_fmt(self.download_obj.downloaded_file_size)} downloaded" + else: + amount_str = f"{utilities.human_fmt(self.download_obj.downloaded_file_size)} downloaded of {utilities.human_fmt(self.download_obj.total_file_size)} ({self.download_obj.get_percent():.2f}%)" + label_amount.SetLabel(amount_str) + label_amount.Center(wx.HORIZONTAL) + + label_speed.SetLabel( + f"Average download speed: {utilities.human_fmt(self.download_obj.get_speed())}/s" + ) + + label_est_time.SetLabel( + f"Estimated time remaining: {utilities.seconds_to_readable_time(self.download_obj.get_time_remaining())}" + ) + + + progress_bar.SetValue(int(self.download_obj.get_percent())) + wx.GetApp().Yield() + + if self.download_obj.download_complete is False: + wx.MessageBox(f"Download failed: \n{self.download_obj.error_msg}", "Error", wx.OK | wx.ICON_ERROR) + + frame.Destroy() + + + + diff --git a/resources/wx_gui/gui_macos_installer.py b/resources/wx_gui/gui_macos_installer.py new file mode 100644 index 000000000..999ac3897 --- /dev/null +++ b/resources/wx_gui/gui_macos_installer.py @@ -0,0 +1,298 @@ +import wx +import logging +import threading + +from pathlib import Path + +from resources.wx_gui import gui_main_menu, gui_support, gui_download +from resources import ( + constants, + macos_installer_handler, + utilities, + network_handler, + integrity_verification +) + + +class macOSInstallerFrame(wx.Frame): + """ + Create a frame for downloading and creating macOS installers + Uses a Modal Dialog for smoother transition from other frames + """ + def __init__(self, parent: wx.Frame, title: str, global_constants: constants.Constants, screen_location: tuple = None): + # super(macOSInstallerFrame, self).__init__(parent, title=title, size=(350, 200)) + + self.constants: constants.Constants = global_constants + self.title: str = title + self.parent: wx.Frame = parent + + self.available_installers = None + self.available_installers_latest = None + + self.frame_modal = wx.Dialog(parent, title=title, size=(330, 200)) + + self._generate_elements(self.frame_modal) + self.frame_modal.ShowWindowModal() + + + def _generate_elements(self, frame: wx.Frame = None) -> None: + """ + Format: + - Title: Create macOS Installer + - Button: Download macOS Installer + - Button: Use existing macOS Installer + - Button: Return to Main Menu + """ + + frame = self if not frame else frame + + title_label = wx.StaticText(frame, label="Create macOS Installer", pos=(-1,5)) + title_label.SetFont(wx.Font(19, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD, False, ".AppleSystemUIFont")) + title_label.Center(wx.HORIZONTAL) + + # Button: Download macOS Installer + download_button = wx.Button(frame, label="Download macOS Installer", pos=(-1, title_label.GetPosition()[1] + title_label.GetSize()[1] + 5), size=(200, 30)) + download_button.Bind(wx.EVT_BUTTON, self.on_download) + download_button.Center(wx.HORIZONTAL) + + # Button: Use existing macOS Installer + existing_button = wx.Button(frame, label="Use existing macOS Installer", pos=(-1, download_button.GetPosition()[1] + download_button.GetSize()[1] - 5), size=(200, 30)) + existing_button.Bind(wx.EVT_BUTTON, self.on_existing) + existing_button.Center(wx.HORIZONTAL) + + # Button: Return to Main Menu + return_button = wx.Button(frame, label="Return to Main Menu", pos=(-1, existing_button.GetPosition()[1] + existing_button.GetSize()[1] + 5), size=(150, 30)) + return_button.Bind(wx.EVT_BUTTON, self.on_return) + return_button.Center(wx.HORIZONTAL) + + # Set size of frame + frame.SetSize((-1, return_button.GetPosition()[1] + return_button.GetSize()[1] + 40)) + + + def _generate_catalog_frame(self) -> wx.Frame: + super(macOSInstallerFrame, self).__init__(None, title=self.title, size=(300, 200)) + self.SetPosition((self.parent.GetPosition()[0], self.parent.GetPosition()[1])) + + # Title: Pulling installer catalog + title_label = wx.StaticText(self, label="Pulling installer catalog", pos=(-1,5)) + title_label.SetFont(wx.Font(19, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD, False, ".AppleSystemUIFont")) + title_label.Center(wx.HORIZONTAL) + + # Progress bar + progress_bar = wx.Gauge(self, range=100, pos=(-1, title_label.GetPosition()[1] + title_label.GetSize()[1] + 5), size=(250, 30)) + progress_bar.Center(wx.HORIZONTAL) + progress_bar.Pulse() + + # Set size of frame + self.SetSize((-1, progress_bar.GetPosition()[1] + progress_bar.GetSize()[1] + 40)) + + self.Show() + + # Grab installer catalog + def fetch_installers(): + 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 + + thread = threading.Thread(target=fetch_installers) + thread.start() + + while thread.is_alive(): + wx.Yield() + + progress_bar.Hide() + self._display_available_installers() + + + def _display_available_installers(self, event: wx.Event = None, show_full: bool = False) -> None: + self.frame_modal.Destroy() + dialog = wx.Dialog(self, title="Select macOS Installer", size=(300, 200)) + + # Title: Select macOS Installer + title_label = wx.StaticText(dialog, label="Select macOS Installer", pos=(-1,5)) + title_label.SetFont(wx.Font(19, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD, False, ".AppleSystemUIFont")) + title_label.Center(wx.HORIZONTAL) + + # Subtitle: Installers currently available from Apple: + subtitle_label = wx.StaticText(dialog, label="Installers currently available from Apple:", pos=(-1, title_label.GetPosition()[1] + title_label.GetSize()[1] + 5)) + subtitle_label.SetFont(wx.Font(13, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, ".AppleSystemUIFont")) + subtitle_label.Center(wx.HORIZONTAL) + + # List of installers + installers = self.available_installers_latest if show_full is False else self.available_installers + if installers: + spacer = 0 + for app in installers: + logging.info(f"macOS {installers[app]['Version']} ({installers[app]['Build']}):\n - Size: {utilities.human_fmt(installers[app]['Size'])}\n - Source: {installers[app]['Source']}\n - Variant: {installers[app]['Variant']}\n - Link: {installers[app]['Link']}\n") + extra = " Beta" if installers[app]['Variant'] in ["DeveloperSeed" , "PublicSeed"] else "" + + installer_button = wx.Button(dialog, label=f"macOS {installers[app]['Version']}{extra} ({installers[app]['Build']} - {utilities.human_fmt(installers[app]['Size'])})", pos=(-1, subtitle_label.GetPosition()[1] + subtitle_label.GetSize()[1] + 5 + spacer), size=(270, 30)) + installer_button.Bind(wx.EVT_BUTTON, lambda event, temp=app: self.on_download_installer(installers[temp])) + installer_button.Center(wx.HORIZONTAL) + spacer += 25 + + # Show all available installers + show_all_button = wx.Button(dialog, label="Show all available installers" if show_full is False else "Show only latest installers", pos=(-1, installer_button.GetPosition()[1] + installer_button.GetSize()[1]), size=(180, 30)) + show_all_button.Bind(wx.EVT_BUTTON, lambda event: self._display_available_installers(event, not show_full)) + show_all_button.Center(wx.HORIZONTAL) + + # Return to Main Menu + return_button = wx.Button(dialog, label="Return to Main Menu", pos=(-1, show_all_button.GetPosition()[1] + show_all_button.GetSize()[1] - 7), size=(150, 30)) + return_button.Bind(wx.EVT_BUTTON, self.on_return_to_main_menu) + return_button.Center(wx.HORIZONTAL) + + # Set size of frame + dialog.SetSize((-1, return_button.GetPosition()[1] + return_button.GetSize()[1] + 40)) + dialog.ShowWindowModal() + self.frame_modal = dialog + + + def on_download_installer(self, app: dict) -> None: + self.frame_modal.Close() + + download_obj = network_handler.DownloadObject(app['Link'], self.constants.payload_path / "InstallAssistant.pkg") + + gui_download.DownloadFrame( + self, + title=self.title, + global_constants=self.constants, + screen_location=self.GetScreenPosition(), + download_obj=download_obj, + item_name=f"macOS {app['Version']} ({app['Build']})", + ) + + if download_obj.download_complete is False: + self.on_return_to_main_menu() + return + + self._validate_installer(app['integrity']) + + + def _validate_installer(self, chunklist_link: str) -> None: + + self.SetSize((300, 200)) + for child in self.GetChildren(): + child.Destroy() + + # Title: Validating macOS Installer + title_label = wx.StaticText(self, label="Validating macOS Installer", pos=(-1,5)) + title_label.SetFont(wx.Font(19, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD, False, ".AppleSystemUIFont")) + title_label.Center(wx.HORIZONTAL) + + # Label: Validating chunk 0 of 0 + chunk_label = wx.StaticText(self, label="Validating chunk 0 of 0", pos=(-1, title_label.GetPosition()[1] + title_label.GetSize()[1] + 5)) + chunk_label.SetFont(wx.Font(13, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, ".AppleSystemUIFont")) + chunk_label.Center(wx.HORIZONTAL) + + # Progress bar + progress_bar = wx.Gauge(self, range=100, pos=(-1, chunk_label.GetPosition()[1] + chunk_label.GetSize()[1] + 5), size=(270, 30)) + progress_bar.Center(wx.HORIZONTAL) + + + # Set size of frame + self.SetSize((-1, progress_bar.GetPosition()[1] + progress_bar.GetSize()[1] + 40)) + self.Show() + + chunklist_stream = network_handler.NetworkUtilities().get(chunklist_link).content + if chunklist_stream: + utilities.disable_sleep_while_running() + chunk_obj = integrity_verification.ChunklistVerification(self.constants.payload_path / Path("InstallAssistant.pkg"), chunklist_stream) + if chunk_obj.chunks: + progress_bar.SetValue(chunk_obj.current_chunk) + progress_bar.SetRange(chunk_obj.total_chunks) + + wx.App.Get().Yield() + chunk_obj.validate() + + while chunk_obj.status == integrity_verification.ChunklistStatus.IN_PROGRESS: + progress_bar.SetValue(chunk_obj.current_chunk) + chunk_label.SetLabel(f"Validating chunk {chunk_obj.current_chunk} of {chunk_obj.total_chunks}") + chunk_label.Center(wx.HORIZONTAL) + wx.App.Get().Yield() + + if chunk_obj.status == integrity_verification.ChunklistStatus.FAILURE: + wx.MessageBox("Chunklist validation failed.\n\nThis generally happens when downloading on unstable connections such as WiFi or cellular.\n\nPlease try redownloading again on a stable connection (ie. Ethernet)", "Corrupted Installer!", wx.OK | wx.ICON_ERROR) + self.on_return_to_main_menu() + return + + + # Extract installer + title_label.SetLabel("Extracting macOS Installer") + title_label.Center(wx.HORIZONTAL) + + chunk_label.SetLabel("May take a few minutes...") + chunk_label.Center(wx.HORIZONTAL) + + progress_bar.Pulse() + + # Start thread to extract installer + self.result = False + def extract_installer(): + self.result = macos_installer_handler.InstallerCreation().install_macOS_installer(self.constants.payload_path) + + thread = threading.Thread(target=extract_installer) + thread.start() + + # Show frame + self.Show() + + # Wait for thread to finish + while thread.is_alive(): + wx.Yield() + + if self.result is False: + progress_bar.SetValue(0) + chunk_label.SetLabel("Failed to extract macOS installer") + chunk_label.Center(wx.HORIZONTAL) + wx.MessageBox("An error occurred while extracting the macOS installer. Could be due to a corrupted installer", "Error", wx.OK | wx.ICON_ERROR) + + progress_bar.Hide() + chunk_label.SetLabel("Successfully extracted macOS installer") + chunk_label.Center(wx.HORIZONTAL) + + # Create macOS Installer button + create_installer_button = wx.Button(self, label="Create macOS Installer", pos=(-1, progress_bar.GetPosition()[1]), size=(170, 30)) + create_installer_button.Bind(wx.EVT_BUTTON, self.on_existing) + create_installer_button.Center(wx.HORIZONTAL) + + # Return to main menu button + return_button = wx.Button(self, label="Return to Main Menu", pos=(-1, create_installer_button.GetPosition()[1] + create_installer_button.GetSize()[1]), size=(150, 30)) + return_button.Bind(wx.EVT_BUTTON, self.on_return_to_main_menu) + return_button.Center(wx.HORIZONTAL) + + # Set size of frame + self.SetSize((-1, return_button.GetPosition()[1] + return_button.GetSize()[1] + 40)) + + # Show frame + self.Show() + + result = wx.MessageBox("Finished extracting the installer, would you like to continue and create a macOS installer?", "Create macOS Installer?", wx.YES_NO | wx.ICON_QUESTION) + if result == wx.YES: + self.on_existing() + + + def on_download(self, event: wx.Event) -> None: + self.frame_modal.Close() + self.parent.Hide() + self._generate_catalog_frame() + self.parent.Close() + + def on_existing(self, event: wx.Event = None) -> None: + pass + + def on_return(self, event: wx.Event) -> None: + self.frame_modal.Close() + + def on_return_to_main_menu(self, event: wx.Event = None): + if self.frame_modal: + self.frame_modal.Hide() + main_menu_frame = gui_main_menu.MainMenu( + None, + title=self.title, + global_constants=self.constants, + screen_location=self.GetScreenPosition() + ) + main_menu_frame.Show() + if self.frame_modal: + self.frame_modal.Destroy() + self.Destroy() \ No newline at end of file diff --git a/resources/wx_gui/gui_main_menu.py b/resources/wx_gui/gui_main_menu.py index b31f46a1c..3542f9d8e 100644 --- a/resources/wx_gui/gui_main_menu.py +++ b/resources/wx_gui/gui_main_menu.py @@ -4,6 +4,7 @@ from resources.wx_gui import ( gui_sys_patch, gui_support, gui_help, + gui_macos_installer, ) from resources import constants @@ -93,7 +94,13 @@ class MainMenu(wx.Frame): def on_create_macos_installer(self, event: wx.Event = None): - pass + gui_macos_installer.macOSInstallerFrame( + parent=self, + title=self.title, + global_constants=self.constants, + screen_location=self.GetPosition() + ) + def on_settings(self, event: wx.Event = None): pass diff --git a/resources/wx_gui/gui_sys_patch.py b/resources/wx_gui/gui_sys_patch.py index 8c2bcbbb1..7e8bbfe08 100644 --- a/resources/wx_gui/gui_sys_patch.py +++ b/resources/wx_gui/gui_sys_patch.py @@ -20,6 +20,7 @@ from resources.sys_patch import ( from resources.wx_gui import ( gui_main_menu, gui_support, + gui_download, ) class SysPatchMenu(wx.Frame): @@ -89,46 +90,23 @@ class SysPatchMenu(wx.Frame): # KDK is already downloaded return True - kdk_download_obj.download() + gui_download.DownloadFrame( + self, + title=self.title, + global_constants=self.constants, + screen_location=self.GetScreenPosition(), + download_obj=kdk_download_obj, + item_name=f"KDK Build {self.kdk_obj.kdk_url_build}" + ) + if kdk_download_obj.download_complete is False: + return False - header.SetLabel(f"Downloading KDK Build: {self.kdk_obj.kdk_url_build}") + header.SetLabel(f"Validating KDK: {self.kdk_obj.kdk_url_build}") header.Center(wx.HORIZONTAL) - progress_bar.SetValue(0) - # Set below developer note - progress_bar.SetPosition( - wx.Point( - subheader.GetPosition().x, - subheader.GetPosition().y + subheader.GetSize().height + 30 - ) - ) - progress_bar.Center(wx.HORIZONTAL) - progress_bar.Show() - - developer_note = wx.StaticText(frame, label="Starting shortly", pos=(-1, progress_bar.GetPosition().y - 23)) - developer_note.SetFont(wx.Font(13, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, ".AppleSystemUIFont")) - developer_note.Center(wx.HORIZONTAL) - - frame.SetSize(-1, progress_bar.GetPosition().y + progress_bar.GetSize().height + 40) - - while kdk_download_obj.is_active(): - subheader.SetLabel(f"{utilities.human_fmt(kdk_download_obj.downloaded_file_size)} downloaded of {utilities.human_fmt(kdk_download_obj.total_file_size)} ({kdk_download_obj.get_percent():.2f}%)") - subheader.Center(wx.HORIZONTAL) - developer_note.SetLabel( - f"Average download speed: {utilities.human_fmt(kdk_download_obj.get_speed())}/s" - ) - developer_note.Center(wx.HORIZONTAL) - - progress_bar.SetValue(int(kdk_download_obj.get_percent())) - wx.GetApp().Yield() - - if kdk_download_obj.download_complete is False: - logging.info("Failed to download KDK") - logging.info(kdk_download_obj.error_msg) - # wx.MessageBox(f"KDK download failed: {kdk_download_obj.error_msg}", "Error", wx.OK | wx.ICON_ERROR) - msg = wx.MessageDialog(frame, f"KDK download failed: {kdk_download_obj.error_msg}", "Error", wx.OK | wx.ICON_ERROR) - msg.ShowModal() - return False + subheader.SetLabel("Checking if checksum is valid...") + subheader.Center(wx.HORIZONTAL) + wx.GetApp().Yield() if self.kdk_obj.validate_kdk_checksum() is False: logging.error("KDK checksum validation failed")