qvm_template_gui.py 15 KB

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