280 lines
15 KiB
Python
280 lines
15 KiB
Python
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.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/")
|
|
|
|
@QtCore.pyqtSlot("QUrl")
|
|
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)
|
|
|
|
@QtCore.pyqtSlot()
|
|
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
|
|
"""
|
|
process_dialog = QtWidgets.QProgressDialog("Loading tool information...", str(), 0, 100, self, QtCore.Qt.Dialog)
|
|
process_dialog.setAutoClose(True)
|
|
process_dialog.setAutoReset(True)
|
|
process_dialog.setCancelButton(None)
|
|
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)
|
|
reply.downloadProgress.connect(lambda received, total: process_dialog.setMaximum(total))
|
|
reply.downloadProgress.connect(lambda received, total: process_dialog.setValue(received))
|
|
process_dialog.show()
|
|
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()
|
|
self.selection_menu.itemSelectionChanged.connect(self._enforce_dependencies)
|
|
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
|
|
"""
|
|
process_dialog = QtWidgets.QProgressDialog("Loading tool information...", str(), 0, len(self.__tools__)-1, self, QtCore.Qt.Dialog)
|
|
process_dialog.setAutoClose(True)
|
|
process_dialog.setAutoReset(True)
|
|
process_dialog.setCancelButton(None)
|
|
process_dialog.show()
|
|
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)
|
|
process_dialog.setValue(process_dialog.value() + 1)
|
|
|
|
for item in [self.selection_menu.topLevelItem(i) for i in range(self.selection_menu.topLevelItemCount())]:
|
|
item.setExpanded(True)
|
|
|
|
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.
|
|
"""
|
|
return list(MenuIterator(self.selection_menu, QtWidgets.QTreeWidgetItemIterator.Checked|QtWidgets.QTreeWidgetItemIterator.NoChildren))
|
|
|
|
@QtCore.pyqtSlot()
|
|
def _enforce_dependencies(self):
|
|
for tool in self._get_selections():
|
|
try:
|
|
dependencies = tool["depends on"].split(",")
|
|
except KeyError:
|
|
continue
|
|
else:
|
|
for dependency in [self.selection_menu.findItems(item, QtCore.Qt.MatchFixedString|QtCore.Qt.MatchCaseSensitive|QtCore.Qt.MatchRecursive) for item in dependencies]:
|
|
dependency.setSelected(True)
|
|
dependency.setHidden(False)
|
|
|