qvm-template: Port initial code to PyQt.
This commit is contained in:
parent
79b6d8f72c
commit
7e8ee7e8cc
@ -1,19 +1,19 @@
|
|||||||
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
import concurrent
|
|
||||||
import concurrent.futures
|
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import gi
|
import PyQt5
|
||||||
gi.require_version('Gtk', '3.0')
|
import PyQt5.QtWidgets
|
||||||
|
|
||||||
#pylint: disable=wrong-import-position
|
from . import ui_qvmtemplate
|
||||||
from gi.repository import GLib
|
from . import ui_templateinstallconfirmdlg
|
||||||
from gi.repository import Gtk
|
from . import ui_templateinstallprogressdlg
|
||||||
from gi.repository import Pango
|
from . import utils
|
||||||
|
|
||||||
|
#pylint: disable=invalid-name
|
||||||
|
|
||||||
BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes', '--quiet']
|
BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes', '--quiet']
|
||||||
|
|
||||||
@ -28,16 +28,10 @@ class Template(typing.NamedTuple):
|
|||||||
licence: str
|
licence: str
|
||||||
url: str
|
url: str
|
||||||
summary: str
|
summary: str
|
||||||
# --- internal ---
|
# ---- internal ----
|
||||||
description: str
|
description: str
|
||||||
default_status: 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 = [
|
COL_NAMES = [
|
||||||
'Status',
|
'Status',
|
||||||
@ -49,27 +43,24 @@ class Template(typing.NamedTuple):
|
|||||||
'Install Time',
|
'Install Time',
|
||||||
'License',
|
'License',
|
||||||
'URL',
|
'URL',
|
||||||
'Summary']
|
'Summary'
|
||||||
|
]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build(status, entry, model):
|
def build(status, entry):
|
||||||
return Template(
|
return Template(
|
||||||
status,
|
status,
|
||||||
entry['name'],
|
entry['name'],
|
||||||
'%s:%s-%s' % (entry['epoch'], entry['version'], entry['release']),
|
'%s:%s-%s' % (entry['epoch'], entry['version'], entry['release']),
|
||||||
entry['reponame'],
|
entry['reponame'],
|
||||||
# XXX: This may overflow glib ints, though pretty unlikely in the
|
int(entry['size']) // 1000,
|
||||||
# foreseeable future
|
|
||||||
int(entry['size']) / 1000,
|
|
||||||
entry['buildtime'],
|
entry['buildtime'],
|
||||||
entry['installtime'],
|
entry['installtime'],
|
||||||
entry['license'],
|
entry['license'],
|
||||||
entry['url'],
|
entry['url'],
|
||||||
entry['summary'],
|
entry['summary'],
|
||||||
entry['description'],
|
entry['description'],
|
||||||
status,
|
status
|
||||||
Pango.Weight.BOOK,
|
|
||||||
model
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Action(typing.NamedTuple):
|
class Action(typing.NamedTuple):
|
||||||
@ -80,253 +71,230 @@ class Action(typing.NamedTuple):
|
|||||||
TYPES = [str, str, str]
|
TYPES = [str, str, str]
|
||||||
COL_NAMES = ['Operation', 'Name', 'Version']
|
COL_NAMES = ['Operation', 'Name', 'Version']
|
||||||
|
|
||||||
# TODO: Set default window sizes
|
class TemplateStatusDelegate(PyQt5.QtWidgets.QStyledItemDelegate):
|
||||||
|
OPS = [
|
||||||
|
['Installed', 'Reinstall', 'Remove'],
|
||||||
|
['Extra', 'Remove'],
|
||||||
|
['Upgradable', 'Upgrade', 'Remove'],
|
||||||
|
['Downgradable', 'Downgrade', 'Remove'],
|
||||||
|
['Available', 'Install']
|
||||||
|
]
|
||||||
|
|
||||||
class ConfirmDialog(Gtk.Dialog):
|
def createEditor(self, parent, option, index):
|
||||||
def __init__(self, parent, actions):
|
_ = option # unused
|
||||||
super(ConfirmDialog, self).__init__(
|
editor = PyQt5.QtWidgets.QComboBox(parent)
|
||||||
title='Confirmation', transient_for=parent, modal=True)
|
# Otherwise the internalPointer can be overwritten with a QComboBox
|
||||||
self.add_buttons(
|
index = index.model().index(index.row(), index.column())
|
||||||
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
kind = index.internalPointer().default_status
|
||||||
Gtk.STOCK_OK, Gtk.ResponseType.OK)
|
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
|
||||||
|
|
||||||
box = self.get_content_area()
|
def setEditorData(self, editor, index):
|
||||||
self.msg = Gtk.Label()
|
#pylint: disable=no-self-use
|
||||||
self.msg.set_markup((
|
cur = index.data()
|
||||||
'<b>WARNING: Local changes made to the following'
|
idx = editor.findText(cur)
|
||||||
' templates will be overwritten! Continue?</b>'))
|
if idx >= 0:
|
||||||
box.add(self.msg)
|
editor.setCurrentIndex(idx)
|
||||||
|
|
||||||
self.store = Gtk.ListStore(*Action.TYPES)
|
def setModelData(self, editor, model, index):
|
||||||
self.listing = Gtk.TreeView(model=self.store)
|
#pylint: disable=no-self-use
|
||||||
for idx, colname in enumerate(Action.COL_NAMES):
|
model.setData(index, editor.currentText())
|
||||||
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:
|
def updateEditorGeometry(self, editor, option, index):
|
||||||
self.store.append(row)
|
#pylint: disable=no-self-use
|
||||||
|
_ = index # unused
|
||||||
|
editor.setGeometry(option.rect)
|
||||||
|
|
||||||
self.scrollable_listing = Gtk.ScrolledWindow()
|
@PyQt5.QtCore.pyqtSlot()
|
||||||
self.scrollable_listing.add(self.listing)
|
def currentIndexChanged(self):
|
||||||
box.pack_start(self.scrollable_listing, True, True, 16)
|
self.commitData.emit(self.sender())
|
||||||
|
|
||||||
self.show_all()
|
class TemplateModel(PyQt5.QtCore.QAbstractItemModel):
|
||||||
|
|
||||||
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):
|
def __init__(self):
|
||||||
super(QubesTemplateApp, self).__init__(title='Qubes Template Manager')
|
super().__init__()
|
||||||
|
|
||||||
self.iconsize = Gtk.IconSize.SMALL_TOOLBAR
|
self.children = []
|
||||||
|
|
||||||
self.executor = concurrent.futures.ThreadPoolExecutor()
|
def flags(self, index):
|
||||||
self.outerbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
if index.isValid() and index.column() == 0:
|
||||||
self.__build_action_models()
|
return super().flags(index) | PyQt5.QtCore.Qt.ItemIsEditable
|
||||||
self.__build_toolbar()
|
return super().flags(index)
|
||||||
self.__build_listing()
|
|
||||||
self.__build_infobox()
|
|
||||||
|
|
||||||
self.add(self.outerbox)
|
def sort(self, idx, order):
|
||||||
|
rev = (order == PyQt5.QtCore.Qt.AscendingOrder)
|
||||||
|
self.children.sort(key=lambda x: x[idx], reverse=rev)
|
||||||
|
|
||||||
def __build_action_models(self):
|
self.dataChanged.emit(*self.row_index(0, self.rowCount() - 1))
|
||||||
#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):
|
def index(self, row, column, parent=PyQt5.QtCore.QModelIndex()):
|
||||||
self.toolbar = Gtk.Toolbar()
|
if not self.hasIndex(row, column, parent):
|
||||||
self.btn_refresh = Gtk.ToolButton(
|
return PyQt5.QtCore.QModelIndex()
|
||||||
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(
|
return self.createIndex(row, column, self.children[row])
|
||||||
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 parent(self, child):
|
||||||
|
#pylint: disable=no-self-use
|
||||||
|
_ = child # unused
|
||||||
|
return PyQt5.QtCore.QModelIndex()
|
||||||
|
|
||||||
def __build_listing(self):
|
def rowCount(self, parent=PyQt5.QtCore.QModelIndex()):
|
||||||
self.store = Gtk.ListStore(*Template.TYPES)
|
#pylint: disable=no-self-use
|
||||||
|
_ = parent # unused
|
||||||
|
return len(self.children)
|
||||||
|
|
||||||
self.listing = Gtk.TreeView(model=self.store)
|
def columnCount(self, parent=PyQt5.QtCore.QModelIndex()):
|
||||||
self.cols = []
|
#pylint: disable=no-self-use
|
||||||
for idx, colname in enumerate(Template.COL_NAMES):
|
_ = parent # unused
|
||||||
if colname == 'Status':
|
return len(Template.COL_NAMES)
|
||||||
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()
|
def hasChildren(self, index=PyQt5.QtCore.QModelIndex()):
|
||||||
self.scrollable_listing.add(self.listing)
|
#pylint: disable=no-self-use
|
||||||
self.scrollable_listing.set_visible(False)
|
return index == PyQt5.QtCore.QModelIndex()
|
||||||
|
|
||||||
self.spinner = Gtk.Spinner()
|
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
|
||||||
|
|
||||||
self.outerbox.pack_start(self.scrollable_listing, True, True, 0)
|
def setData(self, index, value, role=PyQt5.QtCore.Qt.EditRole):
|
||||||
self.outerbox.pack_start(self.spinner, True, True, 0)
|
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 __build_infobox(self):
|
def headerData(self, section, orientation,
|
||||||
self.infobox = Gtk.TextView()
|
role=PyQt5.QtCore.Qt.DisplayRole):
|
||||||
self.outerbox.pack_start(self.infobox, True, True, 16)
|
#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 refresh(self, button=None):
|
def removeRows(self, row, count, parent=PyQt5.QtCore.QModelIndex()):
|
||||||
# Ignore if we're already doing a refresh
|
_ = parent # unused
|
||||||
#pylint: disable=no-member
|
self.beginRemoveRows(PyQt5.QtCore.QModelIndex(), row, row + count)
|
||||||
if self.spinner.props.active:
|
del self.children[row:row+count]
|
||||||
return
|
self.endRemoveRows()
|
||||||
self.scrollable_listing.set_visible(False)
|
self.dataChanged.emit(*self.row_index(row, row + count))
|
||||||
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 row_index(self, low, high):
|
||||||
def callback():
|
return self.createIndex(low, 0), \
|
||||||
if future.exception() is not None:
|
self.createIndex(high, self.columnCount())
|
||||||
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)
|
def set_templates(self, templates):
|
||||||
future.add_done_callback(finish_cb)
|
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 show_confirm(self, button=None):
|
def get_actions(self):
|
||||||
_ = button # unused
|
|
||||||
actions = []
|
actions = []
|
||||||
for row in self.store:
|
for tpl in self.children:
|
||||||
tpl = Template(*row)
|
|
||||||
if tpl.status != tpl.default_status:
|
if tpl.status != tpl.default_status:
|
||||||
actions.append(Action(tpl.status, tpl.name, tpl.evr))
|
actions.append(Action(tpl.status, tpl.name, tpl.evr))
|
||||||
dialog = ConfirmDialog(self, actions)
|
return actions
|
||||||
resp = dialog.run()
|
|
||||||
dialog.destroy()
|
|
||||||
if resp == Gtk.ResponseType.OK:
|
|
||||||
self.do_install(actions)
|
|
||||||
|
|
||||||
def do_install(self, actions):
|
async def refresh(self, refresh=True):
|
||||||
dialog = ProgressDialog(self)
|
cmd = BASE_CMD[:]
|
||||||
def worker():
|
if refresh:
|
||||||
actions.sort()
|
# Force refresh if triggered by button press
|
||||||
for oper, grp in itertools.groupby(actions, lambda x: x[0]):
|
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()
|
oper = oper.lower()
|
||||||
# No need to specify versions for local operations
|
# No need to specify versions for local operations
|
||||||
if oper in ('remove', 'purge'):
|
if oper in ('remove', 'purge'):
|
||||||
@ -339,67 +307,108 @@ class QubesTemplateApp(Gtk.Window):
|
|||||||
# the messages can be displayed in time.
|
# the messages can be displayed in time.
|
||||||
envs = os.environ.copy()
|
envs = os.environ.copy()
|
||||||
envs['PYTHONUNBUFFERED'] = '1'
|
envs['PYTHONUNBUFFERED'] = '1'
|
||||||
proc = subprocess.Popen(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
BASE_CMD + [oper, '--'] + specs,
|
*(BASE_CMD + [oper, '--'] + specs),
|
||||||
stdout=subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
env=envs)
|
env=envs)
|
||||||
#pylint: disable=cell-var-from-loop
|
#pylint: disable=cell-var-from-loop
|
||||||
for line in iter(proc.stdout.readline, ''):
|
while True:
|
||||||
# Need to modify the buffers in the main thread
|
line = await proc.stdout.readline()
|
||||||
def callback():
|
if line == b'':
|
||||||
buf = dialog.infobox.get_buffer()
|
break
|
||||||
end_iter = buf.get_end_iter()
|
line = line.decode('ASCII')
|
||||||
buf.insert(end_iter, line)
|
self.textEdit.append(line.rstrip())
|
||||||
GLib.idle_add(callback)
|
if await proc.wait() != 0:
|
||||||
if proc.wait() != 0:
|
self.buttonBox.show()
|
||||||
|
self.progressBar.setMaximum(100)
|
||||||
|
self.progressBar.setValue(0)
|
||||||
return False
|
return False
|
||||||
|
self.progressBar.setMaximum(100)
|
||||||
|
self.progressBar.setValue(100)
|
||||||
|
self.buttonBox.show()
|
||||||
return True
|
return True
|
||||||
|
asyncio.create_task(coro())
|
||||||
|
|
||||||
def finish_cb(future):
|
class QvmTemplateWindow(
|
||||||
def callback():
|
ui_qvmtemplate.Ui_QubesTemplateManager,
|
||||||
dialog.finish(future.result())
|
PyQt5.QtWidgets.QMainWindow):
|
||||||
dialog.destroy()
|
def __init__(self, qt_app, qubes_app, dispatcher, parent=None):
|
||||||
self.refresh()
|
_ = parent # unused
|
||||||
GLib.idle_add(callback)
|
|
||||||
|
|
||||||
future = self.executor.submit(worker)
|
super().__init__()
|
||||||
future.add_done_callback(finish_cb)
|
self.setupUi(self)
|
||||||
|
|
||||||
def update_info(self, sel):
|
self.qubes_app = qubes_app
|
||||||
model, treeiters = sel.get_selected_rows()
|
self.qt_app = qt_app
|
||||||
if not treeiters:
|
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
|
return
|
||||||
buf = self.infobox.get_buffer()
|
self.infobox.clear()
|
||||||
if len(treeiters) > 1:
|
cursor = PyQt5.QtGui.QTextCursor(self.infobox.document())
|
||||||
def row_to_spec(row):
|
bold_fmt = PyQt5.QtGui.QTextCharFormat()
|
||||||
tpl = Template(*row)
|
bold_fmt.setFontWeight(PyQt5.QtGui.QFont.Bold)
|
||||||
return tpl.name + '-' + tpl.evr
|
norm_fmt = PyQt5.QtGui.QTextCharFormat()
|
||||||
text = '\n'.join(row_to_spec(model[it]) for it in treeiters)
|
if len(indices) > 1:
|
||||||
buf.set_text('Selected templates:\n' + text)
|
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:
|
else:
|
||||||
itr = treeiters[0]
|
idx = indices[0]
|
||||||
tpl = Template(*model[itr])
|
tpl = self.listing_model.children[idx.row()]
|
||||||
text = 'Name: %s\n\nDescription:\n%s' % (tpl.name, tpl.description)
|
cursor.insertText('Name: ', bold_fmt)
|
||||||
buf.set_text(text)
|
cursor.insertText(tpl.name + '\n', norm_fmt)
|
||||||
|
cursor.insertText('Description:\n', bold_fmt)
|
||||||
|
cursor.insertText(tpl.description + '\n', norm_fmt)
|
||||||
|
|
||||||
def entry_edit(self, widget, path, text):
|
def refresh(self, refresh=True):
|
||||||
_ = widget # unused
|
self.progressBar.show()
|
||||||
#pylint: disable=unsubscriptable-object
|
async def coro():
|
||||||
tpl = Template(*self.store[path])
|
ok, stderr = await self.listing_model.refresh(refresh)
|
||||||
tpl = tpl._replace(status=text)
|
self.infobox.clear()
|
||||||
if text == tpl.default_status:
|
if not ok:
|
||||||
tpl = tpl._replace(weight=Pango.Weight.BOOK)
|
cursor = PyQt5.QtGui.QTextCursor(self.infobox.document())
|
||||||
else:
|
fmt = PyQt5.QtGui.QTextCharFormat()
|
||||||
tpl = tpl._replace(weight=Pango.Weight.BOLD)
|
fmt.setFontWeight(PyQt5.QtGui.QFont.Bold)
|
||||||
#pylint: disable=unsupported-assignment-operation
|
cursor.insertText('Failed to fetch template list:\n', fmt)
|
||||||
self.store[path] = tpl
|
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__':
|
if __name__ == '__main__':
|
||||||
main = QubesTemplateApp()
|
main()
|
||||||
main.connect('destroy', Gtk.main_quit)
|
|
||||||
main.show_all()
|
|
||||||
main.refresh()
|
|
||||||
Gtk.main()
|
|
||||||
|
104
ui/qvmtemplate.ui
Normal file
104
ui/qvmtemplate.ui
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>QubesTemplateManager</class>
|
||||||
|
<widget class="QMainWindow" name="QubesTemplateManager">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>465</width>
|
||||||
|
<height>478</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Qubes Template Manager</string>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="centralwidget">
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QProgressBar" name="progressBar">
|
||||||
|
<property name="maximum">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="value">
|
||||||
|
<number>-1</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QTreeView" name="listing">
|
||||||
|
<property name="editTriggers">
|
||||||
|
<set>QAbstractItemView::AllEditTriggers</set>
|
||||||
|
</property>
|
||||||
|
<property name="selectionMode">
|
||||||
|
<enum>QAbstractItemView::MultiSelection</enum>
|
||||||
|
</property>
|
||||||
|
<property name="itemsExpandable">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="sortingEnabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="expandsOnDoubleClick">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QTextEdit" name="infobox">
|
||||||
|
<property name="readOnly">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="placeholderText">
|
||||||
|
<string>Click the 'Status' column to change the status of templates.</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<widget class="QMenuBar" name="menubar">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>465</width>
|
||||||
|
<height>24</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
<widget class="QStatusBar" name="statusbar"/>
|
||||||
|
<widget class="QToolBar" name="toolBar">
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>toolBar</string>
|
||||||
|
</property>
|
||||||
|
<attribute name="toolBarArea">
|
||||||
|
<enum>TopToolBarArea</enum>
|
||||||
|
</attribute>
|
||||||
|
<attribute name="toolBarBreak">
|
||||||
|
<bool>false</bool>
|
||||||
|
</attribute>
|
||||||
|
<addaction name="actionRefresh"/>
|
||||||
|
<addaction name="actionInstall"/>
|
||||||
|
</widget>
|
||||||
|
<action name="actionRefresh">
|
||||||
|
<property name="icon">
|
||||||
|
<iconset theme="view-refresh">
|
||||||
|
<normaloff>.</normaloff>.</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Refresh</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionInstall">
|
||||||
|
<property name="icon">
|
||||||
|
<iconset theme="go-down">
|
||||||
|
<normaloff>.</normaloff>.</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Install</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
84
ui/templateinstallconfirmdlg.ui
Normal file
84
ui/templateinstallconfirmdlg.ui
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>TemplateInstallConfirmDlg</class>
|
||||||
|
<widget class="QDialog" name="TemplateInstallConfirmDlg">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>400</width>
|
||||||
|
<height>290</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Template Install Confirmation</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label">
|
||||||
|
<property name="text">
|
||||||
|
<string><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></string>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QTreeView" name="treeView">
|
||||||
|
<property name="sortingEnabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="expandsOnDoubleClick">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QDialogButtonBox" name="buttonBox">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="standardButtons">
|
||||||
|
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>TemplateInstallConfirmDlg</receiver>
|
||||||
|
<slot>accept()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>248</x>
|
||||||
|
<y>254</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>157</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>rejected()</signal>
|
||||||
|
<receiver>TemplateInstallConfirmDlg</receiver>
|
||||||
|
<slot>reject()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>316</x>
|
||||||
|
<y>260</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>286</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
</connections>
|
||||||
|
</ui>
|
81
ui/templateinstallprogressdlg.ui
Normal file
81
ui/templateinstallprogressdlg.ui
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>TemplateInstallProgressDlg</class>
|
||||||
|
<widget class="QDialog" name="TemplateInstallProgressDlg">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>400</width>
|
||||||
|
<height>300</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Installing Templates...</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QProgressBar" name="progressBar">
|
||||||
|
<property name="maximum">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="value">
|
||||||
|
<number>-1</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QTextEdit" name="textEdit">
|
||||||
|
<property name="readOnly">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QDialogButtonBox" name="buttonBox">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="standardButtons">
|
||||||
|
<set>QDialogButtonBox::Ok</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>TemplateInstallProgressDlg</receiver>
|
||||||
|
<slot>accept()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>248</x>
|
||||||
|
<y>254</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>157</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>rejected()</signal>
|
||||||
|
<receiver>TemplateInstallProgressDlg</receiver>
|
||||||
|
<slot>reject()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>316</x>
|
||||||
|
<y>260</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>286</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
</connections>
|
||||||
|
</ui>
|
Loading…
Reference in New Issue
Block a user