diff --git a/debian/install b/debian/install index 81795e7..1ebd7bb 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/qubesmanager/qvm_template_gui.py b/qubesmanager/qvm_template_gui.py new file mode 100644 index 0000000..766a2e8 --- /dev/null +++ b/qubesmanager/qvm_template_gui.py @@ -0,0 +1,446 @@ +import asyncio +import collections +from datetime import datetime +import itertools +import json +import os +import typing + +import PyQt5 # pylint: disable=import-error +import PyQt5.QtWidgets # pylint: disable=import-error + +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'] + +# singleton for "no date" +ZERO_DATE = datetime.utcfromtimestamp(0) + +# pylint: disable=too-few-public-methods,inherit-non-class +class Template(typing.NamedTuple): + status: str + name: str + evr: str + reponame: str + size: int + buildtime: datetime + installtime: typing.Optional[datetime] + #licence: str + #url: str + #summary: str + # ---- internal ---- + description: str + default_status: str + # ------------------ + + COL_NAMES = [ + 'Status', + 'Name', + 'Version', + 'Repository', + 'Download Size (MB)', + 'Build', + 'Install', + #'License', + #'URL', + #'Summary' + ] + + @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, + buildtime, + installtime, + #entry['license'], + #entry['url'], + entry['description'], + status + ) + +class Action(typing.NamedTuple): + op: str + name: str + evr: str + + TYPES = [str, str, str] + COL_NAMES = ['Operation', 'Name', 'Version'] + +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().__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(): + data = self.children[index.row()][index.column()] + if role == PyQt5.QtCore.Qt.DisplayRole: + 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(data, 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 tpl in self.children: + if tpl.status != tpl.default_status: + actions.append(Action(tpl.status, tpl.name, tpl.evr)) + 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() + + @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() + 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'): + 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 = 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 + status_text = '' + while True: + line = await proc.stdout.read(100) + if line == b'': + break + 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) + self.progressBar.setValue(0) + return False + self.progressBar.setMaximum(100) + self.progressBar.setValue(100) + self.buttonBox.show() + return True + 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.listing.header().setSectionResizeMode( + PyQt5.QtWidgets.QHeaderView.ResizeToContents) + + 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 + 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: + 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() diff --git a/rpm_spec/qmgr.spec.in b/rpm_spec/qmgr.spec.in index 9b39f9d..f11112f 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 6226047..561623b 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ if __name__ == '__main__': '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' ], }) diff --git a/ui/qvmtemplate.ui b/ui/qvmtemplate.ui new file mode 100644 index 0000000..0901e4a --- /dev/null +++ b/ui/qvmtemplate.ui @@ -0,0 +1,114 @@ + + + QubesTemplateManager + + + + 0 + 0 + 930 + 478 + + + + Qubes Template Manager + + + + + + + 0 + + + -1 + + + + + + + Select actions to perform in "Status" column: + + + + + + + QAbstractItemView::AllEditTriggers + + + QAbstractItemView::SingleSelection + + + false + + + true + + + false + + + + + + + true + + + Use "Status" column to select actions to execute on a given template; when desired actions are selected, use "Apply" to perform them. + + + + + + + + + 0 + 0 + 930 + 24 + + + + + + + toolBar + + + Qt::ToolButtonTextUnderIcon + + + TopToolBarArea + + + false + + + + + + + + .. + + + Refresh + + + + + + .. + + + Apply + + + + + + diff --git a/ui/templateinstallconfirmdlg.ui b/ui/templateinstallconfirmdlg.ui new file mode 100644 index 0000000..84e054b --- /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 0000000..3f98082 --- /dev/null +++ b/ui/templateinstallprogressdlg.ui @@ -0,0 +1,81 @@ + + + TemplateInstallProgressDlg + + + + 0 + 0 + 840 + 260 + + + + 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 + + + + +