From 79b6d8f72c7f88de638dcac56c99c20391a6242f Mon Sep 17 00:00:00 2001 From: WillyPillow Date: Wed, 19 Aug 2020 02:02:05 +0800 Subject: [PATCH 01/11] qvm-template: Initial GUI implementation. --- qubesmanager/qvm_template_gui.py | 405 +++++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 qubesmanager/qvm_template_gui.py diff --git a/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py new file mode 100644 index 00000000..46748321 --- /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() From 7e8ee7e8cc93c1c46d0082e39e8d4f769258adc7 Mon Sep 17 00:00:00 2001 From: WillyPillow Date: Tue, 25 Aug 2020 00:17:24 +0800 Subject: [PATCH 02/11] qvm-template: Port initial code to PyQt. --- qubesmanager/qvm_template_gui.py | 657 ++++++++++++++++--------------- ui/qvmtemplate.ui | 104 +++++ ui/templateinstallconfirmdlg.ui | 84 ++++ ui/templateinstallprogressdlg.ui | 81 ++++ 4 files changed, 602 insertions(+), 324 deletions(-) create mode 100644 ui/qvmtemplate.ui create mode 100644 ui/templateinstallconfirmdlg.ui create mode 100644 ui/templateinstallprogressdlg.ui diff --git a/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py index 46748321..944680fb 100644 --- a/qubesmanager/qvm_template_gui.py +++ b/qubesmanager/qvm_template_gui.py @@ -1,19 +1,19 @@ +import asyncio 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') +import PyQt5 +import PyQt5.QtWidgets -#pylint: disable=wrong-import-position -from gi.repository import GLib -from gi.repository import Gtk -from gi.repository import Pango +from . import ui_qvmtemplate +from . import ui_templateinstallconfirmdlg +from . import ui_templateinstallprogressdlg +from . import utils + +#pylint: disable=invalid-name BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes', '--quiet'] @@ -28,16 +28,10 @@ class Template(typing.NamedTuple): licence: str url: str summary: str - # --- internal --- + # ---- 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', @@ -49,27 +43,24 @@ class Template(typing.NamedTuple): 'Install Time', 'License', 'URL', - 'Summary'] + 'Summary' + ] @staticmethod - def build(status, entry, model): + def build(status, entry): 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, + int(entry['size']) // 1000, entry['buildtime'], entry['installtime'], entry['license'], entry['url'], entry['summary'], entry['description'], - status, - Pango.Weight.BOOK, - model + status ) class Action(typing.NamedTuple): @@ -80,253 +71,230 @@ class Action(typing.NamedTuple): 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): +class TemplateStatusDelegate(PyQt5.QtWidgets.QStyledItemDelegate): + OPS = [ + ['Installed', 'Reinstall', 'Remove'], + ['Extra', 'Remove'], + ['Upgradable', 'Upgrade', 'Remove'], + ['Downgradable', 'Downgrade', 'Remove'], + ['Available', 'Install'] + ] + + def createEditor(self, parent, option, index): + _ = option # unused + editor = PyQt5.QtWidgets.QComboBox(parent) + # Otherwise the internalPointer can be overwritten with a QComboBox + index = index.model().index(index.row(), index.column()) + kind = index.internalPointer().default_status + for op_list in TemplateStatusDelegate.OPS: + if op_list[0] == kind: + for op in op_list: + editor.addItem(op) + editor.currentIndexChanged.connect(self.currentIndexChanged) + editor.showPopup() + return editor + return None + + def setEditorData(self, editor, index): + #pylint: disable=no-self-use + cur = index.data() + idx = editor.findText(cur) + if idx >= 0: + editor.setCurrentIndex(idx) + + def setModelData(self, editor, model, index): + #pylint: disable=no-self-use + model.setData(index, editor.currentText()) + + def updateEditorGeometry(self, editor, option, index): + #pylint: disable=no-self-use + _ = index # unused + editor.setGeometry(option.rect) + + @PyQt5.QtCore.pyqtSlot() + def currentIndexChanged(self): + self.commitData.emit(self.sender()) + +class TemplateModel(PyQt5.QtCore.QAbstractItemModel): 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 + super().__init__() + + self.children = [] + + def flags(self, index): + if index.isValid() and index.column() == 0: + return super().flags(index) | PyQt5.QtCore.Qt.ItemIsEditable + return super().flags(index) + + def sort(self, idx, order): + rev = (order == PyQt5.QtCore.Qt.AscendingOrder) + self.children.sort(key=lambda x: x[idx], reverse=rev) + + self.dataChanged.emit(*self.row_index(0, self.rowCount() - 1)) + + def index(self, row, column, parent=PyQt5.QtCore.QModelIndex()): + if not self.hasIndex(row, column, parent): + return PyQt5.QtCore.QModelIndex() + + return self.createIndex(row, column, self.children[row]) + + def parent(self, child): + #pylint: disable=no-self-use + _ = child # unused + return PyQt5.QtCore.QModelIndex() + + def rowCount(self, parent=PyQt5.QtCore.QModelIndex()): + #pylint: disable=no-self-use + _ = parent # unused + return len(self.children) + + def columnCount(self, parent=PyQt5.QtCore.QModelIndex()): + #pylint: disable=no-self-use + _ = parent # unused + return len(Template.COL_NAMES) + + def hasChildren(self, index=PyQt5.QtCore.QModelIndex()): + #pylint: disable=no-self-use + return index == PyQt5.QtCore.QModelIndex() + + def data(self, index, role=PyQt5.QtCore.Qt.DisplayRole): + if index.isValid(): + if role == PyQt5.QtCore.Qt.DisplayRole: + return self.children[index.row()][index.column()] + if role == PyQt5.QtCore.Qt.FontRole: + font = PyQt5.QtGui.QFont() + tpl = self.children[index.row()] + font.setBold(tpl.status != tpl.default_status) + return font + if role == PyQt5.QtCore.Qt.TextAlignmentRole: + if isinstance(self.children[index.row()][index.column()], int): + return PyQt5.QtCore.Qt.AlignRight + return PyQt5.QtCore.Qt.AlignLeft + return None + + def setData(self, index, value, role=PyQt5.QtCore.Qt.EditRole): + if index.isValid() and role == PyQt5.QtCore.Qt.EditRole: + old_list = list(self.children[index.row()]) + old_list[index.column()] = value + new_tpl = Template(*old_list) + self.children[index.row()] = new_tpl + self.dataChanged.emit(index, index) + return True + return False + + def headerData(self, section, orientation, + role=PyQt5.QtCore.Qt.DisplayRole): + #pylint: disable=no-self-use + if section < len(Template.COL_NAMES) \ + and orientation == PyQt5.QtCore.Qt.Horizontal \ + and role == PyQt5.QtCore.Qt.DisplayRole: + return Template.COL_NAMES[section] + return None + + def removeRows(self, row, count, parent=PyQt5.QtCore.QModelIndex()): + _ = parent # unused + self.beginRemoveRows(PyQt5.QtCore.QModelIndex(), row, row + count) + del self.children[row:row+count] + self.endRemoveRows() + self.dataChanged.emit(*self.row_index(row, row + count)) + + def row_index(self, low, high): + return self.createIndex(low, 0), \ + self.createIndex(high, self.columnCount()) + + def set_templates(self, templates): + self.removeRows(0, self.rowCount()) + cnt = sum(len(g) for _, g in templates.items()) + self.beginInsertRows(PyQt5.QtCore.QModelIndex(), 0, cnt - 1) + for status, grp in templates.items(): + for tpl in grp: + self.children.append(Template.build(status, tpl)) + self.endInsertRows() + self.dataChanged.emit(*self.row_index(0, self.rowCount() - 1)) + + def get_actions(self): actions = [] - for row in self.store: - tpl = Template(*row) + for tpl in self.children: 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]): + return actions + + async def refresh(self, refresh=True): + cmd = BASE_CMD[:] + if refresh: + # Force refresh if triggered by button press + cmd.append('--refresh') + cmd.extend(['info', '--machine-readable-json', '--installed', + '--available', '--upgrades', '--extras']) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE) + output, stderr = await proc.communicate() + output = output.decode('ASCII') + if proc.returncode != 0: + stderr = stderr.decode('ASCII') + return False, stderr + # 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 + tpls = {k.title(): list(v.values()) for k, v in tpls.items()} + self.set_templates(tpls) + return True, None + +class TemplateInstallConfirmDialog( + ui_templateinstallconfirmdlg.Ui_TemplateInstallConfirmDlg, + PyQt5.QtWidgets.QDialog): + def __init__(self, actions): + super().__init__() + self.setupUi(self) + + model = PyQt5.QtGui.QStandardItemModel() + model.setHorizontalHeaderLabels(Action.COL_NAMES) + self.treeView.setModel(model) + + for act in actions: + model.appendRow([PyQt5.QtGui.QStandardItem(x) for x in act]) + +class TemplateInstallProgressDialog( + ui_templateinstallprogressdlg.Ui_TemplateInstallProgressDlg, + PyQt5.QtWidgets.QDialog): + def __init__(self, actions): + super().__init__() + self.setupUi(self) + self.actions = actions + self.buttonBox.hide() + + def install(self): + async def coro(): + self.actions.sort() + for oper, grp in itertools.groupby(self.actions, lambda x: x[0]): oper = oper.lower() # No need to specify versions for local operations if oper in ('remove', 'purge'): @@ -339,67 +307,108 @@ def worker(): # 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, + proc = await asyncio.create_subprocess_exec( + *(BASE_CMD + [oper, '--'] + specs), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, 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: + while True: + line = await proc.stdout.readline() + if line == b'': + break + line = line.decode('ASCII') + self.textEdit.append(line.rstrip()) + if await proc.wait() != 0: + self.buttonBox.show() + self.progressBar.setMaximum(100) + self.progressBar.setValue(0) return False + self.progressBar.setMaximum(100) + self.progressBar.setValue(100) + self.buttonBox.show() 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: + asyncio.create_task(coro()) + +class QvmTemplateWindow( + ui_qvmtemplate.Ui_QubesTemplateManager, + PyQt5.QtWidgets.QMainWindow): + def __init__(self, qt_app, qubes_app, dispatcher, parent=None): + _ = parent # unused + + super().__init__() + self.setupUi(self) + + self.qubes_app = qubes_app + self.qt_app = qt_app + self.dispatcher = dispatcher + + self.listing_model = TemplateModel() + self.listing_delegate = TemplateStatusDelegate(self.listing) + + self.listing.setModel(self.listing_model) + self.listing.setItemDelegateForColumn(0, self.listing_delegate) + + self.refresh(False) + self.listing.setItemDelegateForColumn(0, self.listing_delegate) + self.listing.selectionModel() \ + .selectionChanged.connect(self.update_info) + + self.actionRefresh.triggered.connect(lambda: self.refresh(True)) + self.actionInstall.triggered.connect(self.do_install) + + def update_info(self, selected): + _ = selected # unused + indices = [ + x + for x in self.listing.selectionModel().selectedIndexes() + if x.column() == 0] + if len(indices) == 0: 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) + self.infobox.clear() + cursor = PyQt5.QtGui.QTextCursor(self.infobox.document()) + bold_fmt = PyQt5.QtGui.QTextCharFormat() + bold_fmt.setFontWeight(PyQt5.QtGui.QFont.Bold) + norm_fmt = PyQt5.QtGui.QTextCharFormat() + if len(indices) > 1: + cursor.insertText('Selected templates:\n', bold_fmt) + for idx in indices: + tpl = self.listing_model.children[idx.row()] + cursor.insertText(tpl.name + '-' + tpl.evr + '\n', norm_fmt) else: - tpl = tpl._replace(weight=Pango.Weight.BOLD) - #pylint: disable=unsupported-assignment-operation - self.store[path] = tpl + idx = indices[0] + tpl = self.listing_model.children[idx.row()] + cursor.insertText('Name: ', bold_fmt) + cursor.insertText(tpl.name + '\n', norm_fmt) + cursor.insertText('Description:\n', bold_fmt) + cursor.insertText(tpl.description + '\n', norm_fmt) + + def refresh(self, refresh=True): + self.progressBar.show() + async def coro(): + ok, stderr = await self.listing_model.refresh(refresh) + self.infobox.clear() + if not ok: + cursor = PyQt5.QtGui.QTextCursor(self.infobox.document()) + fmt = PyQt5.QtGui.QTextCharFormat() + fmt.setFontWeight(PyQt5.QtGui.QFont.Bold) + cursor.insertText('Failed to fetch template list:\n', fmt) + fmt.setFontWeight(PyQt5.QtGui.QFont.Normal) + cursor.insertText(stderr, fmt) + self.progressBar.hide() + asyncio.create_task(coro()) + + def do_install(self): + actions = self.listing_model.get_actions() + confirm = TemplateInstallConfirmDialog(actions) + if confirm.exec_(): + progress = TemplateInstallProgressDialog(actions) + progress.install() + progress.exec_() + self.refresh() + +def main(): + utils.run_asynchronous(QvmTemplateWindow) if __name__ == '__main__': - main = QubesTemplateApp() - main.connect('destroy', Gtk.main_quit) - main.show_all() - main.refresh() - Gtk.main() + main() diff --git a/ui/qvmtemplate.ui b/ui/qvmtemplate.ui new file mode 100644 index 00000000..36e5f50e --- /dev/null +++ b/ui/qvmtemplate.ui @@ -0,0 +1,104 @@ + + + QubesTemplateManager + + + + 0 + 0 + 465 + 478 + + + + Qubes Template Manager + + + + + + + 0 + + + -1 + + + + + + + QAbstractItemView::AllEditTriggers + + + QAbstractItemView::MultiSelection + + + false + + + true + + + false + + + + + + + true + + + Click the 'Status' column to change the status of templates. + + + + + + + + + 0 + 0 + 465 + 24 + + + + + + + toolBar + + + TopToolBarArea + + + false + + + + + + + + .. + + + Refresh + + + + + + .. + + + Install + + + + + + diff --git a/ui/templateinstallconfirmdlg.ui b/ui/templateinstallconfirmdlg.ui new file mode 100644 index 00000000..84e054b6 --- /dev/null +++ b/ui/templateinstallconfirmdlg.ui @@ -0,0 +1,84 @@ + + + TemplateInstallConfirmDlg + + + + 0 + 0 + 400 + 290 + + + + Template Install Confirmation + + + + + + <html><head/><body><p><span style=" font-weight:600;">WARNING: Local changes made to the following templates will be overwritten! Continue?</span></p></body></html> + + + true + + + + + + + true + + + false + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + TemplateInstallConfirmDlg + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + TemplateInstallConfirmDlg + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/ui/templateinstallprogressdlg.ui b/ui/templateinstallprogressdlg.ui new file mode 100644 index 00000000..5cc77915 --- /dev/null +++ b/ui/templateinstallprogressdlg.ui @@ -0,0 +1,81 @@ + + + TemplateInstallProgressDlg + + + + 0 + 0 + 400 + 300 + + + + Installing Templates... + + + + + + 0 + + + -1 + + + + + + + true + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + TemplateInstallProgressDlg + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + TemplateInstallProgressDlg + reject() + + + 316 + 260 + + + 286 + 274 + + + + + From d0fb97595814db000b991bd2a6cf27fd0854f793 Mon Sep 17 00:00:00 2001 From: WillyPillow Date: Sun, 30 Aug 2020 01:25:00 +0800 Subject: [PATCH 03/11] qvm-template: Disable multi-selection for simplicity. --- ui/qvmtemplate.ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/qvmtemplate.ui b/ui/qvmtemplate.ui index 36e5f50e..7f610796 100644 --- a/ui/qvmtemplate.ui +++ b/ui/qvmtemplate.ui @@ -31,7 +31,7 @@ QAbstractItemView::AllEditTriggers - QAbstractItemView::MultiSelection + QAbstractItemView::SingleSelection false From 5c5758783be354492557b86ddb01d4111021c0b1 Mon Sep 17 00:00:00 2001 From: WillyPillow Date: Sun, 30 Aug 2020 01:34:03 +0800 Subject: [PATCH 04/11] qvm-template: Change size unit from kB to MB. --- qubesmanager/qvm_template_gui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py index 944680fb..2870de5b 100644 --- a/qubesmanager/qvm_template_gui.py +++ b/qubesmanager/qvm_template_gui.py @@ -38,7 +38,7 @@ class Template(typing.NamedTuple): 'Name', 'Version', 'Reponame', - 'Size (kB)', + 'Size (MB)', 'Build Time', 'Install Time', 'License', @@ -53,7 +53,7 @@ def build(status, entry): entry['name'], '%s:%s-%s' % (entry['epoch'], entry['version'], entry['release']), entry['reponame'], - int(entry['size']) // 1000, + int(entry['size']) // 1000000, entry['buildtime'], entry['installtime'], entry['license'], From 0857ff86bbd324440d6e843980bab9615008f51d Mon Sep 17 00:00:00 2001 From: WillyPillow Date: Mon, 31 Aug 2020 00:46:45 +0800 Subject: [PATCH 05/11] qvm-template: Include files in deb/rpm package --- debian/install | 5 +++++ rpm_spec/qmgr.spec.in | 5 +++++ setup.py | 3 ++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/debian/install b/debian/install index 81795e75..1ebd7bbb 100644 --- a/debian/install +++ b/debian/install @@ -7,6 +7,7 @@ /usr/bin/qubes-qube-manager /usr/bin/qubes-log-viewer /usr/bin/qubes-template-manager +/usr/bin/qvm-template-gui /usr/bin/qubes-vm-clone /usr/libexec/qubes-manager/mount_for_backup.sh /usr/libexec/qubes-manager/qvm_about.sh @@ -34,6 +35,7 @@ /usr/lib/*/dist-packages/qubesmanager/bootfromdevice.py /usr/lib/*/dist-packages/qubesmanager/device_list.py /usr/lib/*/dist-packages/qubesmanager/template_manager.py +/usr/lib/*/dist-packages/qubesmanager/qvm_template_gui.py /usr/lib/*/dist-packages/qubesmanager/clone_vm.py /usr/lib/*/dist-packages/qubesmanager/resources_rc.py @@ -54,6 +56,9 @@ /usr/lib/*/dist-packages/qubesmanager/ui_devicelist.py /usr/lib/*/dist-packages/qubesmanager/ui_templatemanager.py /usr/lib/*/dist-packages/qubesmanager/ui_clonevmdlg.py +/usr/lib/*/dist-packages/qubesmanager/ui_qvmtemplate.py +/usr/lib/*/dist-packages/qubesmanager/ui_templateinstallconfirmdlg.py +/usr/lib/*/dist-packages/qubesmanager/ui_templateinstallprogressdlg.py /usr/lib/*/dist-packages/qubesmanager/i18n/qubesmanager_*.qm /usr/lib/*/dist-packages/qubesmanager/i18n/qubesmanager_*.ts diff --git a/rpm_spec/qmgr.spec.in b/rpm_spec/qmgr.spec.in index a535ae39..b1f7bf84 100644 --- a/rpm_spec/qmgr.spec.in +++ b/rpm_spec/qmgr.spec.in @@ -64,6 +64,7 @@ rm -rf $RPM_BUILD_ROOT /usr/bin/qubes-qube-manager /usr/bin/qubes-log-viewer /usr/bin/qubes-template-manager +/usr/bin/qvm-template-gui /usr/libexec/qubes-manager/mount_for_backup.sh /usr/libexec/qubes-manager/qvm_about.sh /usr/libexec/qubes-manager/dsa-4371-update @@ -92,6 +93,7 @@ rm -rf $RPM_BUILD_ROOT %{python3_sitelib}/qubesmanager/bootfromdevice.py %{python3_sitelib}/qubesmanager/device_list.py %{python3_sitelib}/qubesmanager/template_manager.py +%{python3_sitelib}/qubesmanager/qvm_template_gui.py %{python3_sitelib}/qubesmanager/resources_rc.py @@ -111,6 +113,9 @@ rm -rf $RPM_BUILD_ROOT %{python3_sitelib}/qubesmanager/ui_devicelist.py %{python3_sitelib}/qubesmanager/ui_templatemanager.py %{python3_sitelib}/qubesmanager/ui_clonevmdlg.py +%{python3_sitelib}/qubesmanager/ui_qvmtemplate.py +%{python3_sitelib}/qubesmanager/ui_templateinstallconfirmdlg.py +%{python3_sitelib}/qubesmanager/ui_templateinstallprogressdlg.py %{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.qm %{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.ts diff --git a/setup.py b/setup.py index 62260473..561623b1 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ 'qubes-backup-restore = qubesmanager.restore:main', 'qubes-qube-manager = qubesmanager.qube_manager:main', 'qubes-log-viewer = qubesmanager.log_dialog:main', - 'qubes-template-manager = qubesmanager.template_manager:main' + 'qubes-template-manager = qubesmanager.template_manager:main', + 'qvm-template-gui = qubesmanager.qvm_template_gui:main' ], }) From 679a23980dd63fad1ef371c6e97cf2e919fe5bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Thu, 1 Apr 2021 22:24:25 +0200 Subject: [PATCH 06/11] qvm-template-gui: UI improvements - rename "install" button to "apply" - it also does remove and reinstall - show button descriptions always - show help text always, not only before selecting anything - make the window a bit wider --- qubesmanager/qvm_template_gui.py | 2 +- ui/qvmtemplate.ui | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py index 2870de5b..5c5b78d9 100644 --- a/qubesmanager/qvm_template_gui.py +++ b/qubesmanager/qvm_template_gui.py @@ -38,7 +38,7 @@ class Template(typing.NamedTuple): 'Name', 'Version', 'Reponame', - 'Size (MB)', + 'Download Size (MB)', 'Build Time', 'Install Time', 'License', diff --git a/ui/qvmtemplate.ui b/ui/qvmtemplate.ui index 7f610796..0901e4af 100644 --- a/ui/qvmtemplate.ui +++ b/ui/qvmtemplate.ui @@ -6,7 +6,7 @@ 0 0 - 465 + 930 478 @@ -25,6 +25,13 @@ + + + + Select actions to perform in "Status" column: + + + @@ -50,7 +57,7 @@ true - Click the 'Status' column to change the status of templates. + Use "Status" column to select actions to execute on a given template; when desired actions are selected, use "Apply" to perform them. @@ -61,7 +68,7 @@ 0 0 - 465 + 930 24 @@ -71,6 +78,9 @@ toolBar + + Qt::ToolButtonTextUnderIcon + TopToolBarArea @@ -95,7 +105,7 @@ .. - Install + Apply From 03a34fbeddbf08bbb58e1d28e81c40c8ab27f4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Thu, 1 Apr 2021 22:25:48 +0200 Subject: [PATCH 07/11] qvm-template-gui: improve displaying progress The final version of qvm-template does not display untrusted data, so it is safe to decode UTF-8. Specifically, this shows download progress bar done using tqdm library. For this to work, \r needs to be properly handled, as tqdm uses it to erase the current line to update progress status. Since QTextEdit does not support it natively, add a simple wrapper for this job. Alternatively, qvm-template could be modified to display machine-readable progress info that would be passed through QT progress widget, but it would basically duplicate the work already done by tqdm library. We might do this at some point, though. And also enlarge progress window default size, to avoid wrapping. --- qubesmanager/qvm_template_gui.py | 23 +++++++++++++++++++---- ui/templateinstallprogressdlg.ui | 4 ++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py index 5c5b78d9..d4a8e2cb 100644 --- a/qubesmanager/qvm_template_gui.py +++ b/qubesmanager/qvm_template_gui.py @@ -15,7 +15,7 @@ #pylint: disable=invalid-name -BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes', '--quiet'] +BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes'] class Template(typing.NamedTuple): status: str @@ -291,6 +291,19 @@ def __init__(self, actions): self.actions = actions self.buttonBox.hide() + @staticmethod + def _process_cr(text): + """Reduce lines replaced using CR character (\r)""" + while '\r' in text: + prefix, suffix = text.rsplit('\r', 1) + if '\n' in prefix: + prefix = prefix.rsplit('\n', 1)[0] + prefix += '\n' + else: + prefix = '' + text = prefix + suffix + return text + def install(self): async def coro(): self.actions.sort() @@ -313,12 +326,14 @@ async def coro(): stderr=asyncio.subprocess.STDOUT, env=envs) #pylint: disable=cell-var-from-loop + status_text = '' while True: - line = await proc.stdout.readline() + line = await proc.stdout.read(100) if line == b'': break - line = line.decode('ASCII') - self.textEdit.append(line.rstrip()) + line = line.decode('UTF-8') + status_text = self._process_cr(status_text + line) + self.textEdit.setPlainText(status_text) if await proc.wait() != 0: self.buttonBox.show() self.progressBar.setMaximum(100) diff --git a/ui/templateinstallprogressdlg.ui b/ui/templateinstallprogressdlg.ui index 5cc77915..3f980820 100644 --- a/ui/templateinstallprogressdlg.ui +++ b/ui/templateinstallprogressdlg.ui @@ -6,8 +6,8 @@ 0 0 - 400 - 300 + 840 + 260 From be4e2050a14b3b31ae5d02698544db82ff54fa4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Fri, 2 Apr 2021 05:39:42 +0200 Subject: [PATCH 08/11] Make pylint happy --- qubesmanager/qvm_template_gui.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py index d4a8e2cb..7c9f8731 100644 --- a/qubesmanager/qvm_template_gui.py +++ b/qubesmanager/qvm_template_gui.py @@ -5,18 +5,19 @@ import os import typing -import PyQt5 -import PyQt5.QtWidgets +import PyQt5 # pylint: disable=import-error +import PyQt5.QtWidgets # pylint: disable=import-error -from . import ui_qvmtemplate -from . import ui_templateinstallconfirmdlg -from . import ui_templateinstallprogressdlg +from . import ui_qvmtemplate # pylint: disable=no-name-in-module +from . import ui_templateinstallconfirmdlg # pylint: disable=no-name-in-module +from . import ui_templateinstallprogressdlg # pylint: disable=no-name-in-module from . import utils #pylint: disable=invalid-name BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes'] +# pylint: disable=too-few-public-methods,inherit-non-class class Template(typing.NamedTuple): status: str name: str From 099bbe99aa6a9dd9c9d047125855fef166cf2a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Sat, 3 Apr 2021 02:15:22 +0200 Subject: [PATCH 09/11] qvm-template-gui: UX improvements - hide license, URL and summary - those are the same for all the templates, and not really useful for the user - remove repeated "Time" word from column headers - use full "Repository" word as the column header --- qubesmanager/qvm_template_gui.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py index 7c9f8731..70920dc7 100644 --- a/qubesmanager/qvm_template_gui.py +++ b/qubesmanager/qvm_template_gui.py @@ -26,9 +26,9 @@ class Template(typing.NamedTuple): size: int buildtime: str installtime: str - licence: str - url: str - summary: str + #licence: str + #url: str + #summary: str # ---- internal ---- description: str default_status: str @@ -38,13 +38,13 @@ class Template(typing.NamedTuple): 'Status', 'Name', 'Version', - 'Reponame', + 'Repository', 'Download Size (MB)', - 'Build Time', - 'Install Time', - 'License', - 'URL', - 'Summary' + 'Build', + 'Install', + #'License', + #'URL', + #'Summary' ] @staticmethod @@ -57,9 +57,8 @@ def build(status, entry): int(entry['size']) // 1000000, entry['buildtime'], entry['installtime'], - entry['license'], - entry['url'], - entry['summary'], + #entry['license'], + #entry['url'], entry['description'], status ) From 6c9514c0345bcfffdd2ec3dd6c638ce513ca2680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Sat, 3 Apr 2021 17:19:05 +0200 Subject: [PATCH 10/11] qvm-template-gui: change date format to '%d %b %Y' Make it always include month as a word, to less overwhelm the user with a table full of numbers. --- qubesmanager/qvm_template_gui.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py index 70920dc7..6f5e94d1 100644 --- a/qubesmanager/qvm_template_gui.py +++ b/qubesmanager/qvm_template_gui.py @@ -1,5 +1,6 @@ import asyncio import collections +from datetime import datetime import itertools import json import os @@ -17,6 +18,9 @@ BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes'] +# singleton for "no date" +ZERO_DATE = datetime.utcfromtimestamp(0) + # pylint: disable=too-few-public-methods,inherit-non-class class Template(typing.NamedTuple): status: str @@ -24,8 +28,8 @@ class Template(typing.NamedTuple): evr: str reponame: str size: int - buildtime: str - installtime: str + buildtime: datetime + installtime: typing.Optional[datetime] #licence: str #url: str #summary: str @@ -49,14 +53,20 @@ class Template(typing.NamedTuple): @staticmethod def build(status, entry): + cli_format = '%Y-%m-%d %H:%M:%S' + buildtime = datetime.strptime(entry['buildtime'], cli_format) + if entry['installtime']: + installtime = datetime.strptime(entry['installtime'], cli_format) + else: + installtime = ZERO_DATE return Template( status, entry['name'], '%s:%s-%s' % (entry['epoch'], entry['version'], entry['release']), entry['reponame'], int(entry['size']) // 1000000, - entry['buildtime'], - entry['installtime'], + buildtime, + installtime, #entry['license'], #entry['url'], entry['description'], @@ -159,15 +169,20 @@ def hasChildren(self, index=PyQt5.QtCore.QModelIndex()): def data(self, index, role=PyQt5.QtCore.Qt.DisplayRole): if index.isValid(): + data = self.children[index.row()][index.column()] if role == PyQt5.QtCore.Qt.DisplayRole: - return self.children[index.row()][index.column()] + if data is ZERO_DATE: + return '' + if isinstance(data, datetime): + return data.strftime('%d %b %Y') + return data if role == PyQt5.QtCore.Qt.FontRole: font = PyQt5.QtGui.QFont() tpl = self.children[index.row()] font.setBold(tpl.status != tpl.default_status) return font if role == PyQt5.QtCore.Qt.TextAlignmentRole: - if isinstance(self.children[index.row()][index.column()], int): + if isinstance(data, int): return PyQt5.QtCore.Qt.AlignRight return PyQt5.QtCore.Qt.AlignLeft return None From fcf5db817f395bdc2dc8a18fef434b91560ffd62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Sat, 3 Apr 2021 19:06:38 +0200 Subject: [PATCH 11/11] qvm-template-gui: auto resize columns to the content --- qubesmanager/qvm_template_gui.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py index 6f5e94d1..766a2e8b 100644 --- a/qubesmanager/qvm_template_gui.py +++ b/qubesmanager/qvm_template_gui.py @@ -368,6 +368,8 @@ def __init__(self, qt_app, qubes_app, dispatcher, parent=None): super().__init__() self.setupUi(self) + self.listing.header().setSectionResizeMode( + PyQt5.QtWidgets.QHeaderView.ResizeToContents) self.qubes_app = qubes_app self.qt_app = qt_app