Compare commits

35 Commits

Author SHA1 Message Date
Brennen Raimer
d8bd206856 fully worked around bug in Qt 2019-05-25 12:27:18 -04:00
Brennen Raimer
fdf8e7d0a2 fixed logic error that caused roots of NTFS drives to return as not NTFS 2019-05-25 11:30:34 -04:00
Brennen Raimer
76d1121206 mostly worked around annoying Qt bug 2019-05-24 18:23:51 -04:00
Brennen Raimer
806941b3de trying be a little more platform independant 2019-05-09 16:51:38 -04:00
Brennen Raimer
ea96dd3669 some cleanup 2019-05-06 23:37:00 -04:00
Brennen Raimer
9280a7b7cf add winbuilds to tools 2019-05-06 16:36:19 -04:00
Brennen Raimer
7ce680b2fd checkpoint 2019-05-05 20:29:14 -04:00
Brennen Raimer
20801e9872 relaxing requirements check since most tools don't actually require ntfs 2019-05-05 16:57:28 -04:00
Brennen Raimer
9946330fa5 allow continuing on unsupported platform if desired 2019-05-04 15:38:03 -04:00
Brennen Raimer
1c39435ded fixed inconsistant formatting of introduction 2019-05-04 15:21:05 -04:00
Brennen Raimer
4c48a6b827 checkpoint. continued work on downloader 2019-05-03 16:47:59 -04:00
Brennen Raimer
602273a219 checkpoint. beginning download process 2019-05-01 17:03:57 -04:00
Brennen Raimer
cb491c7bbd tweaks to selection and progress page attributes 2019-04-30 16:28:50 -04:00
Brennen Raimer
77a13e2753 polished dependency enforcement 2019-04-30 15:40:20 -04:00
Brennen Raimer
e1237985b8 dependencies properly enforced 2019-04-30 14:41:55 -04:00
Brennen Raimer
2571e097e2 wizard no longer crashes if selection menu reloaded
wizard now caches icons so no duplication of requests
enforcement of dependencies partially implemented
allowed redirection of all network requests
2019-04-30 12:55:41 -04:00
Brennen Raimer
1f426ea6ae removed redundant resizes, and added expand of top level menu items 2019-04-29 23:05:47 -04:00
Brennen Raimer
f4c5cbee37 addition of octave to tools 2019-04-29 22:24:55 -04:00
Brennen Raimer
79da5c9c1c checkpoint 2019-04-29 16:50:43 -04:00
Brennen Raimer
ce8da72876 addition of process dialogs during tool loading 2019-04-29 15:22:58 -04:00
Brennen Raimer
c63bc4f61c updated some icon urls 2019-04-29 15:22:19 -04:00
Brennen Raimer
0e8db03be5 menu now displays with proper hierarchy 2019-04-29 14:25:58 -04:00
Brennen Raimer
1ccdb845cc corrected spelling error 2019-04-29 14:25:28 -04:00
Brennen Raimer
7c71483c8d added a few tools 2019-04-28 20:30:11 -04:00
Brennen Raimer
9d7a092f38 getting menu to display properly in-progress 2019-04-27 19:43:28 -04:00
Brennen Raimer
9a3987ff55 changed format of supported_tools.json 2019-04-26 13:28:09 -04:00
Brennen Raimer
cd8a2c2ce7 checkpoint. getting stuck 2019-04-24 16:56:31 -04:00
Brennen Raimer
ecfb8032d9 Merge branch 'initial_dev' of https://github.com/norweeg/portable-computing-tookit-installer into initial_dev 2019-04-23 20:51:49 -04:00
Brennen Raimer
0fc5e5cecf beginnings of downloader class 2019-04-23 16:46:25 -04:00
Brennen Raimer
9291d870b9 created an interable subclass of non-iterable qtreewidgetiterator 2019-04-23 00:45:22 -04:00
Brennen Raimer
87d3bcdc46 checkpoint. don't want to lose this work 2019-04-22 16:32:39 -04:00
Brennen Raimer
cbccf82671 updated structure of supported_tools.json 2019-04-20 16:03:57 -04:00
Brennen Raimer
dc3f3ecb18 fixed embarassing typo 2019-04-20 10:52:00 -04:00
Brennen Raimer
32c32306d6 checkpoint. nearing completion of GUI 2019-04-19 20:12:57 -04:00
Brennen Raimer
fb5131e480 begin migrating code that can be reused from first iteration of this idea 2019-04-16 00:04:23 -04:00
14 changed files with 1731 additions and 1 deletions

4
.gitignore vendored
View File

@@ -531,4 +531,6 @@ healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# End of https://www.gitignore.io/api/linux,macos,windows,visualstudio,visualstudiocode
# End of https://www.gitignore.io/api/linux,macos,windows,visualstudio,visualstudiocode
.vscode

View File

@@ -0,0 +1,22 @@
import sys
from pathlib import Path
from PyQt5 import QtWidgets, QtGui
from ui.installer_wizard import InstallerWizard
if __name__ == "__main__":
# create the QApplication that will manage this window
app = QtWidgets.QApplication(sys.argv)
app.setApplicationName("Portable Computing Toolkit Installer")
try:
app.setStyle(QtWidgets.QStyleFactory.create('WindowsVista'))
except:
app.setStyle(QtWidgets.QStyleFactory.create('Fusion'))
app.setWindowIcon(QtGui.QIcon(str(Path(__file__).parent/"resources/icons/system-software-installer-2.png")))
# create a window
main_window = InstallerWizard()
main_window.show()
# execute the application
sys.exit(app.exec_())

View File

