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