Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/Ultimaker/Cura.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJaime van Kessel <nallath@gmail.com>2022-04-15 14:12:06 +0300
committerJaime van Kessel <nallath@gmail.com>2022-04-15 14:12:06 +0300
commit5ce5ce769e19f3b2ab47b23998b850d4aac92b89 (patch)
treebba4c7c40fd4235fb95c9b227f7711e0c04f321a
parent5a6227809799da8f6e8231bbb13062d1f95b8a31 (diff)
Re-add the CloudSync functionalityCURA-9146_account_sync
It got removed in the marketplace upgrade. CURA-9146
-rw-r--r--plugins/Marketplace/CloudApiModel.py29
-rw-r--r--plugins/Marketplace/CloudSync/CloudApiClient.py52
-rw-r--r--plugins/Marketplace/CloudSync/CloudPackageChecker.py166
-rw-r--r--plugins/Marketplace/CloudSync/DiscrepanciesPresenter.py41
-rw-r--r--plugins/Marketplace/CloudSync/DownloadPresenter.py153
-rw-r--r--plugins/Marketplace/CloudSync/LicenseModel.py77
-rw-r--r--plugins/Marketplace/CloudSync/LicensePresenter.py139
-rw-r--r--plugins/Marketplace/CloudSync/RestartApplicationPresenter.py32
-rw-r--r--plugins/Marketplace/CloudSync/SubscribedPackagesModel.py74
-rw-r--r--plugins/Marketplace/CloudSync/SyncOrchestrator.py114
-rw-r--r--plugins/Marketplace/CloudSync/__init__.py0
-rw-r--r--plugins/Marketplace/__init__.py4
-rw-r--r--plugins/Marketplace/resources/qml/CompatibilityDialog.qml154
-rw-r--r--plugins/Marketplace/resources/qml/MarketplaceLicenseDialog.qml114
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() }
+ }
+ ]
+}