diff options
author | Jaime van Kessel <nallath@gmail.com> | 2022-04-15 14:12:06 +0300 |
---|---|---|
committer | Jaime van Kessel <nallath@gmail.com> | 2022-04-15 14:12:06 +0300 |
commit | 5ce5ce769e19f3b2ab47b23998b850d4aac92b89 (patch) | |
tree | bba4c7c40fd4235fb95c9b227f7711e0c04f321a /plugins/Marketplace | |
parent | 5a6227809799da8f6e8231bbb13062d1f95b8a31 (diff) |
Re-add the CloudSync functionalityCURA-9146_account_sync
It got removed in the marketplace upgrade.
CURA-9146
Diffstat (limited to 'plugins/Marketplace')
-rw-r--r-- | plugins/Marketplace/CloudApiModel.py | 29 | ||||
-rw-r--r-- | plugins/Marketplace/CloudSync/CloudApiClient.py | 52 | ||||
-rw-r--r-- | plugins/Marketplace/CloudSync/CloudPackageChecker.py | 166 | ||||
-rw-r--r-- | plugins/Marketplace/CloudSync/DiscrepanciesPresenter.py | 41 | ||||
-rw-r--r-- | plugins/Marketplace/CloudSync/DownloadPresenter.py | 153 | ||||
-rw-r--r-- | plugins/Marketplace/CloudSync/LicenseModel.py | 77 | ||||
-rw-r--r-- | plugins/Marketplace/CloudSync/LicensePresenter.py | 139 | ||||
-rw-r--r-- | plugins/Marketplace/CloudSync/RestartApplicationPresenter.py | 32 | ||||
-rw-r--r-- | plugins/Marketplace/CloudSync/SubscribedPackagesModel.py | 74 | ||||
-rw-r--r-- | plugins/Marketplace/CloudSync/SyncOrchestrator.py | 114 | ||||
-rw-r--r-- | plugins/Marketplace/CloudSync/__init__.py | 0 | ||||
-rw-r--r-- | plugins/Marketplace/__init__.py | 4 | ||||
-rw-r--r-- | plugins/Marketplace/resources/qml/CompatibilityDialog.qml | 154 | ||||
-rw-r--r-- | plugins/Marketplace/resources/qml/MarketplaceLicenseDialog.qml | 114 |
14 files changed, 1147 insertions, 2 deletions
diff --git a/plugins/Marketplace/CloudApiModel.py b/plugins/Marketplace/CloudApiModel.py new file mode 100644 index 0000000000..bef37d8173 --- /dev/null +++ b/plugins/Marketplace/CloudApiModel.py @@ -0,0 +1,29 @@ +from typing import Union + +from cura import ApplicationMetadata +from cura.UltimakerCloud import UltimakerCloudConstants + + +class CloudApiModel: + sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int] + cloud_api_version = UltimakerCloudConstants.CuraCloudAPIVersion # type: str + cloud_api_root = UltimakerCloudConstants.CuraCloudAPIRoot # type: str + api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format( + cloud_api_root = cloud_api_root, + cloud_api_version = cloud_api_version, + sdk_version = sdk_version + ) # type: str + + # https://api.ultimaker.com/cura-packages/v1/user/packages + api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format( + cloud_api_root=cloud_api_root, + cloud_api_version=cloud_api_version, + ) + + @classmethod + def userPackageUrl(cls, package_id: str) -> str: + """https://api.ultimaker.com/cura-packages/v1/user/packages/{package_id}""" + + return (CloudApiModel.api_url_user_packages + "/{package_id}").format( + package_id=package_id + ) diff --git a/plugins/Marketplace/CloudSync/CloudApiClient.py b/plugins/Marketplace/CloudSync/CloudApiClient.py new file mode 100644 index 0000000000..21eb1bdbd2 --- /dev/null +++ b/plugins/Marketplace/CloudSync/CloudApiClient.py @@ -0,0 +1,52 @@ +from UM.Logger import Logger +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope +from cura.CuraApplication import CuraApplication +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope +from ..CloudApiModel import CloudApiModel + + +class CloudApiClient: + """Manages Cloud subscriptions + + When a package is added to a user's account, the user is 'subscribed' to that package. + Whenever the user logs in on another instance of Cura, these subscriptions can be used to sync the user's plugins + + Singleton: use CloudApiClient.getInstance() instead of CloudApiClient() + """ + + __instance = None + + @classmethod + def getInstance(cls, app: CuraApplication): + if not cls.__instance: + cls.__instance = CloudApiClient(app) + return cls.__instance + + def __init__(self, app: CuraApplication) -> None: + if self.__instance is not None: + raise RuntimeError("This is a Singleton. use getInstance()") + + self._scope = JsonDecoratorScope(UltimakerCloudScope(app)) # type: JsonDecoratorScope + + app.getPackageManager().packageInstalled.connect(self._onPackageInstalled) + + def unsubscribe(self, package_id: str) -> None: + url = CloudApiModel.userPackageUrl(package_id) + HttpRequestManager.getInstance().delete(url = url, scope = self._scope) + + def _subscribe(self, package_id: str) -> None: + """You probably don't want to use this directly. All installed packages will be automatically subscribed.""" + + Logger.debug("Subscribing to {}", package_id) + data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, CloudApiModel.sdk_version) + HttpRequestManager.getInstance().put( + url = CloudApiModel.api_url_user_packages, + data = data.encode(), + scope = self._scope + ) + + def _onPackageInstalled(self, package_id: str): + if CuraApplication.getInstance().getCuraAPI().account.isLoggedIn: + # We might already be subscribed, but checking would take one extra request. Instead, simply subscribe + self._subscribe(package_id) diff --git a/plugins/Marketplace/CloudSync/CloudPackageChecker.py b/plugins/Marketplace/CloudSync/CloudPackageChecker.py new file mode 100644 index 0000000000..b27b2e0ab3 --- /dev/null +++ b/plugins/Marketplace/CloudSync/CloudPackageChecker.py @@ -0,0 +1,166 @@ +# Copyright (c) 2020 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import json +from typing import List, Dict, Any, Set +from typing import Optional + +from PyQt6.QtCore import QObject +from PyQt6.QtNetwork import QNetworkReply + +from UM import i18nCatalog +from UM.Logger import Logger +from UM.Message import Message +from UM.Signal import Signal +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope +from cura.API.Account import SyncState +from cura.CuraApplication import CuraApplication, ApplicationMetadata +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope +from .SubscribedPackagesModel import SubscribedPackagesModel +from ..CloudApiModel import CloudApiModel + + +class CloudPackageChecker(QObject): + + SYNC_SERVICE_NAME = "CloudPackageChecker" + + def __init__(self, application: CuraApplication) -> None: + super().__init__() + + self.discrepancies = Signal() # Emits SubscribedPackagesModel + self._application = application # type: CuraApplication + self._scope = JsonDecoratorScope(UltimakerCloudScope(application)) + self._model = SubscribedPackagesModel() + self._message = None # type: Optional[Message] + + self._application.initializationFinished.connect(self._onAppInitialized) + self._i18n_catalog = i18nCatalog("cura") + self._sdk_version = ApplicationMetadata.CuraSDKVersion + + self._last_notified_packages = set() # type: Set[str] + """Packages for which a notification has been shown. No need to bother the user twice for equal content""" + + # This is a plugin, so most of the components required are not ready when + # this is initialized. Therefore, we wait until the application is ready. + def _onAppInitialized(self) -> None: + self._package_manager = self._application.getPackageManager() + # initial check + self._getPackagesIfLoggedIn() + + self._application.getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged) + self._application.getCuraAPI().account.syncRequested.connect(self._getPackagesIfLoggedIn) + + def _onLoginStateChanged(self) -> None: + # reset session + self._last_notified_packages = set() + self._getPackagesIfLoggedIn() + + def _getPackagesIfLoggedIn(self) -> None: + if self._application.getCuraAPI().account.isLoggedIn: + self._getUserSubscribedPackages() + else: + self._hideSyncMessage() + + def _getUserSubscribedPackages(self) -> None: + self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING) + url = CloudApiModel.api_url_user_packages + self._application.getHttpRequestManager().get(url, + callback = self._onUserPackagesRequestFinished, + error_callback = self._onUserPackagesRequestFinished, + timeout = 10, + scope = self._scope) + + def _onUserPackagesRequestFinished(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None: + if error is not None or HttpRequestManager.safeHttpStatus(reply) != 200: + Logger.log("w", + "Requesting user packages failed, response code %s while trying to connect to %s", + HttpRequestManager.safeHttpStatus(reply), reply.url()) + self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) + return + + try: + json_data = json.loads(bytes(reply.readAll()).decode("utf-8")) + # Check for errors: + if "errors" in json_data: + for error in json_data["errors"]: + Logger.log("e", "%s", error["title"]) + self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) + return + self._handleCompatibilityData(json_data["data"]) + except json.decoder.JSONDecodeError: + Logger.log("w", "Received invalid JSON for user subscribed packages from the Web Marketplace") + + self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS) + + def _handleCompatibilityData(self, subscribed_packages_payload: List[Dict[str, Any]]) -> None: + user_subscribed_packages = {plugin["package_id"] for plugin in subscribed_packages_payload} + user_installed_packages = self._package_manager.getAllInstalledPackageIDs() + + # We need to re-evaluate the dismissed packages + # (i.e. some package might got updated to the correct SDK version in the meantime, + # hence remove them from the Dismissed Incompatible list) + self._package_manager.reEvaluateDismissedPackages(subscribed_packages_payload, self._sdk_version) + user_dismissed_packages = self._package_manager.getDismissedPackages() + if user_dismissed_packages: + user_installed_packages.update(user_dismissed_packages) + + # We check if there are packages installed in Web Marketplace but not in Cura marketplace + package_discrepancy = list(user_subscribed_packages.difference(user_installed_packages)) + + if user_subscribed_packages != self._last_notified_packages: + # scenario: + # 1. user subscribes to a package + # 2. dismisses the license/unsubscribes + # 3. subscribes to the same package again + # in this scenario we want to notify the user again. To capture that there was a change during + # step 2, we clear the last_notified after step 2. This way, the user will be notified after + # step 3 even though the list of packages for step 1 and 3 are equal + self._last_notified_packages = set() + + if package_discrepancy: + account = self._application.getCuraAPI().account + account.setUpdatePackagesAction(lambda: self._onSyncButtonClicked(None, None)) + + if user_subscribed_packages == self._last_notified_packages: + # already notified user about these + return + + Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") + self._model.addDiscrepancies(package_discrepancy) + self._model.initialize(self._package_manager, subscribed_packages_payload) + self._showSyncMessage() + self._last_notified_packages = user_subscribed_packages + + def _showSyncMessage(self) -> None: + """Show the message if it is not already shown""" + + if self._message is not None: + self._message.show() + return + + sync_message = Message(self._i18n_catalog.i18nc( + "@info:generic", + "Do you want to sync material and software packages with your account?"), + title = self._i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) + sync_message.addAction("sync", + name = self._i18n_catalog.i18nc("@action:button", "Sync"), + icon = "", + description = "Sync your plugins and print profiles to Ultimaker Cura.", + button_align = Message.ActionButtonAlignment.ALIGN_RIGHT) + sync_message.actionTriggered.connect(self._onSyncButtonClicked) + sync_message.show() + self._message = sync_message + + def _hideSyncMessage(self) -> None: + """Hide the message if it is showing""" + + if self._message is not None: + self._message.hide() + self._message = None + + def _onSyncButtonClicked(self, sync_message: Optional[Message], sync_message_action: Optional[str]) -> None: + if sync_message is not None: + sync_message.hide() + self._hideSyncMessage() # Should be the same message, but also sets _message to None + self.discrepancies.emit(self._model) diff --git a/plugins/Marketplace/CloudSync/DiscrepanciesPresenter.py b/plugins/Marketplace/CloudSync/DiscrepanciesPresenter.py new file mode 100644 index 0000000000..45ce70b32c --- /dev/null +++ b/plugins/Marketplace/CloudSync/DiscrepanciesPresenter.py @@ -0,0 +1,41 @@ +import os +from typing import Optional + +from PyQt6.QtCore import QObject + +from UM.Qt.QtApplication import QtApplication +from UM.Signal import Signal +from .SubscribedPackagesModel import SubscribedPackagesModel + + +class DiscrepanciesPresenter(QObject): + """Shows a list of packages to be added or removed. The user can select which packages to (un)install. The user's + + choices are emitted on the `packageMutations` Signal. + """ + + def __init__(self, app: QtApplication) -> None: + super().__init__() + + self.packageMutations = Signal() # Emits SubscribedPackagesModel + + self._app = app + self._package_manager = app.getPackageManager() + self._dialog = None # type: Optional[QObject] + self._compatibility_dialog_path = "resources/qml/CompatibilityDialog.qml" + + def present(self, plugin_path: str, model: SubscribedPackagesModel) -> None: + path = os.path.join(plugin_path, self._compatibility_dialog_path) + self._dialog = self._app.createQmlComponent(path, {"subscribedPackagesModel": model, "handler": self}) + assert self._dialog + self._dialog.accepted.connect(lambda: self._onConfirmClicked(model)) + + def _onConfirmClicked(self, model: SubscribedPackagesModel) -> None: + # If there are incompatible packages - automatically dismiss them + if model.getIncompatiblePackages(): + self._package_manager.dismissAllIncompatiblePackages(model.getIncompatiblePackages()) + # For now, all compatible packages presented to the user should be installed. + # Later, we might remove items for which the user unselected the package + if model.getCompatiblePackages(): + model.setItems(model.getCompatiblePackages()) + self.packageMutations.emit(model) diff --git a/plugins/Marketplace/CloudSync/DownloadPresenter.py b/plugins/Marketplace/CloudSync/DownloadPresenter.py new file mode 100644 index 0000000000..e7ac682d69 --- /dev/null +++ b/plugins/Marketplace/CloudSync/DownloadPresenter.py @@ -0,0 +1,153 @@ +# Copyright (c) 2020 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import tempfile +from typing import Dict, List, Any + +from PyQt6.QtNetwork import QNetworkReply + +from UM.i18n import i18nCatalog +from UM.Logger import Logger +from UM.Message import Message +from UM.Signal import Signal +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from cura.CuraApplication import CuraApplication +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope +from .SubscribedPackagesModel import SubscribedPackagesModel + +i18n_catalog = i18nCatalog("cura") + + +class DownloadPresenter: + """Downloads a set of packages from the Ultimaker Cloud Marketplace + + use download() exactly once: should not be used for multiple sets of downloads since this class contains state + """ + + DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB + + def __init__(self, app: CuraApplication) -> None: + # Emits (Dict[str, str], List[str]) # (success_items, error_items) + # Dict{success_package_id, temp_file_path} + # List[errored_package_id] + self.done = Signal() + + self._app = app + self._scope = UltimakerCloudScope(app) + + self._started = False + self._progress_message = self._createProgressMessage() + self._progress = {} # type: Dict[str, Dict[str, Any]] # package_id, Dict + self._error = [] # type: List[str] # package_id + + def download(self, model: SubscribedPackagesModel) -> None: + if self._started: + Logger.error("Download already started. Create a new %s instead", self.__class__.__name__) + return + + manager = HttpRequestManager.getInstance() + for item in model.items: + package_id = item["package_id"] + + def finishedCallback(reply: QNetworkReply, pid = package_id) -> None: + self._onFinished(pid, reply) + + def progressCallback(rx: int, rt: int, pid = package_id) -> None: + self._onProgress(pid, rx, rt) + + def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, pid = package_id) -> None: + self._onError(pid) + + request_data = manager.get( + item["download_url"], + callback = finishedCallback, + download_progress_callback = progressCallback, + error_callback = errorCallback, + scope = self._scope) + + self._progress[package_id] = { + "received": 0, + "total": 1, # make sure this is not considered done yet. Also divByZero-safe + "file_written": None, + "request_data": request_data, + "package_model": item + } + + self._started = True + self._progress_message.show() + + def abort(self) -> None: + 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) -> "DownloadPresenter": + self.abort() + self.done.disconnectAll() + return DownloadPresenter(self._app) + + def _createProgressMessage(self) -> Message: + return Message(i18n_catalog.i18nc("@info:generic", "Syncing..."), + lifetime = 0, + use_inactivity_timer = False, + progress = 0.0, + title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account")) + + def _onFinished(self, package_id: str, reply: QNetworkReply) -> None: + self._progress[package_id]["received"] = self._progress[package_id]["total"] + + try: + with tempfile.NamedTemporaryFile(mode = "wb+", suffix = ".curapackage", delete = False) as temp_file: + bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) + while bytes_read: + temp_file.write(bytes_read) + bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) + self._app.processEvents() + self._progress[package_id]["file_written"] = temp_file.name + 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() + + def _onProgress(self, package_id: str, rx: int, rt: int) -> None: + self._progress[package_id]["received"] = rx + self._progress[package_id]["total"] = rt + + received = 0 + total = 0 + for item in self._progress.values(): + received += item["received"] + total += item["total"] + + if total == 0: # Total download size is 0, or unknown, or there are no progress items at all. + self._progress_message.setProgress(100.0) + return + + self._progress_message.setProgress(100.0 * (received / total)) # [0 .. 100] % + + def _onError(self, package_id: str) -> None: + self._progress.pop(package_id) + self._error.append(package_id) + self._checkDone() + + def _checkDone(self) -> bool: + for item in self._progress.values(): + if not item["file_written"]: + return False + + success_items = { + package_id: + { + "package_path": value["file_written"], + "icon_url": value["package_model"]["icon_url"] + } + 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) + return True diff --git a/plugins/Marketplace/CloudSync/LicenseModel.py b/plugins/Marketplace/CloudSync/LicenseModel.py new file mode 100644 index 0000000000..8c036c48b7 --- /dev/null +++ b/plugins/Marketplace/CloudSync/LicenseModel.py @@ -0,0 +1,77 @@ +from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal +from UM.i18n import i18nCatalog + +catalog = i18nCatalog("cura") + + +# Model for the ToolboxLicenseDialog +class LicenseModel(QObject): + DEFAULT_DECLINE_BUTTON_TEXT = catalog.i18nc("@button", "Decline") + ACCEPT_BUTTON_TEXT = catalog.i18nc("@button", "Agree") + + dialogTitleChanged = pyqtSignal() + packageNameChanged = pyqtSignal() + licenseTextChanged = pyqtSignal() + iconChanged = pyqtSignal() + + def __init__(self, decline_button_text: str = DEFAULT_DECLINE_BUTTON_TEXT, parent = None) -> None: + super().__init__(parent) + + self._current_page_idx = 0 + self._page_count = 1 + self._dialogTitle = "" + self._license_text = "" + self._package_name = "" + self._icon_url = "" + self._decline_button_text = decline_button_text + + @pyqtProperty(str, constant = True) + def acceptButtonText(self): + return self.ACCEPT_BUTTON_TEXT + + @pyqtProperty(str, constant = True) + def declineButtonText(self): + return self._decline_button_text + + @pyqtProperty(str, notify=dialogTitleChanged) + def dialogTitle(self) -> str: + return self._dialogTitle + + @pyqtProperty(str, notify=packageNameChanged) + def packageName(self) -> str: + return self._package_name + + def setPackageName(self, name: str) -> None: + self._package_name = name + self.packageNameChanged.emit() + + @pyqtProperty(str, notify=iconChanged) + def iconUrl(self) -> str: + return self._icon_url + + def setIconUrl(self, url: str): + self._icon_url = url + self.iconChanged.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() + + def setCurrentPageIdx(self, idx: int) -> None: + self._current_page_idx = idx + self._updateDialogTitle() + + def setPageCount(self, count: int) -> None: + self._page_count = count + self._updateDialogTitle() + + def _updateDialogTitle(self): + self._dialogTitle = catalog.i18nc("@title:window", "Plugin License Agreement") + if self._page_count > 1: + self._dialogTitle = self._dialogTitle + " ({}/{})".format(self._current_page_idx + 1, self._page_count) + self.dialogTitleChanged.emit() diff --git a/plugins/Marketplace/CloudSync/LicensePresenter.py b/plugins/Marketplace/CloudSync/LicensePresenter.py new file mode 100644 index 0000000000..ce0d198c01 --- /dev/null +++ b/plugins/Marketplace/CloudSync/LicensePresenter.py @@ -0,0 +1,139 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import os +from typing import Dict, Optional, List, Any + +from PyQt6.QtCore import QObject, pyqtSlot + +from UM.Logger import Logger +from UM.PackageManager import PackageManager +from UM.Signal import Signal +from cura.CuraApplication import CuraApplication +from UM.i18n import i18nCatalog + +from .LicenseModel import LicenseModel + + +class LicensePresenter(QObject): + """Presents licenses for a set of packages for the user to accept or reject. + + Call present() exactly once to show a licenseDialog for a set of packages + Before presenting another set of licenses, create a new instance using resetCopy(). + + licenseAnswers emits a list of Dicts containing answers when the user has made a choice for all provided packages. + """ + + def __init__(self, app: CuraApplication) -> None: + super().__init__() + self._presented = False + """Whether present() has been called and state is expected to be initialized""" + self._catalog = i18nCatalog("cura") + self._dialog = None # type: Optional[QObject] + self._package_manager = app.getPackageManager() # type: PackageManager + # Emits List[Dict[str, [Any]] containing for example + # [{ "package_id": "BarbarianPlugin", "package_path" : "/tmp/dg345as", "accepted" : True }] + self.licenseAnswers = Signal() + + self._current_package_idx = 0 + self._package_models = [] # type: List[Dict] + decline_button_text = self._catalog.i18nc("@button", "Decline and remove from account") + self._license_model = LicenseModel(decline_button_text=decline_button_text) # type: LicenseModel + self._page_count = 0 + + self._app = app + + self._compatibility_dialog_path = "resources/qml/MarketplaceLicenseDialog.qml" + + def present(self, plugin_path: str, packages: Dict[str, Dict[str, str]]) -> None: + """Show a license dialog for multiple packages where users can read a license and accept or decline them + + :param plugin_path: Root directory of the Toolbox plugin + :param packages: Dict[package id, file path] + """ + if self._presented: + Logger.error("{clazz} is single-use. Create a new {clazz} instead", clazz=self.__class__.__name__) + return + + path = os.path.join(plugin_path, self._compatibility_dialog_path) + + self._initState(packages) + + if self._page_count == 0: + self.licenseAnswers.emit(self._package_models) + return + + if self._dialog is None: + context_properties = { + "licenseModel": self._license_model, + "handler": self + } + self._dialog = self._app.createQmlComponent(path, context_properties) + self._presentCurrentPackage() + self._presented = True + + def resetCopy(self) -> "LicensePresenter": + """Clean up and return a new copy with the same settings such as app""" + if self._dialog: + self._dialog.close() + self.licenseAnswers.disconnectAll() + return LicensePresenter(self._app) + + @pyqtSlot() + def onLicenseAccepted(self) -> None: + self._package_models[self._current_package_idx]["accepted"] = True + self._checkNextPage() + + @pyqtSlot() + def onLicenseDeclined(self) -> None: + self._package_models[self._current_package_idx]["accepted"] = False + self._checkNextPage() + + def _initState(self, packages: Dict[str, Dict[str, Any]]) -> None: + + implicitly_accepted_count = 0 + + for package_id, item in packages.items(): + item["package_id"] = package_id + try: + item["licence_content"] = self._package_manager.getPackageLicense(item["package_path"]) + except EnvironmentError as e: + Logger.error(f"Could not open downloaded package {package_id} to read license file! {type(e)} - {e}") + continue # Skip this package. + if item["licence_content"] is None: + # Implicitly accept when there is no license + item["accepted"] = True + implicitly_accepted_count = implicitly_accepted_count + 1 + self._package_models.append(item) + else: + item["accepted"] = None #: None: no answer yet + # When presenting the packages, we want to show packages which have a license first. + # In fact, we don't want to show the others at all because they are implicitly accepted + self._package_models.insert(0, item) + CuraApplication.getInstance().processEvents() + self._page_count = len(self._package_models) - implicitly_accepted_count + self._license_model.setPageCount(self._page_count) + + + def _presentCurrentPackage(self) -> None: + package_model = self._package_models[self._current_package_idx] + package_info = self._package_manager.getPackageInfo(package_model["package_path"]) + + self._license_model.setCurrentPageIdx(self._current_package_idx) + self._license_model.setPackageName(package_info["display_name"]) + self._license_model.setIconUrl(package_model["icon_url"]) + self._license_model.setLicenseText(package_model["licence_content"]) + if self._dialog: + self._dialog.open() # Does nothing if already open + + def _checkNextPage(self) -> None: + if self._current_package_idx + 1 < self._page_count: + self._current_package_idx += 1 + self._presentCurrentPackage() + else: + if self._dialog: + self._dialog.close() + self.licenseAnswers.emit(self._package_models) + + + diff --git a/plugins/Marketplace/CloudSync/RestartApplicationPresenter.py b/plugins/Marketplace/CloudSync/RestartApplicationPresenter.py new file mode 100644 index 0000000000..8776d1782a --- /dev/null +++ b/plugins/Marketplace/CloudSync/RestartApplicationPresenter.py @@ -0,0 +1,32 @@ +from UM import i18nCatalog +from UM.Message import Message +from cura.CuraApplication import CuraApplication + + +class RestartApplicationPresenter: + """Presents a dialog telling the user that a restart is required to apply changes + + Since we cannot restart Cura, the app is closed instead when the button is clicked + """ + def __init__(self, app: CuraApplication) -> None: + self._app = app + self._i18n_catalog = i18nCatalog("cura") + + def present(self) -> None: + app_name = self._app.getApplicationDisplayName() + + message = Message(self._i18n_catalog.i18nc("@info:generic", + "You need to quit and restart {} before changes have effect.", + app_name)) + + message.addAction("quit", + name="Quit " + app_name, + icon = "", + description="Close the application", + button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) + + message.actionTriggered.connect(self._quitClicked) + message.show() + + def _quitClicked(self, *_): + self._app.windowClosed() diff --git a/plugins/Marketplace/CloudSync/SubscribedPackagesModel.py b/plugins/Marketplace/CloudSync/SubscribedPackagesModel.py new file mode 100644 index 0000000000..fcce62fab5 --- /dev/null +++ b/plugins/Marketplace/CloudSync/SubscribedPackagesModel.py @@ -0,0 +1,74 @@ +# Copyright (c) 2020 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt6.QtCore import Qt, pyqtProperty + +from UM.PackageManager import PackageManager +from UM.Qt.ListModel import ListModel +from UM.Version import Version + +from cura import ApplicationMetadata +from typing import List, Dict, Any + + +class SubscribedPackagesModel(ListModel): + def __init__(self, parent = None): + super().__init__(parent) + + self._items = [] + self._metadata = None + self._discrepancies = None + self._sdk_version = ApplicationMetadata.CuraSDKVersion + + self.addRoleName(Qt.ItemDataRole.UserRole + 1, "package_id") + self.addRoleName(Qt.ItemDataRole.UserRole + 2, "display_name") + self.addRoleName(Qt.ItemDataRole.UserRole + 3, "icon_url") + self.addRoleName(Qt.ItemDataRole.UserRole + 4, "is_compatible") + self.addRoleName(Qt.ItemDataRole.UserRole + 5, "is_dismissed") + + @pyqtProperty(bool, constant=True) + def hasCompatiblePackages(self) -> bool: + for item in self._items: + if item['is_compatible']: + return True + return False + + @pyqtProperty(bool, constant=True) + def hasIncompatiblePackages(self) -> bool: + for item in self._items: + if not item['is_compatible']: + return True + return False + + def addDiscrepancies(self, discrepancy: List[str]) -> None: + self._discrepancies = discrepancy + + def getCompatiblePackages(self) -> List[Dict[str, Any]]: + return [package for package in self._items if package["is_compatible"]] + + def getIncompatiblePackages(self) -> List[str]: + return [package["package_id"] for package in self._items if not package["is_compatible"]] + + def initialize(self, package_manager: PackageManager, subscribed_packages_payload: List[Dict[str, Any]]) -> None: + self._items.clear() + for item in subscribed_packages_payload: + if item["package_id"] not in self._discrepancies: + continue + package = { + "package_id": item["package_id"], + "display_name": item["display_name"], + "sdk_versions": item["sdk_versions"], + "download_url": item["download_url"], + "md5_hash": item["md5_hash"], + "is_dismissed": False, + } + + compatible = any(package_manager.isPackageCompatible(Version(version)) for version in item["sdk_versions"]) + package.update({"is_compatible": compatible}) + + try: + package.update({"icon_url": item["icon_url"]}) + except KeyError: # There is no 'icon_url" in the response payload for this package + package.update({"icon_url": ""}) + self._items.append(package) + self.setItems(self._items) diff --git a/plugins/Marketplace/CloudSync/SyncOrchestrator.py b/plugins/Marketplace/CloudSync/SyncOrchestrator.py new file mode 100644 index 0000000000..bfa449b40a --- /dev/null +++ b/plugins/Marketplace/CloudSync/SyncOrchestrator.py @@ -0,0 +1,114 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import os +from typing import List, Dict, Any, cast + +from UM import i18n_catalog +from UM.Extension import Extension +from UM.Logger import Logger +from UM.Message import Message +from UM.PluginRegistry import PluginRegistry +from cura.CuraApplication import CuraApplication +from .CloudPackageChecker import CloudPackageChecker +from .CloudApiClient import CloudApiClient +from .DiscrepanciesPresenter import DiscrepanciesPresenter +from .DownloadPresenter import DownloadPresenter +from .LicensePresenter import LicensePresenter +from .RestartApplicationPresenter import RestartApplicationPresenter +from .SubscribedPackagesModel import SubscribedPackagesModel + + +class SyncOrchestrator(Extension): + """Orchestrates the synchronizing of packages from the user account to the installed packages + + Example flow: + + - CloudPackageChecker compares a list of packages the user `subscribed` to in their account + If there are `discrepancies` between the account and locally installed packages, they are emitted + - DiscrepanciesPresenter shows a list of packages to be added or removed to the user. It emits the `packageMutations` + 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 LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to + be installed. It emits the `licenseAnswers` signal for accept or declines + - The CloudApiClient removes the declined packages from the account + - The SyncOrchestrator uses PackageManager to install the downloaded packages and delete temp files. + - The RestartApplicationPresenter notifies the user that a restart is required for changes to take effect + """ + + def __init__(self, app: CuraApplication) -> None: + super().__init__() + # Differentiate This PluginObject from the Marketplace. self.getId() includes _name. + # getPluginId() will return the same value for The Marketplace extension and this one + self._name = "SyncOrchestrator" + + self._package_manager = app.getPackageManager() + # Keep a reference to the CloudApiClient. it watches for installed packages and subscribes to them + self._cloud_api = CloudApiClient.getInstance(app) # type: CloudApiClient + + self._checker = CloudPackageChecker(app) # type: CloudPackageChecker + self._checker.discrepancies.connect(self._onDiscrepancies) + + self._discrepancies_presenter = DiscrepanciesPresenter(app) # type: DiscrepanciesPresenter + self._discrepancies_presenter.packageMutations.connect(self._onPackageMutations) + + self._download_presenter = DownloadPresenter(app) # type: DownloadPresenter + + self._license_presenter = LicensePresenter(app) # type: LicensePresenter + self._license_presenter.licenseAnswers.connect(self._onLicenseAnswers) + + self._restart_presenter = RestartApplicationPresenter(app) + + def _onDiscrepancies(self, model: SubscribedPackagesModel) -> None: + plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId())) + self._discrepancies_presenter.present(plugin_path, model) + + def _onPackageMutations(self, mutations: SubscribedPackagesModel) -> None: + self._download_presenter = self._download_presenter.resetCopy() + self._download_presenter.done.connect(self._onDownloadFinished) + self._download_presenter.download(mutations) + + def _onDownloadFinished(self, success_items: Dict[str, Dict[str, str]], error_items: List[str]) -> None: + """Called when a set of packages have finished downloading + + :param success_items:: Dict[package_id, Dict[str, str]] + :param error_items:: List[package_id] + """ + if error_items: + message = i18n_catalog.i18nc("@info:generic", "{} plugins failed to download".format(len(error_items))) + self._showErrorMessage(message) + + plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId())) + self._license_presenter = self._license_presenter.resetCopy() + self._license_presenter.licenseAnswers.connect(self._onLicenseAnswers) + self._license_presenter.present(plugin_path, success_items) + + # Called when user has accepted / declined all licenses for the downloaded packages + def _onLicenseAnswers(self, answers: List[Dict[str, Any]]) -> None: + has_changes = False # True when at least one package is installed + + for item in answers: + if item["accepted"]: + # install and subscribe packages + if not self._package_manager.installPackage(item["package_path"]): + message = "Could not install {}".format(item["package_id"]) + self._showErrorMessage(message) + continue + has_changes = True + else: + self._cloud_api.unsubscribe(item["package_id"]) + # delete temp file + try: + os.remove(item["package_path"]) + except EnvironmentError as e: # File was already removed, no access rights, etc. + Logger.error("Can't delete temporary package file: {err}".format(err = str(e))) + + if has_changes: + self._restart_presenter.present() + + def _showErrorMessage(self, text: str): + """Logs an error and shows it to the user""" + + Logger.error(text) + Message(text, lifetime = 0, message_type = Message.MessageType.ERROR).show() diff --git a/plugins/Marketplace/CloudSync/__init__.py b/plugins/Marketplace/CloudSync/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/plugins/Marketplace/CloudSync/__init__.py diff --git a/plugins/Marketplace/__init__.py b/plugins/Marketplace/__init__.py index bd65062ba6..ef1beab33e 100644 --- a/plugins/Marketplace/__init__.py +++ b/plugins/Marketplace/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. - +from .CloudSync.SyncOrchestrator import SyncOrchestrator from .Marketplace import Marketplace def getMetaData(): @@ -14,4 +14,4 @@ def register(app): """ Register the plug-in object with Uranium. """ - return { "extension": Marketplace() } + return { "extension": [Marketplace(), SyncOrchestrator(app)] } diff --git a/plugins/Marketplace/resources/qml/CompatibilityDialog.qml b/plugins/Marketplace/resources/qml/CompatibilityDialog.qml new file mode 100644 index 0000000000..2e8595c811 --- /dev/null +++ b/plugins/Marketplace/resources/qml/CompatibilityDialog.qml @@ -0,0 +1,154 @@ +// Copyright (c) 2022 Ultimaker B.V. +// Marketplace is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.10 +import QtQuick.Window 2.2 +import QtQuick.Controls 2.3 + +import UM 1.1 as UM +import Cura 1.6 as Cura + + +UM.Dialog +{ + visible: true + title: catalog.i18nc("@title", "Changes from your account") + width: UM.Theme.getSize("popup_dialog").width + height: UM.Theme.getSize("popup_dialog").height + minimumWidth: width + maximumWidth: minimumWidth + minimumHeight: height + maximumHeight: minimumHeight + margin: 0 + + property string actionButtonText: subscribedPackagesModel.hasIncompatiblePackages && !subscribedPackagesModel.hasCompatiblePackages ? catalog.i18nc("@button", "Dismiss") : catalog.i18nc("@button", "Next") + + Rectangle + { + id: root + anchors.fill: parent + color: UM.Theme.getColor("main_background") + + UM.I18nCatalog + { + id: catalog + name: "cura" + } + + ScrollView + { + width: parent.width + height: parent.height - nextButton.height - nextButton.anchors.margins * 2 // We want some leftover space for the button at the bottom + clip: true + + Column + { + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + + // Compatible packages + Label + { + font: UM.Theme.getFont("default") + text: catalog.i18nc("@label", "The following packages will be added:") + visible: subscribedPackagesModel.hasCompatiblePackages + color: UM.Theme.getColor("text") + height: contentHeight + UM.Theme.getSize("default_margin").height + } + Repeater + { + model: subscribedPackagesModel + Component + { + Item + { + width: parent.width + property int lineHeight: 60 + visible: model.is_compatible + height: visible ? (lineHeight + UM.Theme.getSize("default_margin").height) : 0 // We only show the compatible packages here + Image + { + id: packageIcon + source: model.icon_url || "../../images/placeholder.svg" + height: lineHeight + width: height + sourceSize.height: height + sourceSize.width: width + mipmap: true + fillMode: Image.PreserveAspectFit + } + Label + { + text: model.display_name + font: UM.Theme.getFont("medium_bold") + anchors.left: packageIcon.right + anchors.leftMargin: UM.Theme.getSize("default_margin").width + anchors.verticalCenter: packageIcon.verticalCenter + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + } + } + } + + // Incompatible packages + Label + { + font: UM.Theme.getFont("default") + text: catalog.i18nc("@label", "The following packages can not be installed because of an incompatible Cura version:") + visible: subscribedPackagesModel.hasIncompatiblePackages + color: UM.Theme.getColor("text") + height: contentHeight + UM.Theme.getSize("default_margin").height + } + Repeater + { + model: subscribedPackagesModel + Component + { + Item + { + width: parent.width + property int lineHeight: 60 + visible: !model.is_compatible && !model.is_dismissed + height: visible ? (lineHeight + UM.Theme.getSize("default_margin").height) : 0 // We only show the incompatible packages here + Image + { + id: packageIcon + source: model.icon_url || "../../images/placeholder.svg" + height: lineHeight + width: height + sourceSize.height: height + sourceSize.width: width + mipmap: true + fillMode: Image.PreserveAspectFit + } + Label + { + text: model.display_name + font: UM.Theme.getFont("medium_bold") + anchors.left: packageIcon.right + anchors.leftMargin: UM.Theme.getSize("default_margin").width + anchors.verticalCenter: packageIcon.verticalCenter + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + } + } + } + } + + } // End of ScrollView + + Cura.PrimaryButton + { + id: nextButton + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: UM.Theme.getSize("default_margin").height + text: actionButtonText + onClicked: accept() + leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width + rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width + } + } +} diff --git a/plugins/Marketplace/resources/qml/MarketplaceLicenseDialog.qml b/plugins/Marketplace/resources/qml/MarketplaceLicenseDialog.qml new file mode 100644 index 0000000000..b4f1ef3dea --- /dev/null +++ b/plugins/Marketplace/resources/qml/MarketplaceLicenseDialog.qml @@ -0,0 +1,114 @@ +// Copyright (c) 2021 Ultimaker B.V. +// Marketplace is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.10 +import QtQuick.Window 2.2 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 + +import UM 1.1 as UM +import Cura 1.6 as Cura + +UM.Dialog +{ + id: licenseDialog + title: licenseModel.dialogTitle + Component.onCompleted: + { + for(var p in licenseModel) + console.log(p + ": " + item[p]); + } + + minimumWidth: UM.Theme.getSize("license_window_minimum").width + minimumHeight: UM.Theme.getSize("license_window_minimum").height + width: minimumWidth + height: minimumHeight + backgroundColor: UM.Theme.getColor("main_background") + margin: screenScaleFactor * 10 + + ColumnLayout + { + anchors.fill: parent + spacing: UM.Theme.getSize("thick_margin").height + + UM.I18nCatalog{id: catalog; name: "cura"} + + Label + { + id: licenseHeader + Layout.fillWidth: true + text: catalog.i18nc("@label", "You need to accept the license to install the package") + color: UM.Theme.getColor("text") + wrapMode: Text.Wrap + renderType: Text.NativeRendering + } + + Row { + id: packageRow + + Layout.fillWidth: true + height: childrenRect.height + spacing: UM.Theme.getSize("default_margin").width + leftPadding: UM.Theme.getSize("narrow_margin").width + + Image + { + id: icon + width: 30 * screenScaleFactor + height: width + sourceSize.width: width + sourceSize.height: height + fillMode: Image.PreserveAspectFit + source: licenseModel.iconUrl || "../../images/placeholder.svg" + mipmap: true + } + + Label + { + id: packageName + text: licenseModel.packageName + color: UM.Theme.getColor("text") + font.bold: true + anchors.verticalCenter: icon.verticalCenter + height: contentHeight + wrapMode: Text.Wrap + renderType: Text.NativeRendering + } + + + } + + Cura.ScrollableTextArea + { + + Layout.fillWidth: true + Layout.fillHeight: true + anchors.topMargin: UM.Theme.getSize("default_margin").height + + textArea.text: licenseModel.licenseText + textArea.readOnly: true + } + + } + rightButtons: + [ + Cura.PrimaryButton + { + leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width + rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width + + text: licenseModel.acceptButtonText + onClicked: { handler.onLicenseAccepted() } + } + ] + + leftButtons: + [ + Cura.SecondaryButton + { + id: declineButton + text: licenseModel.declineButtonText + onClicked: { handler.onLicenseDeclined() } + } + ] +} |