qvm_template_gui.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import asyncio
  2. import collections
  3. import itertools
  4. import json
  5. import os
  6. import typing
  7. import PyQt5 # pylint: disable=import-error
  8. import PyQt5.QtWidgets # pylint: disable=import-error
  9. from . import ui_qvmtemplate # pylint: disable=no-name-in-module
  10. from . import ui_templateinstallconfirmdlg # pylint: disable=no-name-in-module
  11. from . import ui_templateinstallprogressdlg # pylint: disable=no-name-in-module
  12. from . import utils
  13. #pylint: disable=invalid-name
  14. BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes']
  15. # pylint: disable=too-few-public-methods,inherit-non-class
  16. class Template(typing.NamedTuple):
  17. status: str
  18. name: str
  19. evr: str
  20. reponame: str
  21. size: int
  22. buildtime: str
  23. installtime: str
  24. licence: str
  25. url: str
  26. summary: str
  27. # ---- internal ----
  28. description: str
  29. default_status: str
  30. # ------------------
  31. COL_NAMES = [
  32. 'Status',
  33. 'Name',
  34. 'Version',
  35. 'Reponame',
  36. 'Download Size (MB)',
  37. 'Build Time',
  38. 'Install Time',
  39. 'License',
  40. 'URL',
  41. 'Summary'
  42. ]
  43. @staticmethod
  44. def build(status, entry):
  45. return Template(
  46. status,
  47. entry['name'],
  48. '%s:%s-%s' % (entry['epoch'], entry['version'], entry['release']),
  49. entry['reponame'],
  50. int(entry['size']) // 1000000,
  51. entry['buildtime'],
  52. entry['installtime'],
  53. entry['license'],
  54. entry['url'],
  55. entry['summary'],
  56. entry['description'],
  57. status
  58. )
  59. class Action(typing.NamedTuple):
  60. op: str
  61. name: str
  62. evr: str
  63. TYPES = [str, str, str]
  64. COL_NAMES = ['Operation', 'Name', 'Version']
  65. class TemplateStatusDelegate(PyQt5.QtWidgets.QStyledItemDelegate):
  66. OPS = [
  67. ['Installed', 'Reinstall', 'Remove'],
  68. ['Extra', 'Remove'],
  69. ['Upgradable', 'Upgrade', 'Remove'],
  70. ['Downgradable', 'Downgrade', 'Remove'],
  71. ['Available', 'Install']
  72. ]
  73. def createEditor(self, parent, option, index):
  74. _ = option # unused
  75. editor = PyQt5.QtWidgets.QComboBox(parent)
  76. # Otherwise the internalPointer can be overwritten with a QComboBox
  77. index = index.model().index(index.row(), index.column())
  78. kind = index.internalPointer().default_status
  79. for op_list in TemplateStatusDelegate.OPS:
  80. if op_list[0] == kind:
  81. for op in op_list:
  82. editor.addItem(op)
  83. editor.currentIndexChanged.connect(self.currentIndexChanged)
  84. editor.showPopup()
  85. return editor
  86. return None
  87. def setEditorData(self, editor, index):
  88. #pylint: disable=no-self-use
  89. cur = index.data()
  90. idx = editor.findText(cur)
  91. if idx >= 0:
  92. editor.setCurrentIndex(idx)
  93. def setModelData(self, editor, model, index):
  94. #pylint: disable=no-self-use
  95. model.setData(index, editor.currentText())
  96. def updateEditorGeometry(self, editor, option, index):
  97. #pylint: disable=no-self-use
  98. _ = index # unused
  99. editor.setGeometry(option.rect)
  100. @PyQt5.QtCore.pyqtSlot()
  101. def currentIndexChanged(self):
  102. self.commitData.emit(self.sender())
  103. class TemplateModel(PyQt5.QtCore.QAbstractItemModel):
  104. def __init__(self):
  105. super().__init__()
  106. self.children = []
  107. def flags(self, index):
  108. if index.isValid() and index.column() == 0:
  109. return super().flags(index) | PyQt5.QtCore.Qt.ItemIsEditable
  110. return super().flags(index)
  111. def sort(self, idx, order):
  112. rev = (order == PyQt5.QtCore.Qt.AscendingOrder)
  113. self.children.sort(key=lambda x: x[idx], reverse=rev)
  114. self.dataChanged.emit(*self.row_index(0, self.rowCount() - 1))
  115. def index(self, row, column, parent=PyQt5.QtCore.QModelIndex()):
  116. if not self.hasIndex(row, column, parent):
  117. return PyQt5.QtCore.QModelIndex()
  118. return self.createIndex(row, column, self.children[row])
  119. def parent(self, child):
  120. #pylint: disable=no-self-use
  121. _ = child # unused
  122. return PyQt5.QtCore.QModelIndex()
  123. def rowCount(self, parent=PyQt5.QtCore.QModelIndex()):
  124. #pylint: disable=no-self-use
  125. _ = parent # unused
  126. return len(self.children)
  127. def columnCount(self, parent=PyQt5.QtCore.QModelIndex()):
  128. #pylint: disable=no-self-use
  129. _ = parent # unused
  130. return len(Template.COL_NAMES)
  131. def hasChildren(self, index=PyQt5.QtCore.QModelIndex()):
  132. #pylint: disable=no-self-use
  133. return index == PyQt5.QtCore.QModelIndex()
  134. def data(self, index, role=PyQt5.QtCore.Qt.DisplayRole):
  135. if index.isValid():
  136. if role == PyQt5.QtCore.Qt.DisplayRole:
  137. return self.children[index.row()][index.column()]
  138. if role == PyQt5.QtCore.Qt.FontRole:
  139. font = PyQt5.QtGui.QFont()
  140. tpl = self.children[index.row()]
  141. font.setBold(tpl.status != tpl.default_status)
  142. return font
  143. if role == PyQt5.QtCore.Qt.TextAlignmentRole:
  144. if isinstance(self.children[index.row()][index.column()], int):
  145. return PyQt5.QtCore.Qt.AlignRight
  146. return PyQt5.QtCore.Qt.AlignLeft
  147. return None
  148. def setData(self, index, value, role=PyQt5.QtCore.Qt.EditRole):
  149. if index.isValid() and role == PyQt5.QtCore.Qt.EditRole:
  150. old_list = list(self.children[index.row()])
  151. old_list[index.column()] = value
  152. new_tpl = Template(*old_list)
  153. self.children[index.row()] = new_tpl
  154. self.dataChanged.emit(index, index)
  155. return True
  156. return False
  157. def headerData(self, section, orientation,
  158. role=PyQt5.QtCore.Qt.DisplayRole):
  159. #pylint: disable=no-self-use
  160. if section < len(Template.COL_NAMES) \
  161. and orientation == PyQt5.QtCore.Qt.Horizontal \
  162. and role == PyQt5.QtCore.Qt.DisplayRole:
  163. return Template.COL_NAMES[section]
  164. return None
  165. def removeRows(self, row, count, parent=PyQt5.QtCore.QModelIndex()):
  166. _ = parent # unused
  167. self.beginRemoveRows(PyQt5.QtCore.QModelIndex(), row, row + count)
  168. del self.children[row:row+count]
  169. self.endRemoveRows()
  170. self.dataChanged.emit(*self.row_index(row, row + count))
  171. def row_index(self, low, high):
  172. return self.createIndex(low, 0), \
  173. self.createIndex(high, self.columnCount())
  174. def set_templates(self, templates):
  175. self.removeRows(0, self.rowCount())
  176. cnt = sum(len(g) for _, g in templates.items())
  177. self.beginInsertRows(PyQt5.QtCore.QModelIndex(), 0, cnt - 1)
  178. for status, grp in templates.items():
  179. for tpl in grp:
  180. self.children.append(Template.build(status, tpl))
  181. self.endInsertRows()
  182. self.dataChanged.emit(*self.row_index(0, self.rowCount() - 1))
  183. def get_actions(self):
  184. actions = []
  185. for tpl in self.children:
  186. if tpl.status != tpl.default_status:
  187. actions.append(Action(tpl.status, tpl.name, tpl.evr))
  188. return actions
  189. async def refresh(self, refresh=True):
  190. cmd = BASE_CMD[:]
  191. if refresh:
  192. # Force refresh if triggered by button press
  193. cmd.append('--refresh')
  194. cmd.extend(['info', '--machine-readable-json', '--installed',
  195. '--available', '--upgrades', '--extras'])
  196. proc = await asyncio.create_subprocess_exec(
  197. *cmd,
  198. stdout=asyncio.subprocess.PIPE,
  199. stderr=asyncio.subprocess.PIPE)
  200. output, stderr = await proc.communicate()
  201. output = output.decode('ASCII')
  202. if proc.returncode != 0:
  203. stderr = stderr.decode('ASCII')
  204. return False, stderr
  205. # Default type is dict as we're going to replace the lists with
  206. # dicts shortly after
  207. tpls = collections.defaultdict(dict, json.loads(output))
  208. # Remove duplicates
  209. # Should this be done in qvm-template?
  210. # TODO: Merge templates with same name?
  211. # If so, we may need to have a separate UI to force versions.
  212. local_names = set(x['name'] for x in tpls['installed'])
  213. # Convert to dict for easier subtraction
  214. for key in tpls:
  215. tpls[key] = {
  216. (x['name'], x['epoch'], x['version'], x['release']): x
  217. for x in tpls[key]}
  218. tpls['installed'] = {
  219. k: v for k, v in tpls['installed'].items()
  220. if k not in tpls['extra'] and k not in tpls['upgradable']}
  221. tpls['available'] = {
  222. k: v for k, v in tpls['available'].items()
  223. if k not in tpls['installed']
  224. and k not in tpls['upgradable']}
  225. # If the package name is installed but the specific version is
  226. # neither installed or an upgrade, then it must be a downgrade
  227. tpls['downgradable'] = {
  228. k: v for k, v in tpls['available'].items()
  229. if k[0] in local_names}
  230. tpls['available'] = {
  231. k: v for k, v in tpls['available'].items()
  232. if k not in tpls['downgradable']}
  233. # Convert back to list
  234. tpls = {k.title(): list(v.values()) for k, v in tpls.items()}
  235. self.set_templates(tpls)
  236. return True, None
  237. class TemplateInstallConfirmDialog(
  238. ui_templateinstallconfirmdlg.Ui_TemplateInstallConfirmDlg,
  239. PyQt5.QtWidgets.QDialog):
  240. def __init__(self, actions):
  241. super().__init__()
  242. self.setupUi(self)
  243. model = PyQt5.QtGui.QStandardItemModel()
  244. model.setHorizontalHeaderLabels(Action.COL_NAMES)
  245. self.treeView.setModel(model)
  246. for act in actions:
  247. model.appendRow([PyQt5.QtGui.QStandardItem(x) for x in act])
  248. class TemplateInstallProgressDialog(
  249. ui_templateinstallprogressdlg.Ui_TemplateInstallProgressDlg,
  250. PyQt5.QtWidgets.QDialog):
  251. def __init__(self, actions):
  252. super().__init__()
  253. self.setupUi(self)
  254. self.actions = actions
  255. self.buttonBox.hide()
  256. @staticmethod
  257. def _process_cr(text):
  258. """Reduce lines replaced using CR character (\r)"""
  259. while '\r' in text:
  260. prefix, suffix = text.rsplit('\r', 1)
  261. if '\n' in prefix:
  262. prefix = prefix.rsplit('\n', 1)[0]
  263. prefix += '\n'
  264. else:
  265. prefix = ''
  266. text = prefix + suffix
  267. return text
  268. def install(self):
  269. async def coro():
  270. self.actions.sort()
  271. for oper, grp in itertools.groupby(self.actions, lambda x: x[0]):
  272. oper = oper.lower()
  273. # No need to specify versions for local operations
  274. if oper in ('remove', 'purge'):
  275. specs = [x.name for x in grp]
  276. else:
  277. specs = [x.name + '-' + x.evr for x in grp]
  278. # FIXME: (C)Python versions before 3.9 fully-buffers stderr in
  279. # this context, cf. https://bugs.python.org/issue13601
  280. # Forcing it to be unbuffered for the time being so that
  281. # the messages can be displayed in time.
  282. envs = os.environ.copy()
  283. envs['PYTHONUNBUFFERED'] = '1'
  284. proc = await asyncio.create_subprocess_exec(
  285. *(BASE_CMD + [oper, '--'] + specs),
  286. stdout=asyncio.subprocess.PIPE,
  287. stderr=asyncio.subprocess.STDOUT,
  288. env=envs)
  289. #pylint: disable=cell-var-from-loop
  290. status_text = ''
  291. while True:
  292. line = await proc.stdout.read(100)
  293. if line == b'':
  294. break
  295. line = line.decode('UTF-8')
  296. status_text = self._process_cr(status_text + line)
  297. self.textEdit.setPlainText(status_text)
  298. if await proc.wait() != 0:
  299. self.buttonBox.show()
  300. self.progressBar.setMaximum(100)
  301. self.progressBar.setValue(0)
  302. return False
  303. self.progressBar.setMaximum(100)
  304. self.progressBar.setValue(100)
  305. self.buttonBox.show()
  306. return True
  307. asyncio.create_task(coro())
  308. class QvmTemplateWindow(
  309. ui_qvmtemplate.Ui_QubesTemplateManager,
  310. PyQt5.QtWidgets.QMainWindow):
  311. def __init__(self, qt_app, qubes_app, dispatcher, parent=None):
  312. _ = parent # unused
  313. super().__init__()
  314. self.setupUi(self)
  315. self.qubes_app = qubes_app
  316. self.qt_app = qt_app
  317. self.dispatcher = dispatcher
  318. self.listing_model = TemplateModel()
  319. self.listing_delegate = TemplateStatusDelegate(self.listing)
  320. self.listing.setModel(self.listing_model)
  321. self.listing.setItemDelegateForColumn(0, self.listing_delegate)
  322. self.refresh(False)
  323. self.listing.setItemDelegateForColumn(0, self.listing_delegate)
  324. self.listing.selectionModel() \
  325. .selectionChanged.connect(self.update_info)
  326. self.actionRefresh.triggered.connect(lambda: self.refresh(True))
  327. self.actionInstall.triggered.connect(self.do_install)
  328. def update_info(self, selected):
  329. _ = selected # unused
  330. indices = [
  331. x
  332. for x in self.listing.selectionModel().selectedIndexes()
  333. if x.column() == 0]
  334. if len(indices) == 0:
  335. return
  336. self.infobox.clear()
  337. cursor = PyQt5.QtGui.QTextCursor(self.infobox.document())
  338. bold_fmt = PyQt5.QtGui.QTextCharFormat()
  339. bold_fmt.setFontWeight(PyQt5.QtGui.QFont.Bold)
  340. norm_fmt = PyQt5.QtGui.QTextCharFormat()
  341. if len(indices) > 1:
  342. cursor.insertText('Selected templates:\n', bold_fmt)
  343. for idx in indices:
  344. tpl = self.listing_model.children[idx.row()]
  345. cursor.insertText(tpl.name + '-' + tpl.evr + '\n', norm_fmt)
  346. else:
  347. idx = indices[0]
  348. tpl = self.listing_model.children[idx.row()]
  349. cursor.insertText('Name: ', bold_fmt)
  350. cursor.insertText(tpl.name + '\n', norm_fmt)
  351. cursor.insertText('Description:\n', bold_fmt)
  352. cursor.insertText(tpl.description + '\n', norm_fmt)
  353. def refresh(self, refresh=True):
  354. self.progressBar.show()
  355. async def coro():
  356. ok, stderr = await self.listing_model.refresh(refresh)
  357. self.infobox.clear()
  358. if not ok:
  359. cursor = PyQt5.QtGui.QTextCursor(self.infobox.document())
  360. fmt = PyQt5.QtGui.QTextCharFormat()
  361. fmt.setFontWeight(PyQt5.QtGui.QFont.Bold)
  362. cursor.insertText('Failed to fetch template list:\n', fmt)
  363. fmt.setFontWeight(PyQt5.QtGui.QFont.Normal)
  364. cursor.insertText(stderr, fmt)
  365. self.progressBar.hide()
  366. asyncio.create_task(coro())
  367. def do_install(self):
  368. actions = self.listing_model.get_actions()
  369. confirm = TemplateInstallConfirmDialog(actions)
  370. if confirm.exec_():
  371. progress = TemplateInstallProgressDialog(actions)
  372. progress.install()
  373. progress.exec_()
  374. self.refresh()
  375. def main():
  376. utils.run_asynchronous(QvmTemplateWindow)
  377. if __name__ == '__main__':
  378. main()