diff --git a/debian/control b/debian/control
index 81780915..26cbaaec 100644
--- a/debian/control
+++ b/debian/control
@@ -26,7 +26,7 @@ Description: Qubes UI Applications
Package: python3-qui
Architecture: any
Depends:
- python3-qubesadmin (>= 4.3.19),
+ python3-qubesadmin (>= 4.3.33),
python3-gi,
${python3:Depends},
${misc:Depends}
diff --git a/qui/devices/actionable_widgets.py b/qui/devices/actionable_widgets.py
index 468e0c4b..860478e9 100644
--- a/qui/devices/actionable_widgets.py
+++ b/qui/devices/actionable_widgets.py
@@ -26,6 +26,7 @@
import asyncio
import functools
import pathlib
+import time
from typing import Iterable, Callable, Optional, List
import qubesadmin
@@ -38,7 +39,6 @@
from gi.repository import Gtk, GdkPixbuf, GLib # isort:skip
from . import backend
-import time
def load_icon(icon_name: str, backup_name: str, size: int = 24):
@@ -247,7 +247,7 @@ def __init__(self, vm: backend.VM, device: backend.Device):
self.actionable = False
async def widget_action(self, *_args):
- self.device.attach_to_vm(self.vm)
+ await asyncio.to_thread(self.device.attach_to_vm, self.vm)
class DetachWidget(ActionableWidget, SimpleActionWidget):
@@ -259,7 +259,7 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
self.device = device
async def widget_action(self, *_args):
- self.device.detach_from_vm(self.vm, False)
+ await asyncio.to_thread(self.device.detach_from_vm, self.vm, False)
class DetachWithWidget(ActionableWidget, SimpleActionWidget):
@@ -278,7 +278,7 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
self.device = device
async def widget_action(self, *_args):
- self.device.detach_from_vm(self.vm, True)
+ await asyncio.to_thread(self.device.detach_from_vm, self.vm, True)
class DetachAndShutdownWidget(ActionableWidget, SimpleActionWidget):
@@ -292,8 +292,8 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
self.device = device
async def widget_action(self, *_args):
- self.device.detach_from_vm(self.vm, True)
- self.vm.vm_object.shutdown()
+ await asyncio.to_thread(self.device.detach_from_vm, self.vm, True)
+ await asyncio.to_thread(self.vm.vm_object.shutdown)
class DetachAndAttachWidget(ActionableWidget, VMWithIcon):
@@ -306,8 +306,8 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
async def widget_action(self, *_args):
for vm in self.device.attachments:
- self.device.detach_from_vm(vm, True)
- self.device.attach_to_vm(self.vm)
+ await asyncio.to_thread(self.device.detach_from_vm, vm, True)
+ await asyncio.to_thread(self.device.attach_to_vm, self.vm)
class AttachDisposableWidget(ActionableWidget, VMWithIcon):
@@ -320,9 +320,9 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
async def widget_action(self, *_args):
new_dispvm = qubesadmin.vm.DispVM.from_appvm(self.vm.vm_object.app, self.vm)
- new_dispvm.start()
+ await asyncio.to_thread(new_dispvm.start)
- self.device.attach_to_vm(backend.VM(new_dispvm))
+ await asyncio.to_thread(self.device.attach_to_vm, backend.VM(new_dispvm))
class DetachAndAttachDisposableWidget(ActionableWidget, VMWithIcon):
@@ -334,11 +334,11 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
self.device = device
async def widget_action(self, *_args):
- self.device.detach_from_vm(self.vm)
+ await asyncio.to_thread(self.device.detach_from_vm, self.vm)
new_dispvm = qubesadmin.vm.DispVM.from_appvm(self.vm.vm_object.app, self.vm)
- new_dispvm.start()
+ await asyncio.to_thread(new_dispvm.start)
- self.device.attach_to_vm(backend.VM(new_dispvm))
+ await asyncio.to_thread(self.device.attach_to_vm, backend.VM(new_dispvm))
class ToggleFeatureItem(ActionableWidget, SimpleActionWidget):
@@ -379,7 +379,7 @@ def __init__(self, usbvm: backend.VM, variant: str = "dark"):
self.usbvm = usbvm
async def widget_action(self, *_args):
- self.usbvm.vm_object.start()
+ await asyncio.to_thread(self.usbvm.vm_object.start)
#### Configuration-related actions
diff --git a/qui/tray/domains.py b/qui/tray/domains.py
index 46ba6e79..8b541c6b 100644
--- a/qui/tray/domains.py
+++ b/qui/tray/domains.py
@@ -157,7 +157,7 @@ def __init__(self, vm, icon_cache):
async def perform_action(self):
try:
- self.vm.pause()
+ await asyncio.to_thread(self.vm.pause)
except exc.QubesException as ex:
show_error(
_("Error pausing qube"),
@@ -178,7 +178,7 @@ def __init__(self, vm, icon_cache):
async def perform_action(self):
try:
- self.vm.unpause()
+ await asyncio.to_thread(self.vm.unpause)
except exc.QubesException as ex:
show_error(
_("Error unpausing qube"),
@@ -192,21 +192,32 @@ async def perform_action(self):
class ShutdownItem(VMActionMenuItem):
"""Shutdown menu Item. When activated shutdowns the domain."""
- def __init__(self, vm, icon_cache, force=False):
+ def __init__(
+ self,
+ vm,
+ icon_cache,
+ force=False,
+ follow_shift=True,
+ label=None,
+ force_label=None,
+ icon_name="shutdown",
+ ):
if force:
- super().__init__(
- vm,
- label=_("Force shutdown"),
- icon_cache=icon_cache,
- icon_name="shutdown",
- )
+ init_label = force_label or _("Force shutdown")
else:
- super().__init__(
- vm, label=_("Shutdown"), icon_cache=icon_cache, icon_name="shutdown"
- )
+ init_label = label or _("Shutdown")
+ super().__init__(
+ vm,
+ label=init_label,
+ icon_cache=icon_cache,
+ icon_name=icon_name,
+ )
self.force = force
+ self.follow_shift = follow_shift
def set_force(self, force):
+ if not self.follow_shift:
+ return
self.force = force
if self.force:
self.label.set_text(_("Force shutdown"))
@@ -215,100 +226,94 @@ def set_force(self, force):
async def perform_action(self):
try:
- self.vm.shutdown(force=self.force)
+ await asyncio.to_thread(self.vm.shutdown, force=self.force, wait=True)
except exc.QubesException as ex:
+ self.show_shutdown_dialog(ex)
+
+ def shutdown_exception_body(self, ex):
+ if isinstance(ex, exc.QubesVMInUseError):
+ title = _("Qube {0} is in use").format(self.vm.name)
+ markup = _(
+ "The qube {0} could not be shut down because it "
+ "is in use by connected qubes.\n\n"
+ "Warning: force shutdown may cause unexpected "
+ "issues in connected qubes."
+ ).format(self.vm.name)
+ action = "force"
+ elif isinstance(ex, exc.QubesVMShutdownTimeoutError):
+ title = _("Qube {0} shutdown timed out").format(self.vm.name)
+ markup = _(
+ "The qube {0} did not shut down within the "
+ "expected time.\n\nYou can retry the shutdown or, "
+ "if the problem persists, kill the qube."
+ ).format(self.vm.name)
+ action = "timeout"
+ else:
+ title = _("Error shutting down qube {0}").format(self.vm.name)
+ markup = _(
+ "The qube {0} could not be shut down. "
+ "The following error occurred:\n"
+ "{1}\n\n"
+ "Would you like to kill the qube?"
+ ).format(self.vm.name, str(ex))
+ action = "kill"
+ return title, markup, action
+
+ def show_shutdown_dialog(self, ex):
+ title, markup, action = self.shutdown_exception_body(ex)
+ dialog = Gtk.MessageDialog(None, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.NONE)
+ dialog.set_title(title)
+ dialog.set_markup(markup)
+ dialog.add_button(_("Cancel"), Gtk.ResponseType.CANCEL)
+ if action == "force":
+ dialog.add_button(_("Force shutdown"), Gtk.ResponseType.OK)
+ elif action == "timeout":
if self.force:
- show_error(
- _("Error shutting down qube"),
- _(
- "The following error occurred while attempting to "
- "shut down qube {0}:\n{1}"
- ).format(self.vm.name, str(ex)),
- )
- return
- if isinstance(ex, exc.QubesVMInUseError):
- title = _("Qube {0} is in use").format(self.vm.name)
- markup = _(
- "The qube {0} could not be shut down because it "
- "is in use by connected qubes.\n\n"
- "Warning: force shutdown may cause unexpected "
- "issues in connected qubes."
- ).format(self.vm.name)
- button_label = _("Force shutdown")
- action = "force"
- elif isinstance(ex, exc.QubesVMShutdownTimeoutError):
- title = _("Qube {0} shutdown timed out").format(self.vm.name)
- markup = _(
- "The qube {0} did not shut down within the "
- "expected time.\n\nYou can retry the shutdown or, "
- "if the problem persists, kill the qube."
- ).format(self.vm.name)
- button_label = None # timeout uses custom buttons below
- action = "timeout"
+ dialog.add_button(_("Retry force shutdown"), Gtk.ResponseType.OK)
else:
- title = _("Error shutting down qube {0}").format(self.vm.name)
- markup = _(
- "The qube {0} could not be shut down. "
- "The following error occurred:\n"
- "{1}\n\n"
- "Would you like to kill the qube?"
- ).format(self.vm.name, str(ex))
- button_label = _("Kill")
- action = "kill"
-
- dialog = Gtk.MessageDialog(
- None, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.NONE
- )
- dialog.set_title(title)
- dialog.set_markup(markup)
- dialog.add_button(_("Cancel"), Gtk.ResponseType.CANCEL)
- if action == "timeout":
dialog.add_button(_("Retry shutdown"), Gtk.ResponseType.OK)
- dialog.add_button(_("Kill"), Gtk.ResponseType.YES)
- else:
- dialog.add_button(button_label, Gtk.ResponseType.OK)
- dialog.connect("response", self.react_to_question, action)
- GLib.idle_add(dialog.show)
+ dialog.add_button(_("Kill"), Gtk.ResponseType.YES)
+ elif action == "kill":
+ dialog.add_button(_("Kill"), Gtk.ResponseType.OK)
+ dialog.connect("response", self.react_to_question, action)
+ GLib.idle_add(dialog.show)
def react_to_question(self, widget, response, action):
- if response not in (Gtk.ResponseType.OK, Gtk.ResponseType.YES):
- widget.destroy()
- return
+ asyncio.create_task(self.react_to_question_async(widget, response, action))
+
+ async def react_to_question_async(self, widget, response, action):
+ widget.destroy()
try:
- if action == "force":
- self.vm.shutdown(force=True)
- elif action == "timeout":
- if response == Gtk.ResponseType.YES:
- self.vm.kill()
- elif response == Gtk.ResponseType.OK:
- self.vm.shutdown(force=False)
- elif action == "kill" and response == Gtk.ResponseType.OK:
- self.vm.kill()
+ await self.shutdown_from_response(response, action)
except exc.QubesException as ex:
- show_error(
- _("Error shutting down qube"),
- _(
- "The following error occurred while attempting to "
- "shut down qube {0}:\n{1}"
- ).format(self.vm.name, str(ex)),
- )
- widget.destroy()
-
-
-class RestartItem(VMActionMenuItem):
+ self.show_shutdown_dialog(ex)
+
+ async def shutdown_from_response(self, response, action):
+ if action == "force":
+ self.set_force(True)
+ await asyncio.to_thread(self.vm.shutdown, force=True, wait=True)
+ elif action == "timeout":
+ if response == Gtk.ResponseType.YES:
+ await asyncio.to_thread(self.vm.kill)
+ elif response == Gtk.ResponseType.OK:
+ await asyncio.to_thread(self.vm.shutdown, force=self.force, wait=True)
+ elif action == "kill" and response == Gtk.ResponseType.OK:
+ await asyncio.to_thread(self.vm.kill)
+
+
+class RestartItem(ShutdownItem):
"""Restart menu Item. When activated shutdowns the domain and
then starts it again."""
def __init__(self, vm, icon_cache, force=False):
- if force:
- super().__init__(
- vm, label=_("Force restart"), icon_cache=icon_cache, icon_name="restart"
- )
- else:
- super().__init__(
- vm, label=_("Restart"), icon_cache=icon_cache, icon_name="restart"
- )
- self.force = force
+ super().__init__(
+ vm,
+ icon_cache,
+ force=force,
+ label=_("Restart"),
+ force_label=_("Force restart"),
+ icon_name="restart",
+ )
self.give_up = False
def set_force(self, force):
@@ -318,70 +323,42 @@ def set_force(self, force):
else:
self.label.set_text(_("Restart"))
- async def perform_action(self, *_args, **_kwargs):
- try:
- self.vm.shutdown(force=self.force)
- except exc.QubesException as ex:
- if self.force:
- # we already tried forcing it, let's just give up
- show_error(
- _("Error restarting qube"),
- _(
- "The following error occurred while attempting to restart"
- "qube {0}:\n{1}"
- ).format(self.vm.name, str(ex)),
- )
- return
- dialog = Gtk.MessageDialog(
- None, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK_CANCEL
- )
- dialog.set_title("Error restarting qube")
- dialog.set_markup(
- f"The qube {self.vm.name} couldn't be shut down "
- "normally. The following error occurred: \n"
- f"{str(ex)}\n\n"
- "Do you want to force shutdown? \n\nWarning: "
- "this may cause unexpected issues in connected qubes."
- )
- dialog.connect("response", self.react_to_question)
- GLib.idle_add(dialog.show)
-
+ async def start(self):
+ if self.give_up:
+ return
try:
- while self.vm.is_running():
- if self.give_up:
- return
- await asyncio.sleep(1)
- proc = await asyncio.create_subprocess_exec(
- "qvm-start", self.vm.name, stderr=asyncio.subprocess.PIPE
- )
- _stdout, stderr = await proc.communicate()
- if proc.returncode != 0:
- raise exc.QubesException(stderr)
+ await asyncio.to_thread(self.vm.start)
except exc.QubesException as ex:
show_error(
_("Error restarting qube"),
_(
- "The following error occurred while attempting to restart"
- "qube {0}:\n{1}"
+ "The following error occurred while attempting to start"
+ " qube {0}:\n{1}"
).format(self.vm.name, str(ex)),
)
- def react_to_question(self, widget, response):
- if response == Gtk.ResponseType.OK:
- try:
- self.vm.shutdown(force=True)
- except exc.QubesException as ex:
- show_error(
- _("Error shutting down qube"),
- _(
- "The following error occurred while attempting to "
- "shut down qube {0}:\n{1}"
- ).format(self.vm.name, str(ex)),
- )
- self.give_up = True
+ async def perform_action(self, *_args, **_kwargs):
+ try:
+ await asyncio.to_thread(self.vm.shutdown, force=self.force, wait=True)
+ except exc.QubesException as ex:
+ self.show_shutdown_dialog(ex)
else:
- self.give_up = True
+ await self.start()
+
+ def react_to_question(self, widget, response, action):
+ asyncio.create_task(self.react_to_question_async(widget, response, action))
+
+ async def react_to_question_async(self, widget, response, action):
widget.destroy()
+ if response not in (Gtk.ResponseType.OK, Gtk.ResponseType.YES):
+ self.give_up = True
+ return
+ try:
+ await self.shutdown_from_response(response, action)
+ except exc.QubesException as ex:
+ self.show_shutdown_dialog(ex)
+ else:
+ await self.start()
class KillItem(VMActionMenuItem):
@@ -392,13 +369,13 @@ def __init__(self, vm, icon_cache):
async def perform_action(self, *_args, **_kwargs):
try:
- self.vm.kill()
+ await asyncio.to_thread(self.vm.kill)
except exc.QubesException as ex:
show_error(
_("Error shutting down qube"),
_(
- "The following error occurred while attempting to shut"
- "down qube {0}:\n{1}"
+ "The following error occurred while attempting to kill"
+ "qube {0}:\n{1}"
).format(self.vm.name, str(ex)),
)
@@ -459,7 +436,11 @@ async def perform_action(self):
if self.as_root:
service_args["user"] = "root"
try:
- self.vm.run_service("qubes.StartApp+qubes-run-terminal", **service_args)
+ await asyncio.to_thread(
+ self.vm.run_service_for_stdio,
+ "qubes.StartApp+qubes-run-terminal",
+ **service_args,
+ )
except exc.QubesException as ex:
show_error(
_("Error starting terminal"),
@@ -508,7 +489,9 @@ def __init__(self, vm, icon_cache):
async def perform_action(self):
try:
- self.vm.run_service("qubes.StartApp+qubes-open-file-manager")
+ await asyncio.to_thread(
+ self.vm.run_service_for_stdio, "qubes.StartApp+qubes-open-file-manager"
+ )
except exc.QubesException as ex:
show_error(
_("Error opening file manager"),
@@ -547,7 +530,7 @@ def __init__(self, is_preload=False):
class StartedMenu(Gtk.Menu):
"""The sub-menu for a started domain"""
- def __init__(self, vm, app, icon_cache):
+ def __init__(self, vm, app, icon_cache, shutdown_failed=False):
super().__init__()
self.vm = vm
self.app = app
@@ -561,7 +544,17 @@ def __init__(self, vm, app, icon_cache):
self.add(PreferencesItem(self.vm, icon_cache))
self.add(PauseItem(self.vm, icon_cache))
- self.add(ShutdownItem(self.vm, icon_cache, force=app.shift_pressed))
+ self.add(
+ ShutdownItem(
+ self.vm,
+ icon_cache,
+ force=app.shift_pressed and not shutdown_failed,
+ follow_shift=not shutdown_failed,
+ )
+ )
+ if shutdown_failed:
+ self.add(ShutdownItem(self.vm, icon_cache, force=True, follow_shift=False))
+ self.add(KillItem(self.vm, icon_cache))
if self.vm.klass != "DispVM" or not self.vm.auto_cleanup:
self.add(RestartItem(self.vm, icon_cache, force=app.shift_pressed))
@@ -709,6 +702,7 @@ def __init__(self, vm, app, icon_cache, state=None):
self.vm = vm
self.app = app
self.icon_cache = icon_cache
+ self.shutdown_failed = False
self.decorator = qui.decorators.DomainDecorator(vm)
# Main horizontal box
@@ -766,7 +760,12 @@ def _set_submenu(self, state):
is_preload=is_preload,
)
elif state == "Running":
- submenu = StartedMenu(self.vm, self.app, self.icon_cache)
+ submenu = StartedMenu(
+ self.vm,
+ self.app,
+ self.icon_cache,
+ shutdown_failed=self.shutdown_failed,
+ )
elif state == "Paused":
submenu = PausedMenu(self.vm, self.icon_cache)
else:
@@ -1182,6 +1181,11 @@ def update_domain_item(self, vm, event, **kwargs):
# it's a fragile DispVM
state = "Transient"
+ if event == "domain-shutdown-failed":
+ item.shutdown_failed = True
+ elif event in ("domain-start", "domain-pre-start", "domain-shutdown"):
+ item.shutdown_failed = False
+
item.update_state(state)
if event == "property-reset:is_preload":
diff --git a/qui/updater/summary_page.py b/qui/updater/summary_page.py
index bf14d4b7..88c3e479 100644
--- a/qui/updater/summary_page.py
+++ b/qui/updater/summary_page.py
@@ -31,7 +31,7 @@
from typing import Optional, Any
import qubesadmin
-from qubesadmin.events.utils import wait_for_domain_shutdown
+import qubesadmin.utils
from qubes_config.widgets.gtk_utils import (
load_icon,
@@ -297,52 +297,51 @@ def perform_restart(self):
# clear err and perform shutdown/start
self.err = ""
- self.shutdown_domains(tmpls_to_shutdown)
- self.restart_vms(to_restart)
- self.shutdown_domains(to_shutdown)
-
- if self.status is RestartStatus.NONE:
- self.status = RestartStatus.OK
-
- def shutdown_domains(self, to_shutdown):
- """
- Try to shut down vms and wait to finish.
- """
- wait_for = []
- for vm in to_shutdown:
- try:
- vm.shutdown(force=True)
- wait_for.append(vm)
- self.log.info("Shutdown %s", vm.name)
- except qubesadmin.exc.QubesVMError as err:
- self.err += vm.name + " cannot shutdown: " + str(err) + "\n"
- self.log.error("Cannot shutdown %s because %s", vm.name, str(err))
- self.status = RestartStatus.ERROR_TMPL_DOWN
try:
loop = asyncio.get_event_loop()
except RuntimeError:
# changes between GLib versions and python versions mean that the above
# can fail on some dom0/gui domain configurations
loop = asyncio.new_event_loop()
- loop.run_until_complete(wait_for_domain_shutdown(wait_for))
+ loop.run_until_complete(self.shutdown_domains(tmpls_to_shutdown))
+ loop.run_until_complete(self.restart_vms(to_restart))
+ loop.run_until_complete(self.shutdown_domains(to_shutdown))
- return wait_for
+ if self.status is RestartStatus.NONE:
+ self.status = RestartStatus.OK
- def restart_vms(self, to_restart):
+ async def shutdown_domains(self, to_shutdown):
+ """
+ Try to shut down vms and wait to finish.
+ """
+ failed = await qubesadmin.utils.shutdown(
+ domains=to_shutdown, force=True, wait=True
+ )
+ if not failed:
+ return to_shutdown
+ self.status = RestartStatus.ERROR_TMPL_DOWN
+ all_failed = []
+ for qube, exc in failed.items():
+ all_failed.append(qube)
+ self.err += qube.name + " cannot shutdown: unknown reason\n"
+ self.log.error("Cannot shutdown %s: %s", qube.name, str(exc))
+ done = [qube for qube in to_shutdown if qube not in all_failed]
+ return done
+
+ async def restart_vms(self, to_restart):
"""
Try to restart vms.
"""
- shutdowns = self.shutdown_domains(to_restart)
+ shutdowns = await self.shutdown_domains(to_restart)
# restart shutdown qubes
- for vm in shutdowns:
- try:
- vm.start()
- self.log.info("Restart %s", vm.name)
- except qubesadmin.exc.QubesVMError as err:
- self.err += vm.name + " cannot start: " + str(err) + "\n"
- self.log.error("Cannot start %s because %s", vm.name, str(err))
- self.status = RestartStatus.ERROR_APP_DOWN
+ failed = await qubesadmin.utils.start(domains=shutdowns)
+ if not failed:
+ return
+ for qube, exc in failed.items():
+ self.err += qube.name + " cannot start: " + str(exc) + "\n"
+ self.log.error("Cannot start %s: %s", qube.name, str(exc))
+ self.status = RestartStatus.ERROR_APP_DOWN
def _show_status_dialog(self, show_only_error: bool):
if self.status == RestartStatus.OK and not show_only_error:
diff --git a/qui/updater/tests/test_summary_page.py b/qui/updater/tests/test_summary_page.py
index 6e19fafc..7c60130e 100644
--- a/qui/updater/tests/test_summary_page.py
+++ b/qui/updater/tests/test_summary_page.py
@@ -370,9 +370,7 @@ def set_deletable(self, deletable):
mock_show_dialog.assert_has_calls(calls)
-@patch("qui.updater.summary_page.wait_for_domain_shutdown")
def test_perform_restart(
- _mock_wait_for_domain_shutdown,
test_qapp,
real_builder,
mock_next_button,
@@ -405,7 +403,7 @@ def test_perform_restart(
"vault",
)
expected_shutdown_calls = [
- (tmpl, "admin.vm.Shutdown", "force", None) for tmpl in to_shutdown
+ (tmpl, "admin.vm.Shutdown", "force+wait", None) for tmpl in to_shutdown
]
for call_ in expected_shutdown_calls:
test_qapp.expected_calls[call_] = b"0\x00"
diff --git a/rpm_spec/qubes-desktop-linux-manager.spec.in b/rpm_spec/qubes-desktop-linux-manager.spec.in
index e9332033..c14002fa 100644
--- a/rpm_spec/qubes-desktop-linux-manager.spec.in
+++ b/rpm_spec/qubes-desktop-linux-manager.spec.in
@@ -57,7 +57,7 @@ Requires: python%{python3_pkgversion}-inotify
Requires: libappindicator-gtk3
Requires: python%{python3_pkgversion}-systemd
Requires: gtk3
-Requires: python%{python3_pkgversion}-qubesadmin >= 4.3.19
+Requires: python%{python3_pkgversion}-qubesadmin >= 4.3.33
# FIXME: we need some way for applying updates from GUI VM
#Requires: qubes-mgmt-salt-dom0-update >= 4.0.5
Requires: qubes-artwork >= 4.1.5