@@ -0,0 +1,491 @@
[
{
"name": "Portable Apps Platform",
"page": "https://portableapps.com/download",
"category": "",
"depends on": "Portable Apps Platform",
"homepage": "https://portableapps.com",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Git",
"page": "https://git-scm.com/download/win",
"category": "development.tools.source code management",
"homepage": "https://git-scm.com",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "GitExtensions",
"page": "https://github.com/gitextensions/gitextensions/releases/latest",
"category": "development.tools.source code management",
"depends on": "Git",
"homepage": "http://gitextensions.github.io/",
"icon url": "https://raw.githubusercontent.com/gitextensions/gitextensions/master/Logo/git-extensions-logo-64px.png",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "WinPython",
"page": "https://sourceforge.net/projects/winpython/",
"category": "development.languages.python",
"homepage": "https://winpython.github.io/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "MSYS2",
"page": "https://www.msys2.org/",
"category": "development.languages.c and c++",
"homepage": "https://www.msys2.org",
"icon url": "https://avatars1.githubusercontent.com/u/6759993?s=200&v=4",
"ntfs_required": true,
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Win-builds",
"page": "http://win-builds.org/doku.php/download_and_installation_from_windows",
"category": "development.languages.c and c++",
"homepage": "http://win-builds.org/doku.php/start",
"ntfs_required": false,
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Java JDK",
"page": "https://jdk.java.net/",
"category": "development.languages.java",
"versions": {
"stable": {
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
},
"version": "",
"page": ""
},
"preview": {
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
},
"version": "",
"page": ""
}
},
"homepage": "https://jdk.java.net",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "SublimeText",
"page": "https://www.sublimetext.com/3",
"category": "development.tools.text editors and integrated development environments",
"homepage": "https://www.sublimetext.com",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Atom",
"page": "https://github.com/atom/atom/releases/latest",
"category": "development.tools.text editors and integrated development environments",
"homepage": "https://atom.io",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": ".NET Core SDK",
"page": "https://dotnet.microsoft.com/download/dotnet-core",
"category": "development.languages.C#",
"homepage": "https://dotnet.microsoft.com",
"versions": {
"stable": {
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
},
"version": "",
"page": ""
},
"preview": {
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
},
"version": "",
"page": ""
}
},
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Node.js",
"page": "https://nodejs.org/en/",
"category": "development.languages.javascript",
"homepage": "https://nodejs.org",
"versions": {
"stable": {
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
},
"version": "",
"page": ""
},
"preview": {
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
},
"version": "",
"page": ""
}
},
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Google Chrome",
"page": "https://portableapps.com/apps/internet/google_chrome_portable",
"category": "internet.browsers",
"homepage": "https://google.com/chrome",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Mozilla Firefox",
"page": "https://portableapps.com/apps/internet/firefox_portable",
"category": "internet.browsers",
"homepage": "https://www.mozilla.org/en-US/firefox/new/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Cherrytree",
"page": "https://portableapps.com/apps/office/cherrytree-portable",
"category": "office.notes",
"homepage": "https://www.giuspen.com/cherrytree/",
"icon url": "https://www.giuspen.com/icons_softw/cherrytree.png",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "KeepNote",
"page": "https://portableapps.com/apps/office/keepnote-portable",
"category": "office.notes",
"homepage": "http://keepnote.org/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "LibreOffice",
"page": "https://portableapps.com/apps/office/libreoffice_portable",
"category": "office.suite",
"homepage": "https://libreoffice.org",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "RedNotebook",
"page": "https://portableapps.com/apps/office/rednotebook_portable",
"category": "office.notes",
"homepage": "https://rednotebook.sourceforge.io/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Filezilla",
"page": "https://portableapps.com/apps/internet/filezilla_portable",
"category": "internet.file transfer",
"homepage": "https://filezilla-project.org/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "WinSCP",
"page": "https://portableapps.com/apps/internet/winscp_portable",
"category": "internet.file transfer",
"homepage": "https://winscp.net/eng/index.php",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Cppcheck",
"page": "https://portableapps.com/apps/development/cppcheck-portable",
"category": "development.languages.c and c++",
"homepage": "http://cppcheck.sourceforge.net/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"page": "https://portableapps.com/apps/development/frhed_portable",
"category": "development.tools",
"homepage": "http://frhed.sourceforge.net/en/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
},
"name": "Frhed"
},
{
"name": "Geany",
"page": "https://portableapps.com/apps/development/geany_portable",
"category": "development.tools.text editors and integrated development environments",
"homepage": "https://geany.org/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "gVim",
"page": "https://portableapps.com/apps/development/gvim_portable",
"category": "development.tools.text editors and integrated development environments",
"homepage": "https://www.vim.org/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Notepad++",
"page": "https://portableapps.com/apps/development/notepadpp_portable",
"category": "development.tools.text editors and integrated development environments",
"homepage": "https://notepad-plus-plus.org/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Peazip",
"page": "https://portableapps.com/apps/utilities/peazip_portable",
"category": "utilities.archiving",
"homepage": "http://www.peazip.org/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "7-Zip",
"page": "https://portableapps.com/apps/utilities/7-zip_portable",
"category": "utilities.archiving",
"homepage": "https://7-zip.org",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Explorer++",
"page": "https://portableapps.com/apps/utilities/explorerplusplus_portable",
"category": "utilities.file browsers",
"homepage": "https://explorerplusplus.com/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "WinMerge",
"page": "https://portableapps.com/apps/utilities/winmerge_portable",
"category": "development.tools.file comparison",
"homepage": "http://winmerge.org/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Command Prompt Portable",
"page": "https://portableapps.com/apps/utilities/command_prompt_portable",
"category": "utilities.terminals",
"homepage": "https://portableapps.com/apps/utilities/command_prompt_portable",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Console",
"page": "https://portableapps.com/apps/utilities/console_portable",
"category": "utilities.terminals",
"homepage": "https://sourceforge.net/projects/console/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Terminus",
"page": "https://github.com/Eugeny/terminus/releases/latest",
"category": "utilities.terminals",
"homepage": "https://eugeny.github.io/terminus/",
"icon url": "https://raw.githubusercontent.com/Eugeny/terminus/master/app/assets/tray.png",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "KiTTY",
"page": "https://portableapps.com/apps/internet/kitty-portable",
"category": "utilities.terminals.remote",
"homepage": "http://www.9bis.net/kitty/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "PuTTY",
"page": "https://portableapps.com/apps/internet/putty_portable",
"category": "utilities.terminals.remote",
"homepage": "https://www.chiark.greenend.org.uk/~sgtatham/putty/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Microsoft Visual Studio Code",
"page": "https://code.visualstudio.com/docs/?dv=win64user",
"category": "development.tools.text editors and integrated development environments",
"depends on": "Git",
"homepage": "https://code.visualstudio.com",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "Umbrello",
"page": "https://download.kde.org/stable/umbrello/latest/win64/",
"category": "development.tools",
"homepage": "https://umbrello.kde.org/",
"icon url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/Umbrello-icon.svg/128px-Umbrello-icon.svg.png",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "QOwnNotes",
"page": "https://portableapps.com/apps/office/qownnotes-portable",
"category": "office.notes",
"homepage": "https://www.qownnotes.org/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "SageMath",
"page": "http://mirrors.mit.edu/sage/win/index.html",
"category": "education.mathmatics",
"homepage": "http://www.sagemath.org/",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
},
{
"name": "GNU Octave",
"page": "https://www.gnu.org/software/octave/",
"category": "education.mathmatics",
"homepage": "https://www.gnu.org/software/octave/",
"icon url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/Gnu-octave-logo.svg/240px-Gnu-octave-logo.svg.png",
"search": {
"selector": "",
"filename regex": "",
"text regex": ""
}
}
]

