diff --git a/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py
new file mode 100644
index 0000000..4674832
--- /dev/null
+++ b/qubesmanager/qvm_template_gui.py
@@ -0,0 +1,405 @@
+import collections
+import concurrent
+import concurrent.futures
+import itertools
+import json
+import os
+import subprocess
+import typing
+
+import gi
+gi.require_version('Gtk', '3.0')
+
+#pylint: disable=wrong-import-position
+from gi.repository import GLib
+from gi.repository import Gtk
+from gi.repository import Pango
+
+BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes', '--quiet']
+
+class Template(typing.NamedTuple):
+ status: str
+ name: str
+ evr: str
+ reponame: str
+ size: int
+ buildtime: str
+ installtime: str
+ licence: str
+ url: str
+ summary: str
+ # --- internal ---
+ description: str
+ default_status: str
+ weight: int
+ model: Gtk.TreeModel
+ # ----------------
+
+ # XXX: Is there a better way of doing this?
+ TYPES = [str, str, str, str, int, str, str, str,
+ str, str, str, str, int, Gtk.TreeModel]
+
+ COL_NAMES = [
+ 'Status',
+ 'Name',
+ 'Version',
+ 'Reponame',
+ 'Size (kB)',
+ 'Build Time',
+ 'Install Time',
+ 'License',
+ 'URL',
+ 'Summary']
+
+ @staticmethod
+ def build(status, entry, model):
+ return Template(
+ status,
+ entry['name'],
+ '%s:%s-%s' % (entry['epoch'], entry['version'], entry['release']),
+ entry['reponame'],
+ # XXX: This may overflow glib ints, though pretty unlikely in the
+ # foreseeable future
+ int(entry['size']) / 1000,
+ entry['buildtime'],
+ entry['installtime'],
+ entry['license'],
+ entry['url'],
+ entry['summary'],
+ entry['description'],
+ status,
+ Pango.Weight.BOOK,
+ model
+ )
+
+class Action(typing.NamedTuple):
+ op: str
+ name: str
+ evr: str
+
+ TYPES = [str, str, str]
+ COL_NAMES = ['Operation', 'Name', 'Version']
+
+# TODO: Set default window sizes
+
+class ConfirmDialog(Gtk.Dialog):
+ def __init__(self, parent, actions):
+ super(ConfirmDialog, self).__init__(
+ title='Confirmation', transient_for=parent, modal=True)
+ self.add_buttons(
+ Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
+ Gtk.STOCK_OK, Gtk.ResponseType.OK)
+
+ box = self.get_content_area()
+ self.msg = Gtk.Label()
+ self.msg.set_markup((
+ 'WARNING: Local changes made to the following'
+ ' templates will be overwritten! Continue?'))
+ box.add(self.msg)
+
+ self.store = Gtk.ListStore(*Action.TYPES)
+ self.listing = Gtk.TreeView(model=self.store)
+ for idx, colname in enumerate(Action.COL_NAMES):
+ renderer = Gtk.CellRendererText()
+ col = Gtk.TreeViewColumn(colname, renderer, text=idx)
+ self.listing.append_column(col)
+ col.set_sort_column_id(idx)
+
+ for row in actions:
+ self.store.append(row)
+
+ self.scrollable_listing = Gtk.ScrolledWindow()
+ self.scrollable_listing.add(self.listing)
+ box.pack_start(self.scrollable_listing, True, True, 16)
+
+ self.show_all()
+
+class ProgressDialog(Gtk.Dialog):
+ def __init__(self, parent):
+ super(ProgressDialog, self).__init__(
+ title='Processing...', transient_for=parent, modal=True)
+ box = self.get_content_area()
+
+ self.spinner = Gtk.Spinner()
+ self.spinner.start()
+ box.add(self.spinner)
+
+ self.msg = Gtk.Label()
+ self.msg.set_text('Processing...')
+ box.add(self.msg)
+
+ self.infobox = Gtk.TextView()
+ self.scrollable = Gtk.ScrolledWindow()
+ self.scrollable.add(self.infobox)
+
+ box.pack_start(self.scrollable, True, True, 16)
+
+ self.show_all()
+
+ def finish(self, success):
+ self.spinner.stop()
+ if success:
+ self.msg.set_text('Operations succeeded.')
+ else:
+ self.msg.set_markup('Error:')
+ self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
+ self.run()
+
+class QubesTemplateApp(Gtk.Window):
+ def __init__(self):
+ super(QubesTemplateApp, self).__init__(title='Qubes Template Manager')
+
+ self.iconsize = Gtk.IconSize.SMALL_TOOLBAR
+
+ self.executor = concurrent.futures.ThreadPoolExecutor()
+ self.outerbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ self.__build_action_models()
+ self.__build_toolbar()
+ self.__build_listing()
+ self.__build_infobox()
+
+ self.add(self.outerbox)
+
+ def __build_action_models(self):
+ #pylint: disable=invalid-name
+ OPS = [
+ ['Installed', 'Reinstall', 'Remove'],
+ ['Extra', 'Remove'],
+ ['Upgradable', 'Upgrade', 'Remove'],
+ ['Downgradable', 'Downgrade', 'Remove'],
+ ['Available', 'Install']
+ ]
+ self.action_models = {}
+ for ops in OPS:
+ # First element is the default status for the certain class of
+ # templates
+ self.action_models[ops[0]] = Gtk.ListStore(str)
+ for oper in ops:
+ self.action_models[ops[0]].append([oper])
+
+ def __build_toolbar(self):
+ self.toolbar = Gtk.Toolbar()
+ self.btn_refresh = Gtk.ToolButton(
+ icon_widget=Gtk.Image.new_from_icon_name(
+ 'view-refresh', self.iconsize),
+ label='Refresh')
+ self.btn_refresh.connect('clicked', self.refresh)
+ self.toolbar.insert(self.btn_refresh, 0)
+
+ self.btn_install = Gtk.ToolButton(
+ icon_widget=Gtk.Image.new_from_icon_name('go-down', self.iconsize),
+ label='Apply')
+ self.btn_install.connect('clicked', self.show_confirm)
+ self.toolbar.insert(self.btn_install, 1)
+
+ self.outerbox.pack_start(self.toolbar, False, True, 0)
+
+ def __build_listing(self):
+ self.store = Gtk.ListStore(*Template.TYPES)
+
+ self.listing = Gtk.TreeView(model=self.store)
+ self.cols = []
+ for idx, colname in enumerate(Template.COL_NAMES):
+ if colname == 'Status':
+ renderer = Gtk.CellRendererCombo()
+ renderer.set_property('editable', True)
+ renderer.set_property('has-entry', False)
+ renderer.set_property('text-column', 0)
+ renderer.connect('edited', self.entry_edit)
+ col = Gtk.TreeViewColumn(
+ colname,
+ renderer,
+ text=idx,
+ weight=len(Template.TYPES) - 2,
+ model=len(Template.TYPES) - 1)
+ else:
+ renderer = Gtk.CellRendererText()
+ col = Gtk.TreeViewColumn(
+ colname,
+ renderer,
+ text=idx,
+ weight=len(Template.TYPES) - 2)
+ # Right-align for integers
+ if Template.TYPES[idx] is int:
+ renderer.set_property('xalign', 1.0)
+ self.cols.append(col)
+ self.listing.append_column(col)
+ col.set_sort_column_id(idx)
+ sel = self.listing.get_selection()
+ sel.set_mode(Gtk.SelectionMode.MULTIPLE)
+ sel.connect('changed', self.update_info)
+
+ self.scrollable_listing = Gtk.ScrolledWindow()
+ self.scrollable_listing.add(self.listing)
+ self.scrollable_listing.set_visible(False)
+
+ self.spinner = Gtk.Spinner()
+
+ self.outerbox.pack_start(self.scrollable_listing, True, True, 0)
+ self.outerbox.pack_start(self.spinner, True, True, 0)
+
+ def __build_infobox(self):
+ self.infobox = Gtk.TextView()
+ self.outerbox.pack_start(self.infobox, True, True, 16)
+
+ def refresh(self, button=None):
+ # Ignore if we're already doing a refresh
+ #pylint: disable=no-member
+ if self.spinner.props.active:
+ return
+ self.scrollable_listing.set_visible(False)
+ self.spinner.start()
+ self.spinner.set_visible(True)
+ self.store.clear()
+ def worker():
+ cmd = BASE_CMD[:]
+ if button is not None:
+ # Force refresh if triggered by button press
+ cmd.append('--refresh')
+ cmd.extend(['info', '--machine-readable-json', '--installed',
+ '--available', '--upgrades', '--extras'])
+ output = subprocess.check_output(cmd)
+ # Default type is dict as we're going to replace the lists with
+ # dicts shortly after
+ tpls = collections.defaultdict(dict, json.loads(output))
+ # Remove duplicates
+ # Should this be done in qvm-template?
+ # TODO: Merge templates with same name?
+ # If so, we may need to have a separate UI to force versions.
+ local_names = set(x['name'] for x in tpls['installed'])
+ # Convert to dict for easier subtraction
+ for key in tpls:
+ tpls[key] = {
+ (x['name'], x['epoch'], x['version'], x['release']): x
+ for x in tpls[key]}
+ tpls['installed'] = {
+ k: v for k, v in tpls['installed'].items()
+ if k not in tpls['extra'] and k not in tpls['upgradable']}
+ tpls['available'] = {
+ k: v for k, v in tpls['available'].items()
+ if k not in tpls['installed']
+ and k not in tpls['upgradable']}
+ # If the package name is installed but the specific version is
+ # neither installed or an upgrade, then it must be a downgrade
+ tpls['downgradable'] = {
+ k: v for k, v in tpls['available'].items()
+ if k[0] in local_names}
+ tpls['available'] = {
+ k: v for k, v in tpls['available'].items()
+ if k not in tpls['downgradable']}
+ # Convert back to list
+ for key in tpls:
+ tpls[key] = list(tpls[key].values())
+ for status, seq in tpls.items():
+ status_str = status.title()
+ for entry in seq:
+ self.store.append(Template.build(
+ status_str, entry, self.action_models[status_str]))
+
+ def finish_cb(future):
+ def callback():
+ if future.exception() is not None:
+ buf = self.infobox.get_buffer()
+ buf.set_text('Error:\n' + str(future.exception()))
+ self.spinner.set_visible(False)
+ self.spinner.stop()
+ self.scrollable_listing.set_visible(True)
+ GLib.idle_add(callback)
+
+ future = self.executor.submit(worker)
+ future.add_done_callback(finish_cb)
+
+ def show_confirm(self, button=None):
+ _ = button # unused
+ actions = []
+ for row in self.store:
+ tpl = Template(*row)
+ if tpl.status != tpl.default_status:
+ actions.append(Action(tpl.status, tpl.name, tpl.evr))
+ dialog = ConfirmDialog(self, actions)
+ resp = dialog.run()
+ dialog.destroy()
+ if resp == Gtk.ResponseType.OK:
+ self.do_install(actions)
+
+ def do_install(self, actions):
+ dialog = ProgressDialog(self)
+ def worker():
+ actions.sort()
+ for oper, grp in itertools.groupby(actions, lambda x: x[0]):
+ oper = oper.lower()
+ # No need to specify versions for local operations
+ if oper in ('remove', 'purge'):
+ specs = [x.name for x in grp]
+ else:
+ specs = [x.name + '-' + x.evr for x in grp]
+ # FIXME: (C)Python versions before 3.9 fully-buffers stderr in
+ # this context, cf. https://bugs.python.org/issue13601
+ # Forcing it to be unbuffered for the time being so that
+ # the messages can be displayed in time.
+ envs = os.environ.copy()
+ envs['PYTHONUNBUFFERED'] = '1'
+ proc = subprocess.Popen(
+ BASE_CMD + [oper, '--'] + specs,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ env=envs)
+ #pylint: disable=cell-var-from-loop
+ for line in iter(proc.stdout.readline, ''):
+ # Need to modify the buffers in the main thread
+ def callback():
+ buf = dialog.infobox.get_buffer()
+ end_iter = buf.get_end_iter()
+ buf.insert(end_iter, line)
+ GLib.idle_add(callback)
+ if proc.wait() != 0:
+ return False
+ return True
+
+ def finish_cb(future):
+ def callback():
+ dialog.finish(future.result())
+ dialog.destroy()
+ self.refresh()
+ GLib.idle_add(callback)
+
+ future = self.executor.submit(worker)
+ future.add_done_callback(finish_cb)
+
+ def update_info(self, sel):
+ model, treeiters = sel.get_selected_rows()
+ if not treeiters:
+ return
+ buf = self.infobox.get_buffer()
+ if len(treeiters) > 1:
+ def row_to_spec(row):
+ tpl = Template(*row)
+ return tpl.name + '-' + tpl.evr
+ text = '\n'.join(row_to_spec(model[it]) for it in treeiters)
+ buf.set_text('Selected templates:\n' + text)
+ else:
+ itr = treeiters[0]
+ tpl = Template(*model[itr])
+ text = 'Name: %s\n\nDescription:\n%s' % (tpl.name, tpl.description)
+ buf.set_text(text)
+
+ def entry_edit(self, widget, path, text):
+ _ = widget # unused
+ #pylint: disable=unsubscriptable-object
+ tpl = Template(*self.store[path])
+ tpl = tpl._replace(status=text)
+ if text == tpl.default_status:
+ tpl = tpl._replace(weight=Pango.Weight.BOOK)
+ else:
+ tpl = tpl._replace(weight=Pango.Weight.BOLD)
+ #pylint: disable=unsupported-assignment-operation
+ self.store[path] = tpl
+
+if __name__ == '__main__':
+ main = QubesTemplateApp()
+ main.connect('destroy', Gtk.main_quit)
+ main.show_all()
+ main.refresh()
+ Gtk.main()