qvm-template: Initial GUI implementation.
This commit is contained in:
parent
3e512bb7c3
commit
79b6d8f72c
405
qubesmanager/qvm_template_gui.py
Normal file
405
qubesmanager/qvm_template_gui.py
Normal file
@ -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((
|
||||||
|
'<b>WARNING: Local changes made to the following'
|
||||||
|
' templates will be overwritten! Continue?</b>'))
|
||||||
|
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('<b>Error:</b>')
|
||||||
|
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()
|
Loading…
Reference in New Issue
Block a user