View File

@@ -0,0 +1,30 @@
import json
from urllib.parse import urlparse
from urllib.request import urlopen
from pathlib import Path
import pytest
json_url = "https://raw.githubusercontent.com/norweeg/portable-computing-toolkit-installer/initial_dev/portable_computing_toolkit_installer/resources/supported_tools.json"
class TestJSON(object):
def test_json_url_valid(self):
global json_url
parsed = urlparse(json_url)
assert len(parsed) == 6
assert Path(parsed.path).suffix == ".json"
def test_json_accessible(self):
global json_url
json_file = urlopen(json_url)
assert json_file.code < 400
json_file.close()
def test_json_valid(self):
global json_url
with urlopen(json_url) as json_file:
json_data = json.load(json_file)
assert json_data

View File

@@ -0,0 +1,26 @@
import json
from urllib.request import urlopen
import requests
from pathlib import Path
from parsel import Selector
from w3lib.url import canonicalize_url
import pytest
json_url = "https://raw.githubusercontent.com/norweeg/portable-computing-toolkit-installer/initial_dev/portable_computing_toolkit_installer/resources/supported_tools.json"
class TestPages(object):
def test_pages_accessible(self):
global json_url
with urlopen(json_url) as json_file:
json_data = json.load(json_file)
with requests.Session() as session:
for page in [canonicalize_url(item["homepage"]) for item in json_data]:
try:
response = session.get(page, allow_redirects = True, verify = False)
except:
response = session.get(page.replace("http", "https", 1), allow_redirects = True, verify = False)
assert response.status_code < 400
html = response.text
assert html
assert Selector(text = html, type="html").getall()

View File

