PluginBrowser.py 16 KB

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