PluginBrowser.py 16 KB

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