@@ -0,0 +1,248 @@
import platform
import re
import webbrowser
from collections import deque, namedtuple
from pathlib import Path
from queue import Empty, Queue
from urllib.parse import urljoin
from parsel import Selector
from PyQt5 import QtCore, QtGui, QtNetwork, QtWebEngineWidgets, QtWidgets
from PyQt5.uic import loadUi
try:
import win32api
except ImportError:
if platform.system()=='Windows':
raise #require win32api on windows
class Downloader(QtWidgets.QMainWindow):
download_progress = QtCore.pyqtSignal(str, int, int)
LoadedPage = namedtuple("LoadedPage", ("success", "html"))
def __init__(self, download_queue, install_ready, download_errors, download_directory, parent = None, flags = None):
super().__init__(parent, flags)
loadUi(Path(__file__).parent / "downloader.ui", baseinstance = self)
self.hide()
#create an off-the-record profile, isolated from other Downloader objects
new_page = QtWebEngineWidgets.QtWebEnginePage(QtWebEngineWidgets.QWebEngineProfile(self.web_view))
self.web_view.setPage(new_page)
self._load_result = Queue(maxsize=1)
#set application style and decorate pushbuttons
self._style = QtWidgets.QApplication.instance().style()
self.setWindowIcon(QtWidgets.QApplication.instance().windowIcon())
self.back_button.setIcon(self._style.standardIcon(self._style.SP_ArrowBack))
self.forward_button.setIcon(self._style.standardIcon(self._style.SP_ArrowForward))
self.reload_stop_button.setIcon(self._style.standardIcon(self._style.SP_BrowserReload))
self.home_button.setIcon(self._style.standardIcon(self._style.SP_ArrowUp))
#connect signals to make useable as a browser
self.web_view.urlChanged.connect(lambda url: self.address_bar.setText(url.toString()))
self.web_view.loadStarted.connect(self._reload_becomes_stop)
self.web_view.loadFinished.connect(self._stop_becomes_reload)
self.address_bar.returnPressed.connect(lambda: self.web_view.load(QtCore.QUrl(self.address_bar.text())))
#connect cancel actions to handlers
self.actionCancel.triggered.connect(self._cancel_manual_search)
self.actionCancel_and_Open_Issue_on_Github.triggered.connect(self._cancel_manual_search_open_issue)
#set attributes
self._download_queue = download_queue
self._install_ready = install_ready
self._download_errors = download_errors
self._download_directory = download_directory
def _reload_becomes_stop(self):
"""Turns the reload button into a stop button.
"""
self._reload_stop_button.clicked.disconnect()
self._reload_stop_button.clicked.connect(self.web_view.stop)
self._reload_stop_button.setToolTip("Stop")
self._reload_stop_button.setIcon(self._style.standardIcon(self._style.SP_BrowserStop))
self._check_history()
def _stop_becomes_reload(self, ok):
"""Turns a temporary stop button back into a reload button
Args:
ok (bool): Received from signal indicating load was stopped without error (True) or because of error (False). Ignored.
"""
self._reload_stop_button.clicked.disconnect()
self._reload_stop_button.clicked.connect(self.web_view.reload)
self._reload_stop_button.setToolTip("Reload")
self._reload_stop_button.setIcon(self._style.standardIcon(self._style.SP_BrowserReload))
self._check_history()
def _check_history(self):
"""Checks if the current web view has history to navigate forward or backward to. Disables or enables the forward/backward buttons accordingly
"""
if self.web_view.history().canGoBack():
self.back_button.setEnabled(True)
else:
self.back_button.setEnabled(False)
if self.web_view.history().canGoForward():
self.forward_button.setEnabled(True)
else:
self.forward_button.setEnabled(False)
def _cancel_manual_search(self):
"""Action handler for the cancel action. Puts the received tool info into errors queue and immediately exits
"""
self._download_errors.put(self._tool_info)
self.close()
self.deleteLater()
QtCore.QThread.currentThread().exit(1)
def _cancel_manual_search_open_issue(self):
"""Action handler for the cancel and open a bug report. Same as cancel, except it will open the github issues page in the default browser first
"""
self._download_errors.put(self._tool_info)
webbrowser.open_new_tab("https://github.com/norweeg/portable-computing-toolkit-installer/issues/new")
self.close()
self.deleteLater()
QtCore.QThread.currentThread().exit(1)
def begin_manual_search(self):
self.web_view.history().clear()
self._check_history()
self.show()
self.web_view.load(QtCore.QUrl(self._home_page))
def begin_auto_search(self):
"""Begins the search process by getting tool info from a download queue and finalizes some GUI elements (in case manual search is necessary)
"""
try:
tool_info = self._download_queue.get(timeout = 1)
except Empty:
QtCore.QThread.currentThread().quit()
else:
self._tool_info = tool_info
self._tool_name = tool_info["name"]
self._download_page_selector = deque(tool_info["search"]["selector"])
self._home_page = tool_info["homepage"]
#name window from attribute
self.setWindowTitle(f"Find {self._tool_name}")
#get homepage from attributes and set home button to load it
self.home_button.clicked.connect(lambda: self.web_view.load(QtCore.QUrl(self._home_page)))
self.web_view.loadFinished.connect(lambda status: self._load_result.put(Downloader.LoadedPage(status, self.web_view.page().toHtml())))
try:
self._download_file(self._find_installer_url(self._tool_info["page"]))
except:
self.begin_manual_search()
def _find_installer_url(self, url):
"""Finds the URL of the next page to navigate to or the item to download using the search selectors from a tool info dictionary. The fully-rendered
HTML will be scraped, in case the desired URL is rendered by JavaScript
Args:
url (str): The URL of the page to be scraped
"""
#load the url with a browser that will render page, including urls generated by JavaScript and get result
self.web_view.load(QtCore.QUrl(url))
load_result = self._load_result.get()
#if page loaded successfully, parse the results, otherwise return nothing
if load_result.success:
page = Selector(text = load_result.html)
self._load_result.task_done()
else:
self._load_result.task_done()
return
'''if tool has a download page selector, we need to follow one or more links from the "page" url to find the download page to search
portableapps.com loves to make you do this'''
if self._download_page_selector:
next_page_query = self._download_page_selector.popleft()
return self._find_installer_url(urljoin(url, page.css(next_page_query).attrib["href"]))
#get all links in the current page and search for the tool's installer file using a regular expression
for link in [urljoin(url, anchor.attrib["href"]) for anchor in page.xpath("//a")]:
if re.search(self._tool_info["search"]["filename regex"], link):
#the link matched the regular expression, our download URL is found!
return link
#if nothing was found by now, returns nothing. automated search has failed
return
def _download_file(self, url):
"""Initiates a download of url
Args:
url (str): The URL of the file to be downloaded
"""
#no url means autosearch has failed
assert url
try:
self.web_view.loadFinished.disconnect()
except:
pass
self.web_view.page().profile().downloadRequested.connect(self._process_download)
self.web_view.load(url)
@QtCore.pyqtSlot("QWebEngineDownloadItem*")
def _process_download(self, download_item):
self._tool_info["download url"] = download_item.url().toString()
self._tool_info["mimetype"] = download_item.mimeType()
suggested_path = Path(download_item.path())
filename = suggested_path.name
download_item.setPath(str(Path(self._download_directory/filename)))
self._tool_info["filename"] = Path(download_item.path())
download_item.downloadProgress.connect(lambda x, y: self.download_progress.emit(self._tool_name, x, y))
download_item.stateChanged.connect(self._download_interrupted)
download_item.finished(self._check_finished)
download_item.accept()
@QtCore.pyqtSlot("QWebEngineDownloadItem::DownloadState")
def _download_interrupted(self, state):
"""Receives the QWebEngineDownloadItem's stateChanged signal and, if the state is "interrupted", trigger a failure
"""
if state == QtWebEngineWidgets.QWebEngineDownloadItem.DownloadInterrupted:
sender = self.sender()
self._tool_info["error reason"] = sender.interruptReasonString()
sender.cancel()
self._tool_info["filename"].unlink()
self._tool_info.pop("filename", None)
self._download_errors.put(self._tool_info)
self.close()
self.deleteLater()
QtCore.QThread.currentThread().exit(1)
@QtCore.pyqtSlot()
def _check_finished(self):
"""Receives the QWebEngineDownloadItem's finished signal, verifies success, unblocks the file an triggers a success
"""
#get the downloaditem that sent this signal
sender = self.sender()
#if the download item is completed, unblock the file on windows, then close and exit this thread
if sender.state() == QtWebEngineWidgets.QWebEngineDownloadItem.DownloadCompleted:
if platform.system() == 'Windows':
self._unblock_file()
#check if the total bytes in the download is known. -1 means not known.
if sender.totalBytes() == -1:
#if not known, signal that this download has completed, since the progress thusfar has been ignored
self.download_progress.emit(self._tool_name, 1, 1)
self._install_ready.put(self._tool_info)
self.close()
self.deleteLater()
QtCore.QThread.currentThread().quit()
def _unblock_file(self):
"""Removes the "downladed from internet" Zone Identifier. Windows will prevent executables that have this set from executing.
Contents of zip files inherit this from the .zip they come from
"""
try:
win32api.DeleteFile(str(self._tool_info["filename"])+r":Zone.Identifier")
except win32api.error:
#just ignore the error if the above Zone Identifier is not set
pass
class DownloadWorker(QtCore.QRunnable):
def __init__(self, download_queue, install_ready, download_error, download_directory, wizard):
super().__init__()
self._download_queue = download_queue
self._install_ready = install_ready
self._download_directory = download_directory
self._wizard = wizard
def run(self):
downloader = Downloader(self.download_queue, self._install_ready, self._download_error, self._download_directory)
downloader.download_progress.connect(self._wizard.track_progress)
downloader.begin_auto_search()

