PluginBrowser.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. # Copyright (c) 2017 Ultimaker B.V.
  2. # PluginBrowser is released under the terms of the LGPLv3 or higher.
  3. from UM.Extension import Extension
  4. from UM.i18n import i18nCatalog
  5. from UM.Logger import Logger
  6. from UM.Qt.ListModel import ListModel
  7. from UM.PluginRegistry import PluginRegistry
  8. from UM.Application import Application
  9. from UM.Version import Version
  10. from UM.Message import Message
  11. from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
  12. from PyQt5.QtCore import QUrl, QObject, Qt, pyqtProperty, pyqtSignal, pyqtSlot
  13. from PyQt5.QtQml import QQmlComponent, QQmlContext
  14. import json
  15. import os
  16. import tempfile
  17. import platform
  18. import zipfile
  19. i18n_catalog = i18nCatalog("cura")
  20. class PluginBrowser(QObject, Extension):
  21. def __init__(self, parent=None):
  22. super().__init__(parent)
  23. self._api_version = 2
  24. self._api_url = "http://software.ultimaker.com/cura/v%s/" % self._api_version
  25. self._plugin_list_request = None
  26. self._download_plugin_request = None
  27. self._download_plugin_reply = None
  28. self._network_manager = None
  29. self._plugins_metadata = []
  30. self._plugins_model = None
  31. self._qml_component = None
  32. self._qml_context = None
  33. self._dialog = None
  34. self._download_progress = 0
  35. self._is_downloading = False
  36. self._request_header = [b"User-Agent",
  37. str.encode("%s/%s (%s %s)" % (Application.getInstance().getApplicationName(),
  38. Application.getInstance().getVersion(),
  39. platform.system(),
  40. platform.machine(),
  41. )
  42. )
  43. ]
  44. # Installed plugins are really installed after reboot. In order to prevent the user from downloading the
  45. # same file over and over again, we keep track of the upgraded plugins.
  46. self._newly_installed_plugin_ids = []
  47. # variables for the license agreement dialog
  48. self._license_dialog_plugin_name = ""
  49. self._license_dialog_license_content = ""
  50. self._license_dialog_plugin_file_location = ""
  51. showLicenseDialog = pyqtSignal()
  52. @pyqtSlot(result = str)
  53. def getLicenseDialogPluginName(self):
  54. return self._license_dialog_plugin_name
  55. @pyqtSlot(result = str)
  56. def getLicenseDialogPluginFileLocation(self):
  57. return self._license_dialog_plugin_file_location
  58. @pyqtSlot(result = str)
  59. def getLicenseDialogLicenseContent(self):
  60. return self._license_dialog_license_content
  61. def openLicenseDialog(self, plugin_name, license_content, plugin_file_location):
  62. self._license_dialog_plugin_name = plugin_name
  63. self._license_dialog_license_content = license_content
  64. self._license_dialog_plugin_file_location = plugin_file_location
  65. self.showLicenseDialog.emit()
  66. pluginsMetadataChanged = pyqtSignal()
  67. onDownloadProgressChanged = pyqtSignal()
  68. onIsDownloadingChanged = pyqtSignal()
  69. @pyqtProperty(bool, notify = onIsDownloadingChanged)
  70. def isDownloading(self):
  71. return self._is_downloading
  72. @pyqtSlot()
  73. def browsePlugins(self):
  74. self._createNetworkManager()
  75. self.requestPluginList()
  76. if not self._dialog:
  77. self._dialog = self._createDialog("PluginBrowser.qml")
  78. self._dialog.show()
  79. @pyqtSlot()
  80. def requestPluginList(self):
  81. Logger.log("i", "Requesting plugin list")
  82. url = QUrl(self._api_url + "plugins")
  83. self._plugin_list_request = QNetworkRequest(url)
  84. self._plugin_list_request.setRawHeader(*self._request_header)
  85. self._network_manager.get(self._plugin_list_request)
  86. def _createDialog(self, qml_name):
  87. Logger.log("d", "Creating dialog [%s]", qml_name)
  88. path = QUrl.fromLocalFile(os.path.join(PluginRegistry.getInstance().getPluginPath(self.getPluginId()), qml_name))
  89. self._qml_component = QQmlComponent(Application.getInstance()._engine, path)
  90. # We need access to engine (although technically we can't)
  91. self._qml_context = QQmlContext(Application.getInstance()._engine.rootContext())
  92. self._qml_context.setContextProperty("manager", self)
  93. dialog = self._qml_component.create(self._qml_context)
  94. if dialog is None:
  95. Logger.log("e", "QQmlComponent status %s", self._qml_component.status())
  96. Logger.log("e", "QQmlComponent errorString %s", self._qml_component.errorString())
  97. return dialog
  98. def setIsDownloading(self, is_downloading):
  99. if self._is_downloading != is_downloading:
  100. self._is_downloading = is_downloading
  101. self.onIsDownloadingChanged.emit()
  102. def _onDownloadPluginProgress(self, bytes_sent, bytes_total):
  103. if bytes_total > 0:
  104. new_progress = bytes_sent / bytes_total * 100
  105. self.setDownloadProgress(new_progress)
  106. if new_progress == 100.0:
  107. self.setIsDownloading(False)
  108. self._download_plugin_reply.downloadProgress.disconnect(self._onDownloadPluginProgress)
  109. # must not delete the temporary file on Windows
  110. self._temp_plugin_file = tempfile.NamedTemporaryFile(mode = "w+b", suffix = ".curaplugin", delete = False)
  111. location = self._temp_plugin_file.name
  112. # write first and close, otherwise on Windows, it cannot read the file
  113. self._temp_plugin_file.write(self._download_plugin_reply.readAll())
  114. self._temp_plugin_file.close()
  115. self._checkPluginLicenseOrInstall(location)
  116. return
  117. ## Checks if the downloaded plugin ZIP file contains a license file or not.
  118. # If it does, it will show a popup dialog displaying the license to the user. The plugin will be installed if the
  119. # user accepts the license.
  120. # If there is no license file, the plugin will be directory installed.
  121. def _checkPluginLicenseOrInstall(self, file_path):
  122. with zipfile.ZipFile(file_path, "r") as zip_ref:
  123. plugin_id = None
  124. for file in zip_ref.infolist():
  125. if file.filename.endswith("/"):
  126. plugin_id = file.filename.strip("/")
  127. break
  128. if plugin_id is None:
  129. msg = i18n_catalog.i18nc("@info:status", "Failed to get plugin ID from <filename>{0}</filename>", file_path)
  130. msg_title = i18n_catalog.i18nc("@info:tile", "Warning")
  131. self._progress_message = Message(msg, lifetime=0, dismissable=False, title = msg_title)
  132. return
  133. # find a potential license file
  134. plugin_root_dir = plugin_id + "/"
  135. license_file = None
  136. for f in zip_ref.infolist():
  137. # skip directories (with file_size = 0) and files not in the plugin directory
  138. if f.file_size == 0 or not f.filename.startswith(plugin_root_dir):
  139. continue
  140. file_name = os.path.basename(f.filename).lower()
  141. file_base_name, file_ext = os.path.splitext(file_name)
  142. if file_base_name in ["license", "licence"]:
  143. license_file = f.filename
  144. break
  145. # show a dialog for user to read and accept/decline the license
  146. if license_file is not None:
  147. Logger.log("i", "Found license file for plugin [%s], showing the license dialog to the user", plugin_id)
  148. license_content = zip_ref.read(license_file).decode('utf-8')
  149. self.openLicenseDialog(plugin_id, license_content, file_path)
  150. return
  151. # there is no license file, directly install the plugin
  152. self.installPlugin(file_path)
  153. @pyqtSlot(str)
  154. def installPlugin(self, file_path):
  155. if not file_path.startswith("/"):
  156. location = "/" + file_path # Ensure that it starts with a /, as otherwise it doesn't work on windows.
  157. else:
  158. location = file_path
  159. result = PluginRegistry.getInstance().installPlugin("file://" + location)
  160. self._newly_installed_plugin_ids.append(result["id"])
  161. self.pluginsMetadataChanged.emit()
  162. Application.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Plugin browser"), result["message"])
  163. @pyqtProperty(int, notify = onDownloadProgressChanged)
  164. def downloadProgress(self):
  165. return self._download_progress
  166. def setDownloadProgress(self, progress):
  167. if progress != self._download_progress:
  168. self._download_progress = progress
  169. self.onDownloadProgressChanged.emit()
  170. @pyqtSlot(str)
  171. def downloadAndInstallPlugin(self, url):
  172. Logger.log("i", "Attempting to download & install plugin from %s", url)
  173. url = QUrl(url)
  174. self._download_plugin_request = QNetworkRequest(url)
  175. self._download_plugin_request.setRawHeader(*self._request_header)
  176. self._download_plugin_reply = self._network_manager.get(self._download_plugin_request)
  177. self.setDownloadProgress(0)
  178. self.setIsDownloading(True)
  179. self._download_plugin_reply.downloadProgress.connect(self._onDownloadPluginProgress)
  180. @pyqtSlot()
  181. def cancelDownload(self):
  182. Logger.log("i", "user cancelled the download of a plugin")
  183. self._download_plugin_reply.abort()
  184. self._download_plugin_reply.downloadProgress.disconnect(self._onDownloadPluginProgress)
  185. self._download_plugin_reply = None
  186. self._download_plugin_request = None
  187. self.setDownloadProgress(0)
  188. self.setIsDownloading(False)
  189. @pyqtProperty(QObject, notify=pluginsMetadataChanged)
  190. def pluginsModel(self):
  191. if self._plugins_model is None:
  192. self._plugins_model = ListModel()
  193. self._plugins_model.addRoleName(Qt.UserRole + 1, "name")
  194. self._plugins_model.addRoleName(Qt.UserRole + 2, "version")
  195. self._plugins_model.addRoleName(Qt.UserRole + 3, "short_description")
  196. self._plugins_model.addRoleName(Qt.UserRole + 4, "author")
  197. self._plugins_model.addRoleName(Qt.UserRole + 5, "already_installed")
  198. self._plugins_model.addRoleName(Qt.UserRole + 6, "file_location")
  199. self._plugins_model.addRoleName(Qt.UserRole + 7, "can_upgrade")
  200. else:
  201. self._plugins_model.clear()
  202. items = []
  203. for metadata in self._plugins_metadata:
  204. items.append({
  205. "name": metadata["label"],
  206. "version": metadata["version"],
  207. "short_description": metadata["short_description"],
  208. "author": metadata["author"],
  209. "already_installed": self._checkAlreadyInstalled(metadata["id"]),
  210. "file_location": metadata["file_location"],
  211. "can_upgrade": self._checkCanUpgrade(metadata["id"], metadata["version"])
  212. })
  213. self._plugins_model.setItems(items)
  214. return self._plugins_model
  215. def _checkCanUpgrade(self, id, version):
  216. plugin_registry = PluginRegistry.getInstance()
  217. metadata = plugin_registry.getMetaData(id)
  218. if metadata != {}:
  219. if id in self._newly_installed_plugin_ids:
  220. return False # We already updated this plugin.
  221. current_version = Version(metadata["plugin"]["version"])
  222. new_version = Version(version)
  223. if new_version > current_version:
  224. return True
  225. return False
  226. def _checkAlreadyInstalled(self, id):
  227. plugin_registry = PluginRegistry.getInstance()
  228. metadata = plugin_registry.getMetaData(id)
  229. if metadata != {}:
  230. return True
  231. else:
  232. if id in self._newly_installed_plugin_ids:
  233. return True # We already installed this plugin, but the registry just doesn't know it yet.
  234. return False
  235. def _onRequestFinished(self, reply):
  236. reply_url = reply.url().toString()
  237. if reply.error() == QNetworkReply.TimeoutError:
  238. Logger.log("w", "Got a timeout.")
  239. # Reset everything.
  240. self.setDownloadProgress(0)
  241. self.setIsDownloading(False)
  242. if self._download_plugin_reply:
  243. self._download_plugin_reply.downloadProgress.disconnect(self._onDownloadPluginProgress)
  244. self._download_plugin_reply.abort()
  245. self._download_plugin_reply = None
  246. return
  247. elif reply.error() == QNetworkReply.HostNotFoundError:
  248. Logger.log("w", "Unable to reach server.")
  249. return
  250. if reply.operation() == QNetworkAccessManager.GetOperation:
  251. if reply_url == self._api_url + "plugins":
  252. try:
  253. json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
  254. self._plugins_metadata = json_data
  255. self.pluginsMetadataChanged.emit()
  256. except json.decoder.JSONDecodeError:
  257. Logger.log("w", "Received an invalid print job state message: Not valid JSON.")
  258. return
  259. else:
  260. # Ignore any operation that is not a get operation
  261. pass
  262. def _onNetworkAccesibleChanged(self, accessible):
  263. if accessible == 0:
  264. self.setDownloadProgress(0)
  265. self.setIsDownloading(False)
  266. if self._download_plugin_reply:
  267. self._download_plugin_reply.downloadProgress.disconnect(self._onDownloadPluginProgress)
  268. self._download_plugin_reply.abort()
  269. self._download_plugin_reply = None
  270. def _createNetworkManager(self):
  271. if self._network_manager:
  272. self._network_manager.finished.disconnect(self._onRequestFinished)
  273. self._network_manager.networkAccessibleChanged.disconnect(self._onNetworkAccesibleChanged)
  274. self._network_manager = QNetworkAccessManager()
  275. self._network_manager.finished.connect(self._onRequestFinished)
  276. self._network_manager.networkAccessibleChanged.connect(self._onNetworkAccesibleChanged)