qvm_template_gui.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. import collections
  2. import concurrent
  3. import concurrent.futures
  4. import itertools
  5. import json
  6. import os
  7. import subprocess
  8. import typing
  9. import gi
  10. gi.require_version('Gtk', '3.0')
  11. #pylint: disable=wrong-import-position
  12. from gi.repository import GLib
  13. from gi.repository import Gtk
  14. from gi.repository import Pango
  15. BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes', '--quiet']
  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. weight: int
  31. model: Gtk.TreeModel
  32. # ----------------
  33. # XXX: Is there a better way of doing this?
  34. TYPES = [str, str, str, str, int, str, str, str,
  35. str, str, str, str, int, Gtk.TreeModel]
  36. COL_NAMES = [
  37. 'Status',
  38. 'Name',
  39. 'Version',
  40. 'Reponame',
  41. 'Size (kB)',
  42. 'Build Time',
  43. 'Install Time',
  44. 'License',
  45. 'URL',
  46. 'Summary']
  47. @staticmethod
  48. def build(status, entry, model):
  49. return Template(
  50. status,
  51. entry['name'],
  52. '%s:%s-%s' % (entry['epoch'], entry['version'], entry['release']),
  53. entry['reponame'],
  54. # XXX: This may overflow glib ints, though pretty unlikely in the
  55. # foreseeable future
  56. int(entry['size']) / 1000,
  57. entry['buildtime'],
  58. entry['installtime'],
  59. entry['license'],
  60. entry['url'],
  61. entry['summary'],
  62. entry['description'],
  63. status,
  64. Pango.Weight.BOOK,
  65. model
  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. # TODO: Set default window sizes
  74. class ConfirmDialog(Gtk.Dialog):
  75. def __init__(self, parent, actions):
  76. super(ConfirmDialog, self).__init__(
  77. title='Confirmation', transient_for=parent, modal=True)
  78. self.add_buttons(
  79. Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
  80. Gtk.STOCK_OK, Gtk.ResponseType.OK)
  81. box = self.get_content_area()
  82. self.msg = Gtk.Label()
  83. self.msg.set_markup((
  84. '<b>WARNING: Local changes made to the following'
  85. ' templates will be overwritten! Continue?</b>'))
  86. box.add(self.msg)
  87. self.store = Gtk.ListStore(*Action.TYPES)
  88. self.listing = Gtk.TreeView(model=self.store)
  89. for idx, colname in enumerate(Action.COL_NAMES):
  90. renderer = Gtk.CellRendererText()
  91. col = Gtk.TreeViewColumn(colname, renderer, text=idx)
  92. self.listing.append_column(col)
  93. col.set_sort_column_id(idx)
  94. for row in actions:
  95. self.store.append(row)
  96. self.scrollable_listing = Gtk.ScrolledWindow()
  97. self.scrollable_listing.add(self.listing)
  98. box.pack_start(self.scrollable_listing, True, True, 16)
  99. self.show_all()
  100. class ProgressDialog(Gtk.Dialog):
  101. def __init__(self, parent):
  102. super(ProgressDialog, self).__init__(
  103. title='Processing...', transient_for=parent, modal=True)
  104. box = self.get_content_area()
  105. self.spinner = Gtk.Spinner()
  106. self.spinner.start()
  107. box.add(self.spinner)
  108. self.msg = Gtk.Label()
  109. self.msg.set_text('Processing...')
  110. box.add(self.msg)
  111. self.infobox = Gtk.TextView()
  112. self.scrollable = Gtk.ScrolledWindow()
  113. self.scrollable.add(self.infobox)
  114. box.pack_start(self.scrollable, True, True, 16)
  115. self.show_all()
  116. def finish(self, success):
  117. self.spinner.stop()
  118. if success:
  119. self.msg.set_text('Operations succeeded.')
  120. else:
  121. self.msg.set_markup('<b>Error:</b>')
  122. self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
  123. self.run()
  124. class QubesTemplateApp(Gtk.Window):
  125. def __init__(self):
  126. super(QubesTemplateApp, self).__init__(title='Qubes Template Manager')
  127. self.iconsize = Gtk.IconSize.SMALL_TOOLBAR
  128. self.executor = concurrent.futures.ThreadPoolExecutor()
  129. self.outerbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
  130. self.__build_action_models()
  131. self.__build_toolbar()
  132. self.__build_listing()
  133. self.__build_infobox()
  134. self.add(self.outerbox)
  135. def __build_action_models(self):
  136. #pylint: disable=invalid-name
  137. OPS = [
  138. ['Installed', 'Reinstall', 'Remove'],
  139. ['Extra', 'Remove'],
  140. ['Upgradable', 'Upgrade', 'Remove'],
  141. ['Downgradable', 'Downgrade', 'Remove'],
  142. ['Available', 'Install']
  143. ]
  144. self.action_models = {}
  145. for ops in OPS:
  146. # First element is the default status for the certain class of
  147. # templates
  148. self.action_models[ops[0]] = Gtk.ListStore(str)
  149. for oper in ops:
  150. self.action_models[ops[0]].append([oper])
  151. def __build_toolbar(self):
  152. self.toolbar = Gtk.Toolbar()
  153. self.btn_refresh = Gtk.ToolButton(
  154. icon_widget=Gtk.Image.new_from_icon_name(
  155. 'view-refresh', self.iconsize),
  156. label='Refresh')
  157. self.btn_refresh.connect('clicked', self.refresh)
  158. self.toolbar.insert(self.btn_refresh, 0)
  159. self.btn_install = Gtk.ToolButton(
  160. icon_widget=Gtk.Image.new_from_icon_name('go-down', self.iconsize),
  161. label='Apply')
  162. self.btn_install.connect('clicked', self.show_confirm)
  163. self.toolbar.insert(self.btn_install, 1)
  164. self.outerbox.pack_start(self.toolbar, False, True, 0)
  165. def __build_listing(self):
  166. self.store = Gtk.ListStore(*Template.TYPES)
  167. self.listing = Gtk.TreeView(model=self.store)
  168. self.cols = []
  169. for idx, colname in enumerate(Template.COL_NAMES):
  170. if colname == 'Status':
  171. renderer = Gtk.CellRendererCombo()
  172. renderer.set_property('editable', True)
  173. renderer.set_property('has-entry', False)
  174. renderer.set_property('text-column', 0)
  175. renderer.connect('edited', self.entry_edit)
  176. col = Gtk.TreeViewColumn(
  177. colname,
  178. renderer,
  179. text=idx,
  180. weight=len(Template.TYPES) - 2,
  181. model=len(Template.TYPES) - 1)
  182. else:
  183. renderer = Gtk.CellRendererText()
  184. col = Gtk.TreeViewColumn(
  185. colname,
  186. renderer,
  187. text=idx,
  188. weight=len(Template.TYPES) - 2)
  189. # Right-align for integers
  190. if Template.TYPES[idx] is int:
  191. renderer.set_property('xalign', 1.0)
  192. self.cols.append(col)
  193. self.listing.append_column(col)
  194. col.set_sort_column_id(idx)
  195. sel = self.listing.get_selection()
  196. sel.set_mode(Gtk.SelectionMode.MULTIPLE)
  197. sel.connect('changed', self.update_info)
  198. self.scrollable_listing = Gtk.ScrolledWindow()
  199. self.scrollable_listing.add(self.listing)
  200. self.scrollable_listing.set_visible(False)
  201. self.spinner = Gtk.Spinner()
  202. self.outerbox.pack_start(self.scrollable_listing, True, True, 0)
  203. self.outerbox.pack_start(self.spinner, True, True, 0)
  204. def __build_infobox(self):
  205. self.infobox = Gtk.TextView()
  206. self.outerbox.pack_start(self.infobox, True, True, 16)
  207. def refresh(self, button=None):
  208. # Ignore if we're already doing a refresh
  209. #pylint: disable=no-member
  210. if self.spinner.props.active:
  211. return
  212. self.scrollable_listing.set_visible(False)
  213. self.spinner.start()
  214. self.spinner.set_visible(True)
  215. self.store.clear()
  216. def worker():
  217. cmd = BASE_CMD[:]
  218. if button is not None:
  219. # Force refresh if triggered by button press
  220. cmd.append('--refresh')
  221. cmd.extend(['info', '--machine-readable-json', '--installed',
  222. '--available', '--upgrades', '--extras'])
  223. output = subprocess.check_output(cmd)
  224. # Default type is dict as we're going to replace the lists with
  225. # dicts shortly after
  226. tpls = collections.defaultdict(dict, json.loads(output))
  227. # Remove duplicates
  228. # Should this be done in qvm-template?
  229. # TODO: Merge templates with same name?
  230. # If so, we may need to have a separate UI to force versions.
  231. local_names = set(x['name'] for x in tpls['installed'])
  232. # Convert to dict for easier subtraction
  233. for key in tpls:
  234. tpls[key] = {
  235. (x['name'], x['epoch'], x['version'], x['release']): x
  236. for x in tpls[key]}
  237. tpls['installed'] = {
  238. k: v for k, v in tpls['installed'].items()
  239. if k not in tpls['extra'] and k not in tpls['upgradable']}
  240. tpls['available'] = {
  241. k: v for k, v in tpls['available'].items()
  242. if k not in tpls['installed']
  243. and k not in tpls['upgradable']}
  244. # If the package name is installed but the specific version is
  245. # neither installed or an upgrade, then it must be a downgrade
  246. tpls['downgradable'] = {
  247. k: v for k, v in tpls['available'].items()
  248. if k[0] in local_names}
  249. tpls['available'] = {
  250. k: v for k, v in tpls['available'].items()
  251. if k not in tpls['downgradable']}
  252. # Convert back to list
  253. for key in tpls:
  254. tpls[key] = list(tpls[key].values())
  255. for status, seq in tpls.items():
  256. status_str = status.title()
  257. for entry in seq:
  258. self.store.append(Template.build(
  259. status_str, entry, self.action_models[status_str]))
  260. def finish_cb(future):
  261. def callback():
  262. if future.exception() is not None:
  263. buf = self.infobox.get_buffer()
  264. buf.set_text('Error:\n' + str(future.exception()))
  265. self.spinner.set_visible(False)
  266. self.spinner.stop()
  267. self.scrollable_listing.set_visible(True)
  268. GLib.idle_add(callback)
  269. future = self.executor.submit(worker)
  270. future.add_done_callback(finish_cb)
  271. def show_confirm(self, button=None):
  272. _ = button # unused
  273. actions = []
  274. for row in self.store:
  275. tpl = Template(*row)
  276. if tpl.status != tpl.default_status:
  277. actions.append(Action(tpl.status, tpl.name, tpl.evr))
  278. dialog = ConfirmDialog(self, actions)
  279. resp = dialog.run()
  280. dialog.destroy()
  281. if resp == Gtk.ResponseType.OK:
  282. self.do_install(actions)
  283. def do_install(self, actions):
  284. dialog = ProgressDialog(self)
  285. def worker():
  286. actions.sort()
  287. for oper, grp in itertools.groupby(actions, lambda x: x[0]):
  288. oper = oper.lower()
  289. # No need to specify versions for local operations
  290. if oper in ('remove', 'purge'):
  291. specs = [x.name for x in grp]
  292. else:
  293. specs = [x.name + '-' + x.evr for x in grp]
  294. # FIXME: (C)Python versions before 3.9 fully-buffers stderr in
  295. # this context, cf. https://bugs.python.org/issue13601
  296. # Forcing it to be unbuffered for the time being so that
  297. # the messages can be displayed in time.
  298. envs = os.environ.copy()
  299. envs['PYTHONUNBUFFERED'] = '1'
  300. proc = subprocess.Popen(
  301. BASE_CMD + [oper, '--'] + specs,
  302. stdout=subprocess.PIPE,
  303. stderr=subprocess.STDOUT,
  304. text=True,
  305. bufsize=1,
  306. env=envs)
  307. #pylint: disable=cell-var-from-loop
  308. for line in iter(proc.stdout.readline, ''):
  309. # Need to modify the buffers in the main thread
  310. def callback():
  311. buf = dialog.infobox.get_buffer()
  312. end_iter = buf.get_end_iter()
  313. buf.insert(end_iter, line)
  314. GLib.idle_add(callback)
  315. if proc.wait() != 0:
  316. return False
  317. return True
  318. def finish_cb(future):
  319. def callback():
  320. dialog.finish(future.result())
  321. dialog.destroy()
  322. self.refresh()
  323. GLib.idle_add(callback)
  324. future = self.executor.submit(worker)
  325. future.add_done_callback(finish_cb)
  326. def update_info(self, sel):
  327. model, treeiters = sel.get_selected_rows()
  328. if not treeiters:
  329. return
  330. buf = self.infobox.get_buffer()
  331. if len(treeiters) > 1:
  332. def row_to_spec(row):
  333. tpl = Template(*row)
  334. return tpl.name + '-' + tpl.evr
  335. text = '\n'.join(row_to_spec(model[it]) for it in treeiters)
  336. buf.set_text('Selected templates:\n' + text)
  337. else:
  338. itr = treeiters[0]
  339. tpl = Template(*model[itr])
  340. text = 'Name: %s\n\nDescription:\n%s' % (tpl.name, tpl.description)
  341. buf.set_text(text)
  342. def entry_edit(self, widget, path, text):
  343. _ = widget # unused
  344. #pylint: disable=unsubscriptable-object
  345. tpl = Template(*self.store[path])
  346. tpl = tpl._replace(status=text)
  347. if text == tpl.default_status:
  348. tpl = tpl._replace(weight=Pango.Weight.BOOK)
  349. else:
  350. tpl = tpl._replace(weight=Pango.Weight.BOLD)
  351. #pylint: disable=unsupported-assignment-operation
  352. self.store[path] = tpl
  353. if __name__ == '__main__':
  354. main = QubesTemplateApp()
  355. main.connect('destroy', Gtk.main_quit)
  356. main.show_all()
  357. main.refresh()
  358. Gtk.main()