diff --git a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml index 3e8d686741..4b15d167e8 100644 --- a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml @@ -13,6 +13,7 @@ import UM 1.1 as UM UM.Dialog { + id: licenseDialog title: catalog.i18nc("@title:window", "Plugin License Agreement") minimumWidth: UM.Theme.getSize("license_window_minimum").width minimumHeight: UM.Theme.getSize("license_window_minimum").height @@ -21,16 +22,21 @@ UM.Dialog property var pluginName; property var licenseContent; property var pluginFileLocation; + Item { anchors.fill: parent + + UM.I18nCatalog{id: catalog; name: "cura"} + + Label { id: licenseTitle anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - text: licenseDialog.pluginName + ": " + catalog.i18nc("@label", "This plugin contains a license.\nYou need to accept this license to install this plugin.\nDo you agree with the terms below?") + text: licenseModel.title wrapMode: Text.Wrap renderType: Text.NativeRendering } @@ -43,7 +49,7 @@ UM.Dialog anchors.right: parent.right anchors.topMargin: UM.Theme.getSize("default_margin").height readOnly: true - text: licenseDialog.licenseContent || "" + text: licenseModel.licenseText } } rightButtons: @@ -53,22 +59,14 @@ UM.Dialog id: acceptButton anchors.margins: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@action:button", "Accept") - onClicked: - { - licenseDialog.close(); - toolbox.install(licenseDialog.pluginFileLocation); - toolbox.subscribe(licenseDialog.pluginName); - } + onClicked: handler.onLicenseAccepted }, Button { id: declineButton anchors.margins: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@action:button", "Decline") - onClicked: - { - licenseDialog.close(); - } + onClicked: handler.onLicenseDeclined } ] } diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py index f64a96b9cf..b70eac98df 100644 --- a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -1,7 +1,7 @@ import os import tempfile from functools import reduce -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Any from PyQt5.QtNetwork import QNetworkReply @@ -30,7 +30,7 @@ class DownloadPresenter: self._started = False self._progress_message = None # type: Optional[Message] - self._progress = {} # type: Dict[str, Dict[str, int]] # package_id, Dict + self._progress = {} # type: Dict[str, Dict[str, Any]] # package_id, Dict self._error = [] # type: List[str] # package_id def download(self, model: SubscribedPackagesModel): @@ -41,26 +41,41 @@ class DownloadPresenter: manager = HttpRequestManager.getInstance() for item in model.items: package_id = item["package_id"] + + request_data = manager.get( + item["download_url"], + callback = lambda reply, pid = package_id: self._onFinished(pid, reply), + download_progress_callback = lambda rx, rt, pid = package_id: self._onProgress(pid, rx, rt), + error_callback = lambda rx, rt, pid = package_id: self._onProgress(pid, rx, rt), + scope = self._scope) + self._progress[package_id] = { "received": 0, - "total": 1 # make sure this is not considered done yet. Also divByZero-safe + "total": 1, # make sure this is not considered done yet. Also divByZero-safe + "file_written": None, + "request_data": request_data } - manager.get( - item["download_url"], - callback = lambda reply: self._onFinished(package_id, reply), - download_progress_callback = lambda rx, rt: self._onProgress(package_id, rx, rt), - error_callback = lambda rx, rt: self._onProgress(package_id, rx, rt), - scope = self._scope) - self._started = True self._showProgressMessage() + def abort(self): + manager = HttpRequestManager.getInstance() + for item in self._progress.values(): + manager.abortRequest(item["request_data"]) + + # Aborts all current operations and returns a copy with the same settings such as app and scope + def resetCopy(self): + self.abort() + self.done.disconnectAll() + return DownloadPresenter(self._app) + def _showProgressMessage(self): self._progress_message = Message(i18n_catalog.i18nc( "@info:generic", "\nSyncing..."), lifetime = 0, + use_inactivity_timer=False, progress = 0.0, title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) self._progress_message.show() @@ -68,13 +83,21 @@ class DownloadPresenter: def _onFinished(self, package_id: str, reply: QNetworkReply): self._progress[package_id]["received"] = self._progress[package_id]["total"] - file_path = self._getTempFile(package_id) + file_fd, file_path = tempfile.mkstemp() + os.close(file_fd) # close the file so we can open it from python + try: - with open(file_path) as temp_file: - # todo buffer this - temp_file.write(reply.readAll()) - except IOError: + with open(file_path, "wb+") as temp_file: + bytes_read = reply.read(256 * 1024) + while bytes_read: + temp_file.write(bytes_read) + bytes_read = reply.read(256 * 1024) + self._app.processEvents() + self._progress[package_id]["file_written"] = file_path + except IOError as e: + Logger.logException("e", "Failed to write downloaded package to temp file", e) self._onError(package_id) + temp_file.close() self._checkDone() @@ -90,8 +113,6 @@ class DownloadPresenter: self._progress_message.setProgress(100.0 * (received / total)) # [0 .. 100] % - self._checkDone() - def _onError(self, package_id: str): self._progress.pop(package_id) self._error.append(package_id) @@ -99,16 +120,11 @@ class DownloadPresenter: def _checkDone(self) -> bool: for item in self._progress.values(): - if item["received"] != item["total"] or item["total"] == -1: + if not item["file_written"]: return False - success_items = {package_id : self._getTempFile(package_id) for package_id in self._progress.keys()} + success_items = {package_id : value["file_written"] for package_id, value in self._progress.items()} error_items = [package_id for package_id in self._error] self._progress_message.hide() self.done.emit(success_items, error_items) - - def _getTempFile(self, package_id: str) -> str: - temp_dir = tempfile.gettempdir() - return os.path.join(temp_dir, package_id) - diff --git a/plugins/Toolbox/src/CloudSync/LicenseModel.py b/plugins/Toolbox/src/CloudSync/LicenseModel.py new file mode 100644 index 0000000000..2706306361 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/LicenseModel.py @@ -0,0 +1,30 @@ +from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal + + +# Model for the ToolboxLicenseDialog +class LicenseModel(QObject): + titleChanged = pyqtSignal() + licenseTextChanged = pyqtSignal() + + def __init__(self, title: str = "", license_text: str = ""): + super().__init__() + self._title = title + self._license_text = license_text + + @pyqtProperty(str, notify=titleChanged) + def title(self) -> str: + return self._title + + def setTitle(self, title: str) -> None: + if self._title != title: + self._title = title + self.titleChanged.emit() + + @pyqtProperty(str, notify=licenseTextChanged) + def licenseText(self) -> str: + return self._license_text + + def setLicenseText(self, license_text: str) -> None: + if self._license_text != license_text: + self._license_text = license_text + self.licenseTextChanged.emit() diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py new file mode 100644 index 0000000000..be288ef787 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -0,0 +1,89 @@ +import os +from typing import Dict, Optional + +from PyQt5.QtCore import QObject, pyqtSlot + +from UM.PackageManager import PackageManager +from UM.Signal import Signal +from cura.CuraApplication import CuraApplication +from UM.i18n import i18nCatalog + + +from plugins.Toolbox.src.CloudSync.LicenseModel import LicenseModel + + +class LicensePresenter(QObject): + + def __init__(self, app: CuraApplication): + super().__init__() + self._dialog = None #type: Optional[QObject] + self._package_manager = app.getPackageManager() # type: PackageManager + # Emits # todo + self.license_answers = Signal() + + self._current_package_idx = 0 + self._package_models = None # type: Optional[Dict] + + self._app = app + + self._compatibility_dialog_path = "resources/qml/dialogs/ToolboxLicenseDialog.qml" + + ## Show a license dialog for multiple packages where users can read a license and accept or decline them + # \param packages: Dict[package id, file path] + def present(self, plugin_path: str, packages: Dict[str, str]): + path = os.path.join(plugin_path, self._compatibility_dialog_path) + + self._initState(packages) + + if self._dialog is None: + + context_properties = { + "catalog": i18nCatalog("cura"), + "licenseModel": LicenseModel("initial title", "initial text"), + "handler": self + } + self._dialog = self._app.createQmlComponent(path, context_properties) + + self._present_current_package() + + @pyqtSlot() + def onLicenseAccepted(self): + self._package_models[self._current_package_idx]["accepted"] = True + self._check_next_page() + + @pyqtSlot() + def onLicenseDeclined(self): + self._package_models[self._current_package_idx]["accepted"] = False + self._check_next_page() + + def _initState(self, packages: Dict[str, str]): + self._package_models = [ + { + "package_id" : package_id, + "package_path" : package_path, + "accepted" : None #: None: no answer yet + } + for package_id, package_path in packages.items() + ] + + def _present_current_package(self): + package_model = self._package_models[self._current_package_idx] + license_content = self._package_manager.getPackageLicense(package_model["package_path"]) + if license_content is None: + # implicitly accept when there is no license + self.onLicenseAccepted() + return + + self._dialog.setProperty("licenseModel", LicenseModel("testTitle", "hoi")) + self._dialog.open() # does nothing if already open + + def _check_next_page(self): + if self._current_package_idx + 1 < len(self._package_models): + self._current_package_idx += 1 + self._present_current_package() + else: + self._dialog.close() + self.license_answers.emit(self._package_models) + + + diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index 05436c1db3..704e7c3e3a 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -1,9 +1,12 @@ +from typing import List, Dict + from UM.Extension import Extension from UM.PluginRegistry import PluginRegistry from cura.CuraApplication import CuraApplication from plugins.Toolbox import CloudPackageChecker from plugins.Toolbox.src.CloudSync.DiscrepanciesPresenter import DiscrepanciesPresenter from plugins.Toolbox.src.CloudSync.DownloadPresenter import DownloadPresenter +from plugins.Toolbox.src.CloudSync.LicensePresenter import LicensePresenter from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel @@ -15,8 +18,8 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack # the user selected to be performed # - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed # - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads -# - The LicencePresenter extracts licences from the downloaded packages and presents a licence for each package to -# - be installed. It emits the `licenceAnswers` {'packageId' : bool} for accept or declines +# - The LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to +# - be installed. It emits the `licenseAnswers` {'packageId' : bool} for accept or declines # - The CloudPackageManager removes the declined packages from the account # - The SyncOrchestrator uses PackageManager to install the downloaded packages. # - Bliss / profit / done @@ -24,18 +27,35 @@ class SyncOrchestrator(Extension): def __init__(self, app: CuraApplication): super().__init__() + self._name = "SyncOrchestrator" # Critical to differentiate This PluginObject from the Toolbox - self._checker = CloudPackageChecker(app) + self._checker = CloudPackageChecker(app) # type: CloudPackageChecker self._checker.discrepancies.connect(self._onDiscrepancies) - self._discrepanciesPresenter = DiscrepanciesPresenter(app) + self._discrepanciesPresenter = DiscrepanciesPresenter(app) # type: DiscrepanciesPresenter self._discrepanciesPresenter.packageMutations.connect(self._onPackageMutations) - self._downloadPresenter = DownloadPresenter(app) + self._downloadPresenter = DownloadPresenter(app) # type: DownloadPresenter + + self._licensePresenter = LicensePresenter(app) # type: LicensePresenter def _onDiscrepancies(self, model: SubscribedPackagesModel): - plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) - self._discrepanciesPresenter.present(plugin_path, model) + # todo revert + self._onDownloadFinished({"SupportEraser" : "/home/nvanhooff/Downloads/ThingiBrowser-v7.0.0-2019-12-12T18_24_40Z.curapackage"}, []) + # plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) + # self._discrepanciesPresenter.present(plugin_path, model) def _onPackageMutations(self, mutations: SubscribedPackagesModel): + self._downloadPresenter = self._downloadPresenter.resetCopy() + self._downloadPresenter.done.connect(self._onDownloadFinished) self._downloadPresenter.download(mutations) + + ## When a set of packages have finished downloading + # \param success_items: Dict[package_id, file_path] + # \param error_items: List[package_id] + def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]): + # todo handle error items + plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) + self._licensePresenter.present(plugin_path, success_items) + +