PluginBrowser.py 16 KB

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