View File

@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QFrame" name="frame">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="back_button">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Back</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="forward_button">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Forward</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="reload_stop_button">
<property name="toolTip">
<string>Reload</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="home_button">
<property name="toolTip">
<string>Home</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="address_bar">
<property name="placeholderText">
<string>Enter a URL</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QWebEngineView" name="web_view">
<property name="url">
<url>
<string>about:blank</string>
</url>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>21</height>
</rect>
</property>
<widget class="QMenu" name="menuActions">
<property name="title">
<string>Actions</string>
</property>
<addaction name="actionCancel"/>
<addaction name="actionCancel_and_Open_Issue_on_Github"/>
</widget>
<addaction name="menuActions"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<action name="actionCancel">
<property name="text">
<string>Cancel</string>
</property>
</action>
<action name="actionCancel_and_Open_Issue_on_Github">
<property name="text">
<string>Cancel and Open Issue on Github</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
<class>QWebEngineView</class>
<extends>QWidget</extends>
<header location="global">QtWebEngineWidgets/QWebEngineView</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>back_button</sender>
<signal>clicked()</signal>
<receiver>web_view</receiver>
<slot>back()</slot>
<hints>
<hint type="sourcelabel">
<x>34</x>
<y>51</y>
</hint>
<hint type="destinationlabel">
<x>399</x>
<y>324</y>
</hint>
</hints>
</connection>
<connection>
<sender>forward_button</sender>
<signal>clicked()</signal>
<receiver>web_view</receiver>
<slot>forward()</slot>
<hints>
<hint type="sourcelabel">
<x>72</x>
<y>51</y>
</hint>
<hint type="destinationlabel">
<x>399</x>
<y>324</y>
</hint>
</hints>
</connection>
<connection>
<sender>reload_stop_button</sender>
<signal>clicked()</signal>
<receiver>web_view</receiver>
<slot>reload()</slot>
<hints>
<hint type="sourcelabel">
<x>110</x>
<y>51</y>
</hint>
<hint type="destinationlabel">
<x>399</x>
<y>324</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,382 @@
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
from threading import Lock
import psutil
from PyQt5 import QtCore, QtGui, QtNetwork, QtWebEngineWidgets, QtWidgets
from PyQt5.uic import loadUi
try:
import win32file
except ImportError:
if platform.system()=='Windows':
raise #require win32file on windows
from validators.location_validator import Location_Validator
from .menu_iterator import MenuIterator
from .downloader import DownloadWorker, Downloader
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.selection_menu.itemClicked.connect(self._enforce_dependencies)
self._location_validator = Location_Validator(self)
try:
self.install_location.inputRejected.connect(self.install_location.clear)
except AttributeError:
pass #inputRejected signal added in Qt 5.12, I have 5.9
self.button(self.BackButton).clicked.connect(lambda x: self._location_validator.reset_state())
self.install_location.setValidator(self._location_validator)
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))
self.selection_page.setCommitPage(True)
self.progress_page.setFinalPage(True)
#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/")
self._progress_update_lock = Lock()
@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':
#get all drives read-writable drives except C:\
drives = [drive for drive in psutil.disk_partitions(all = True) if drive.device not in ("","C:\\") and "rw" in drive.opts]
if drives: #drives other than C:\ exist, are likely installation locations
dialog_location = Path(drives[0].mountpoint)
else: #C:\ was only drive, so start in user home
dialog_location = Path.home()
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.setText(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)
request.setAttribute(QtNetwork.QNetworkRequest.FollowRedirectsAttribute, True)
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:
if QtWidgets.QMessageBox.question(self, "", "This toolkit requires Microsoft Windows 7 or newer to be used. Continue anyway?") in (QtWidgets.QMessageBox.No, 0):
reply.abort()
process_dialog.cancel()
return False
if not platform.machine().lower() in ('amd64', 'x86_64'):
if QtWidgets.QMessageBox.question(self, "", "Parts of this toolkit require a 64-bit processor. Continue anyway?") in (QtWidgets.QMessageBox.No, 0):
reply.abort()
process_dialog.cancel()
return False
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 an error while testing Internet connectivity:\n\n{reply.errorString()}")
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)
try: #clear data from this page before reloading
self.__tools__ = None
self._icon_cache = {item.text(0):item.icon(0) for item in list(MenuIterator(self.selection_menu, QtWidgets.QTreeWidgetItemIterator.NoChildren))}
self.selection_menu.clear()
except NameError:
pass
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)
request.setAttribute(QtNetwork.QNetworkRequest.FollowRedirectsAttribute, True)
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()
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:
new_item.setIcon(0, self._icon_cache[new_item.text(0)])
except (AttributeError, KeyError):
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 = QtNetwork.QNetworkRequest(QtCore.QUrl(icon_url))
icon_request.setAttribute(QtNetwork.QNetworkRequest.CacheLoadControlAttribute, QtNetwork.QNetworkRequest.AlwaysNetwork)
icon_request.setAttribute(QtNetwork.QNetworkRequest.FollowRedirectsAttribute, True)
icon_reply = self._network_manager.get(icon_request)
#when download is complete, validates request returned without error and sets icon
icon_reply.finished.connect(partial(self._set_icon, tree_item, icon_reply))
def _set_icon(self, tree_item, reply):
"""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 reply.error():
pixmap = QtGui.QPixmap()
pixmap.loadFromData(reply.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("QTreeWidgetItem*","int")
def _enforce_dependencies(self, item, column):
if column: #ignore everything not in column 0 (the names)
return
elif item.childCount(): #recurse through child items til no children
for child in [item.child(i) for i in range(item.childCount())]:
self._enforce_dependencies(child, column)
else:
#if item is checked, look for dependent items and check them too
tools_by_name = {tool["name"]:tool for tool in self.__tools__}
if item.checkState(0) == QtCore.Qt.Checked:
try:
dependencies = tools_by_name[item.text(0)]["depends on"].split(",")
except KeyError:
pass
else:
for dependency in dependencies:
for dependant_item in self.selection_menu.findItems(dependency, QtCore.Qt.MatchFixedString|QtCore.Qt.MatchCaseSensitive|QtCore.Qt.MatchRecursive):
dependant_item.setCheckState(0, QtCore.Qt.Checked)
else: #item was unchecked
#find all checked items and build a set of their dependencies
selected = self._get_selections()
dependencies_of_selected = set()
names_of_dependant_items = []
for selected_item in selected:
try:
dependencies_of_selected |= set((tools_by_name[selected_item.text(0)]["depends on"]).split(","))
except KeyError:
pass
else:
if item.text(0) in (tools_by_name[selected_item.text(0)]["depends on"]).split(","):
names_of_dependant_items.append(selected_item.text(0))
#if the item that was just unchecked is a dependency of another selected item
if item.text(0) in dependencies_of_selected:
#build a gramatically correct error (minus oxford comma (sorry!!))
if len(names_of_dependant_items) > 1:
for name in names_of_dependant_items[1:-1]:
name = f", {name}"
names_of_dependant_items[-1] = f" and {names_of_dependant_items[-1]}"
depend_text = "depend"
else:
depend_text = "depends"
#display the error and disallow unchecking item while something that depends on it is selected
self._display_error(f"{str().join(names_of_dependant_items)} {depend_text} on {item.text(0)}")
item.setCheckState(0, QtCore.Qt.Checked)
def _begin_downloads(self, download_queue = None, install_ready = None, download_directory = None):
if download_queue:
pass
else:
self._download_queue = Queue()
if install_ready:
pass
else:
self._install_ready = Queue()
self._download_error = Queue()
if download_directory:
pass
else:
try:
self._download_directory = QtCore.QTemporaryDir()
except Exception as e:
self._display_error(str(e), self._download_directory.errorString()).accepted.connect(lambda: self.back())
if self._download_queue.empty():
tools_by_name = {tool["name"]:tool for tool in self.__tools__}
self._download_progress = {}
for selected_tool in self._get_selections():
self._download_queue.put_nowait(tools_by_name[selected_tool.text(0)])
self.progress_bar.setRange(0, self._download_queue.qsize())
while self._thread_pool.activeThreadCount() < self._thread_pool.maxThreadCount() and not self._download_queue.empty():
self._thread_pool.tryStart(DownloadWorker(self._download_queue, self._install_ready, self._download_error, self._download_directory, self))
QtCore.QThread.currentThread().msleep(250)
assert self._download_error.empty()
@QtCore.pyqtSlot(str, int, int)
def track_progress(self, tool_name, bytes_received, bytes_total):
if tool_name not in [tool["name"] for tool in self.__tools__]:
return
else:
try:
self._download_progress[tool_name] = bytes_received/bytes_total
except ZeroDivisionError:
self._download_progress[tool_name] = 0
self._progress_update_lock.acquire()
self.progress_bar.setValue(sum(self._download_progress.values()))
self._progress_update_lock.release()

View File

@@ -0,0 +1,230 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>toolkit_installer</class>
<widget class="QWizard" name="toolkit_installer">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>610</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>800</width>
<height>610</height>
</size>
</property>
<property name="windowTitle">
<string>Portable Computing Toolkit Installer</string>
</property>
<property name="windowIcon">
<iconset>
<normaloff>:/icons/toolbox.svg</normaloff>:/icons/toolbox.svg</iconset>
</property>
<property name="wizardStyle">
<enum>QWizard::ClassicStyle</enum>
</property>
<property name="options">
<set>QWizard::NoBackButtonOnLastPage|QWizard::NoBackButtonOnStartPage|QWizard::NoCancelButtonOnLastPage</set>
</property>
<widget class="QWizardPage" name="intro_page">
<property name="title">
<string>Portable Computing Toolkit Installer</string>
</property>
<property name="subTitle">
<string/>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTextBrowser" name="intro">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="html">
<string>&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
&lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
p, li { white-space: pre-wrap; }
&lt;/style&gt;&lt;/head&gt;&lt;body style=&quot; font-family:'Noto Sans'; font-size:10pt; font-weight:400; font-style:normal;&quot;&gt;
&lt;p style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%; background-color:transparent;&quot;&gt;&lt;span style=&quot; font-size:small;&quot;&gt;The portable computing toolkit installer sets up and combines the &lt;/span&gt;&lt;a href=&quot;https://portableapps.com/&quot;&gt;&lt;span style=&quot; font-size:small; text-decoration: underline; color:#000080;&quot;&gt;PortableApps.com&lt;/span&gt;&lt;/a&gt;&lt;span style=&quot; font-size:small;&quot;&gt; framework with various tools for writing code. The goal of this project is to provide a platform for anyone who has computing needs but does not own their on PC to be able to have a private workspace on public/shared computers.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%; background-color:transparent;&quot;&gt;&lt;span style=&quot; font-size:small; font-weight:600;&quot;&gt;Requirements:&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;&quot;&gt;&lt;li style=&quot; background-color:transparent;&quot; style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%;&quot;&gt;&lt;span style=&quot; font-size:small;&quot;&gt;A PC runninging Windows 7 (64-bit) or later &lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot; background-color:transparent;&quot; style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%;&quot;&gt;&lt;span style=&quot; font-size:small;&quot;&gt;An &lt;/span&gt;&lt;a href=&quot;https://lifehacker.com/how-to-erase-and-format-a-hard-drive-1525128357&quot;&gt;&lt;span style=&quot; font-size:small; text-decoration: underline; color:#000080;&quot;&gt;NTFS-Formatted&lt;/span&gt;&lt;/a&gt;&lt;span style=&quot; font-size:small;&quot;&gt; USB storage device &lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot; background-color:transparent;&quot; style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%;&quot;&gt;&lt;span style=&quot; font-size:small;&quot;&gt;An active Internet connection &lt;/span&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;p style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%; background-color:transparent;&quot;&gt;&lt;span style=&quot; font-size:small; font-weight:600;&quot;&gt;Contributing and bug reporting:&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%; background-color:transparent;&quot;&gt;&lt;span style=&quot; font-size:small;&quot;&gt;Source code for this installer is available on &lt;/span&gt;&lt;a href=&quot;https://github.com/norweeg/portable-computing-toolkit-installer&quot;&gt;&lt;span style=&quot; font-size:small; text-decoration: underline; color:#000080;&quot;&gt;Github&lt;/span&gt;&lt;/a&gt;&lt;span style=&quot; font-size:small;&quot;&gt;. This installer was originally a learning exercise for myself to learn python, so I welcome you to help improve it!&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%; background-color:transparent;&quot;&gt;&lt;span style=&quot; font-size:small;&quot;&gt;This installer scrapes the web for the URLs to the installers for the software. I don't have control over the homepages of the tools in this toolkit, so if they fail to download, it's likely that the download page has changed and this tool needs to be updated to reflect those changes. If you find the installer is unable to download one of the tools, please &lt;/span&gt;&lt;a href=&quot;https://github.com/norweeg/portable-computing-toolkit-installer/issues&quot;&gt;&lt;span style=&quot; font-size:small; text-decoration: underline; color:#000080;&quot;&gt;open an issue&lt;/span&gt;&lt;/a&gt;&lt;span style=&quot; font-size:small;&quot;&gt; on Github&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%; background-color:transparent;&quot;&gt;&lt;span style=&quot; font-size:small;&quot;&gt;This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%; background-color:transparent;&quot;&gt;&lt;span style=&quot; font-size:small;&quot;&gt;This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot; margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; line-height:115%; background-color:transparent;&quot;&gt;&lt;span style=&quot; font-size:small;&quot;&gt;Copyright © 2019 &lt;/span&gt;&lt;a href=&quot;https://github.com/norweeg&quot;&gt;&lt;span style=&quot; font-size:small; text-decoration: underline; color:#000080;&quot;&gt;Brennen Raimer&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="textInteractionFlags">
<set>Qt::TextBrowserInteraction</set>
</property>
<property name="openExternalLinks">
<bool>false</bool>
</property>
<property name="openLinks">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWizardPage" name="license_page">
<property name="title">
<string>Portable Computing Toolkit Installer License Agreement</string>
</property>
<property name="subTitle">
<string/>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QTextBrowser" name="license">
<property name="html">
<string>&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
&lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
p, li { white-space: pre-wrap; }
&lt;/style&gt;&lt;/head&gt;&lt;body style=&quot; font-family:'Noto Sans'; font-size:10pt; font-weight:400; font-style:normal;&quot;&gt;
&lt;p style=&quot;-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:8pt;&quot;&gt;&lt;br /&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="openLinks">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWizardPage" name="location_page">
<property name="title">
<string>Install Location</string>
</property>
<property name="subTitle">
<string>Choose a drive or folder to install the portable toolkit to:</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLineEdit" name="install_location">
<property name="clearButtonEnabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="browse_button">
<property name="text">
<string>Browse...</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWizardPage" name="selection_page">
<layout class="QHBoxLayout" name="horizontalLayout_8">
<item>
<widget class="QTreeWidget" name="selection_menu">
<property name="selectionMode">
<enum>QAbstractItemView::NoSelection</enum>
</property>
<property name="uniformRowHeights">
<bool>true</bool>
</property>
<property name="itemsExpandable">
<bool>true</bool>
</property>
<property name="sortingEnabled">
<bool>false</bool>
</property>
<property name="animated">
<bool>true</bool>
</property>
<attribute name="headerVisible">
<bool>false</bool>
</attribute>
<attribute name="headerDefaultSectionSize">
<number>300</number>
</attribute>
<attribute name="headerShowSortIndicator" stdset="0">
<bool>false</bool>
</attribute>
<attribute name="headerStretchLastSection">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string>Name</string>
</property>
</column>
<column>
<property name="text">
<string>Homepage</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
<widget class="QWizardPage" name="progress_page">
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QProgressBar" name="progress_bar">
<property name="minimum">
<number>0</number>
</property>
<property name="maximum">
<number>100</number>
</property>
<property name="value">
<number>0</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="progress_label">
<property name="text">
<string>Download Progress</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,17 @@
from PyQt5 import QtWidgets
class MenuIterator(QtWidgets.QTreeWidgetItemIterator):
def __init__(self, *args):
super().__init__(*args)
def __iter__(self):
return self
def __next__(self):
value = self.value()
if not value:
raise StopIteration
else:
self += 1
return value

View File

@@ -0,0 +1,100 @@
from PyQt5 import QtCore, QtGui, QtWidgets
import psutil
import os
import platform
from pathlib import Path
from threading import Lock, Condition, Event
class Location_Validator(QtGui.QValidator):
_disposition = QtGui.QValidator.Intermediate
_pos = 0
_location = ""
_ntfs_location_selected = False
_previous_location = None
def __init__(self, parent):
super().__init__(parent)
@property
def ntfs_location_selected(self):
return Location_Validator._ntfs_location_selected
def _onNTFSDrive(self, location):
"""Tests if location is a path on an NTFS-formatted drive. Returns True if path is on NTFS-formatted drive.
Arguments:
location {str} -- A string representing a folder path
"""
match = [drive for drive in psutil.disk_partitions(all = True)
if (Path(drive.mountpoint) in Path(location).parents or Path(drive.mountpoint) == Path(location))
and drive.fstype.lower() == "ntfs"]
if match:
self._ntfs_location_selected = True
return True
#elif platform.system() == "Linux":
# pass
else:
if QtWidgets.QApplication.instance().activeModalWidget():
return False
self._ntfs_location_selected = False
return not QtWidgets.QMessageBox.question(self.parent(), "", f"{Path(location)} is not an NTFS-formatted volume. Tools which require a NTFS filesystem to function will not be available. Continue anyway?") in (QtWidgets.QMessageBox.No, 0)
def validate(self, input_, pos):
"""Called by InstallerWizard via QtWidget.QLineEdit.TextChanged signal to validate installation location input of user
Arguments:
input_ {str} -- The text entered in the installation location QLineEdit widget
pos {int} -- The current location of the cursor in the QLineEdit widget
"""
#there is a very obnoxious, nearly 10 year old bug in QLineEdit that causes validate to be called gratitously and rapidly especially on
#non-Windows platforms where it gets called every time the window focus changes. The bug has been in Qt since at least Qt4 and it doesn't
#look like anyone cares to fix it, so I did my best to supress gratitious input checking and modal dialog opening
if not input_.strip():
_previous_location = input_
return (QtGui.QValidator.Intermediate, input_, pos)
elif input_.strip() == self._previous_location:
return (self._disposition, self._previous_location, self._pos)
elif input_.strip().endswith(r":"):
#if nothing entered or location ends with : e.g. C: which will cause _onNTFSDrive to trigger unwanted messages
self._location = input_
self._disposition = QtGui.QValidator.Intermediate
else:
expanded_input = os.path.expandvars(input_.strip()) # expand any environment variables in input
location = QtCore.QFileInfo(expanded_input)
if location.exists() and location.isDir():
if not self._onNTFSDrive(expanded_input):
# clear the location, reset cursor, but allow continued edits
self._location = ""
self._disposition = QtGui.QValidator.Intermediate
else:
test_file = QtCore.QTemporaryFile(f"{Path(expanded_input)/'testXXXXXX.tmp'}")
if test_file.open(QtCore.QIODevice.ReadWrite):
test_file.close()
test_file.remove()
self._location = input_
self._disposition = QtGui.QValidator.Acceptable
else:
self._location = input_
self._disposition = QtGui.QValidator.Intermediate
else:
# I wanted to return invalid, but that would prevent input of anything not instantly acceptable
# or editing something acceptable to something else acceptable, passing through an invalid on the way
self._location = input_
self._disposition = QtGui.QValidator.Intermediate
self._pos = pos
self._previous_location = self._location
return (self._disposition, self._location, self._pos,)
@QtCore.pyqtSlot()
def reset_state(self):
"""Resets the internal state of the Location_Validator, including the previously validated location
"""
self._disposition = QtGui.QValidator.Intermediate
self._pos = 0
self._location = ""
self._ntfs_location_selected = False
self._previous_location = None