qvm_template_gui.py 16 KB

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