import platform import subprocess import tarfile import tempfile import webbrowser import zipfile from pathlib import Path from queue import Queue import json from collections import deque from itertools import product from functools import partial import psutil from PyQt5 import QtCore, QtGui, QtNetwork, QtWebEngineWidgets, QtWidgets from PyQt5.uic import loadUi try: import win32api import win32file except ImportError: if platform.system()=='Windows': raise #require win32api and win32file on windows from validators.location_validator import Location_Validator from .menu_iterator import MenuIterator class InstallerWizard(QtWidgets.QWizard): def __init__(self, parent=None): super().__init__(parent) loadUi(Path(__file__).parent / "installer_wizard.ui", baseinstance=self) self.setWindowIcon(QtWidgets.QApplication.instance().windowIcon()) self.intro_page.validatePage = self._requirements_check self.selection_page.initializePage = self._load_tools self.currentIdChanged.connect self.intro.anchorClicked.connect(self._link_clicked) self.license.anchorClicked.connect(self._link_clicked) self.install_location.setValidator(Location_Validator(parent=self)) self.location_page.registerField("Location*", self.install_location) self.browse_button.clicked.connect(self._select_location) self.selection_menu.itemClicked.connect(self._open_homepage) self.selection_menu.itemCollapsed.connect(lambda x: self.selection_menu.resizeColumnToContents(0)) self.selection_menu.itemExpanded.connect(lambda x: self.selection_menu.resizeColumnToContents(0)) #Change button text on license page self.license_page.setButtonText(QtWidgets.QWizard.NextButton, "Agree") self.license_page.setButtonText(QtWidgets.QWizard.CancelButton, "Decline") self._network_manager = QtNetwork.QNetworkAccessManager(parent=QtWidgets.QApplication.instance()) self._thread_pool = QtCore.QThreadPool.globalInstance() self._project_page=Path("https://github.com/norweeg/portable-computing-toolkit-installer/") def _link_clicked(self, address): """Receives anchorClicked signal from the intro or license text browsers and opens the address in an external web browser. Opens an error message window if it is not able to do so. Arguments: address {QtCore.QUrl} -- The URL from the received signal """ try: webbrowser.open(address.toString()) except webbrowser.Error as e: self._display_error("Unable to launch external browser",e) except Exception as e: self._display_error("An error has occurred",e) def _select_location(self): """Opens a QFileDialog in either the root of the most-recently connected non-network, non-cd drive or the user's home directory, if no suitable external device is found """ if platform.system() == 'Windows': drives = [drive for drive in win32api.GetLogicalDriveStrings().split('\0') if drive] dialog_location = drives.pop() while (win32file.GetDriveType(dialog_location) == win32file.DRIVE_CDROM or win32file.GetDriveType(dialog_location) == win32file.DRIVE_REMOTE): dialog_location = drives.pop() if dialog_location == "C:\\": dialog_location = Path.home() break else: dialog_location = Path.home() selected_location = QtWidgets.QFileDialog.getExistingDirectory(parent=self,caption="Select Location", directory=str(dialog_location), options=(QtWidgets.QFileDialog.ShowDirsOnly)) self.install_location.clear() self.install_location.insert(selected_location) def _requirements_check(self): """Checks that the minimum system requirements are met. Opens error message and resets the wizard if they are not. Minimum Requirements: Windows 7 or greater (64-bit) """ process_dialog = QtWidgets.QProgressDialog("Checking System Requirements...", str(), 0, 100, self, QtCore.Qt.Dialog) process_dialog.setAutoClose(True) process_dialog.setAutoReset(True) process_dialog.setCancelButton(None) if self._network_manager.networkAccessible(): request = QtNetwork.QNetworkRequest(QtCore.QUrl("https://www.gnu.org/licenses/gpl-3.0-standalone.html")) request.setAttribute(QtNetwork.QNetworkRequest.CacheLoadControlAttribute, QtNetwork.QNetworkRequest.AlwaysNetwork) reply = self._network_manager.get(request) reply.downloadProgress.connect(lambda received, total: process_dialog.setMaximum(total)) reply.downloadProgress.connect(lambda received, total: process_dialog.setValue(received)) process_dialog.show() else: self._display_error("This installer requires an active internet connection, but you do not appear to be online") return False if not platform.system() == 'Windows' or int(platform.release()) < 7: reply.abort() process_dialog.cancel() self._display_error("This toolkit requires Microsoft Windows 7 or newer") return False elif not platform.machine() == 'AMD64': reply.abort() process_dialog.cancel() self._display_error("Parts of this toolkit require a 64-bit processor. Please open an issue on Github if you absolutely need it all 32-bit")\ .buttonClicked.connect(lambda: webbrowser.open_new_tab(self._project_page/"issues")) return False else: while reply.isRunning(): QtWidgets.QApplication.instance().processEvents() QtCore.QThread.currentThread().msleep(100) if not reply.error(): self.license.setHtml(bytearray(reply.readAll()).decode("utf-8")) return True else: self._display_error(f"Encountered a {QtCore.QMetaEnum.valueToKey(reply.error())} while testing Internet connectivity") return False def _display_error(self, message, details = None): """Displays an error message. Arguments: message {str} -- The contents of the error message to be displayed Keyword Arguments: details {Any} -- Any string-printable object that will be set in the more details, typically an Exception object (default: {None}) """ message=QtWidgets.QMessageBox(icon=QtWidgets.QMessageBox.Critical,parent=self,text=message) if details: message.setDetailedText(str(details)) message.show() return message def _load_tools(self): """Loads tool data from supported_tools.json hosted on the project Github page """ request = QtNetwork.QNetworkRequest(QtCore.QUrl("https://raw.githubusercontent.com/norweeg/portable-computing-toolkit-installer/initial_dev/portable_computing_toolkit_installer/resources/supported_tools.json")) request.setAttribute(QtNetwork.QNetworkRequest.CacheLoadControlAttribute, QtNetwork.QNetworkRequest.AlwaysNetwork) reply = self._network_manager.get(request) while reply.isRunning(): QtWidgets.QApplication.instance().processEvents() QtCore.QThread.currentThread().msleep(100) if not reply.error(): try: self.__tools__ = json.loads(bytearray(reply.readAll())) except json.JSONDecodeError as e: self._display_error(f"Unable to decode {request.url.toString()}", e).accepted.connect(self.back) else: self._populate_menu() else: self._display_error(f"Encountered an error while loading supported tools from {request.url().toString()}", reply.errorString()).finished.connect(lambda x: self.back()) def _populate_menu(self): """Populates the selection menu with items loaded from supported_tools.json """ for tool in sorted(self.__tools__, key = lambda x: x["category"] + f".{x['name']}"): placement = deque([level.capitalize().strip() for level in tool["category"].split(".") if level] + [tool["name"]]) root = self.selection_menu.invisibleRootItem() self._add_menu_item(placement, root, tool) self.selection_menu.resizeColumnToContents(0) self.selection_menu.resizeColumnToContents(1) def _add_menu_item(self, placement, root, tool): current_level = placement.popleft() children = [root.child(i) for i in range(root.childCount())] if any([item is child for item, child in product(self.selection_menu.findItems(current_level, QtCore.Qt.MatchFixedString|QtCore.Qt.MatchCaseSensitive|QtCore.Qt.MatchRecursive), children)]): root = list(filter(lambda child: child.text(0) == current_level, children)).pop() self._add_menu_item(placement, root, tool) else: new_item = QtWidgets.QTreeWidgetItem(root, QtWidgets.QTreeWidgetItem.Type) new_item.setText(0, current_level) new_item.setFlags(QtCore.Qt.ItemIsSelectable|QtCore.Qt.ItemIsUserCheckable|QtCore.Qt.ItemIsEnabled) new_item.setCheckState(0, QtCore.Qt.Unchecked) if not placement: #all items popped from placement new_item.setFlags(new_item.flags()|QtCore.Qt.ItemNeverHasChildren) new_item.setData(1, QtCore.Qt.DecorationRole, QtGui.QIcon(str(Path(__file__).parent.parent/"resources/icons/internet-web-browser-4.png"))) new_item.setData(1, QtCore.Qt.ToolTipRole, tool["homepage"]) try: self._get_icon(new_item, tool["icon url"]) except KeyError: self._get_icon(new_item) try: if tool["depends on"] == current_level: #tool depends on itself makes it mandatory new_item.setFlags(new_item.flags()^QtCore.Qt.ItemIsUserCheckable) new_item.setCheckState(0, QtCore.Qt.Checked) except KeyError: pass else: new_item.setFlags(new_item.flags()|QtCore.Qt.ItemIsAutoTristate) new_item.setExpanded(False) new_item.addChild(self._add_menu_item(placement, new_item, tool)) return new_item @QtCore.pyqtSlot("QTreeWidgetItem*", "int") def _open_homepage(self, item, column): """Receives the itemClicked signal from the selection_menu QTreeWidget and if the user has clicked a homepage, opens it in a new browser tab. Arguments: item {QTreeWidgetItem} -- The item that was clicked column {int} -- The index of the column of the item that was clicked """ try: #if the item has a valid URL, open it, otherwise do nothing if column and QtCore.QUrl(item.data(column, QtCore.Qt.ToolTipRole), QtCore.QUrl.StrictMode).scheme().startswith("http"): webbrowser.open_new_tab(item.data(column, QtCore.Qt.ToolTipRole)) except: pass def _get_icon(self, tree_item, icon_url = None): """Given a QTreeWidgetItem, this will initiate a request to download an icon for it. If icon_url is specified, that will be fetched, otherwise the favicon of the tree item's homepage. Arguments: tree_item {QTreeWidgetItem} -- a tree item with a specified homepage to set an icon on Keyword Arguments: icon_url {str} -- A string representing the url of the icon to fetch instead of the favicon of the homepage (default: {None}) """ if not icon_url: icon_url = QtCore.QUrl(f"http://www.google.com/s2/favicons?domain={QtCore.QUrl(tree_item.data(1, QtCore.Qt.ToolTipRole)).toString()}") icon_request = self._network_manager.get(QtNetwork.QNetworkRequest(QtCore.QUrl(icon_url))) #when download is complete, validates request returned without error and sets icon icon_request.finished.connect(partial(self._set_icon, tree_item, icon_request)) def _set_icon(self, tree_item, request): """Validates a network request and sets its result as the icon of a QTreeWidgetItem. Arguments: tree_item {QTreeWidgetItem} -- A tree widget item to set the icon of request {QNetworkReply} -- The results of the HTTP GET request whose data will be used as the icon for tree_item """ if not request.error(): pixmap = QtGui.QPixmap() pixmap.loadFromData(request.readAll()) tree_item.setIcon(0, QtGui.QIcon(pixmap)) def _get_selections(self): """Returns a subset of the supported tools whose corresponding item was selected by the user. """ selected_iterator = MenuIterator(self.selection_menu, QtWidgets.QTreeWidgetItemIterator.Checked|QtWidgets.QTreeWidgetItemIterator.NoChildren) try: selected_tools = {item.text(0):self.__tools__[item.text(0)] for item in selected_iterator} except KeyError as e: self._display_error(f"Unknown tool selected.", e) return selected_tools