From 9fee2cf8395e264c7409b5420ae6e31a92bb511c Mon Sep 17 00:00:00 2001 From: Sahil_Kumar Date: Tue, 10 Mar 2026 12:37:15 +0000 Subject: [PATCH] devices widget: open file manager on dispvm block attach When a block device is attached to a new disposable via the devices widget, automatically open the file manager inside the dispvm using the qubes.StartApp+qubes-open-file-manager qrexec service. Only triggers for block device class. Uses new_dispvm.log for logging, consistent with existing patterns in the codebase. Fixes QubesOS/qubes-issues#10709 --- qui/devices/actionable_widgets.py | 31 ++++ qui/devices/backend.py | 1 + qui/devices/tests/__init__.py | 0 qui/devices/tests/test_actionable_widgets.py | 158 +++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 qui/devices/tests/__init__.py create mode 100644 qui/devices/tests/test_actionable_widgets.py diff --git a/qui/devices/actionable_widgets.py b/qui/devices/actionable_widgets.py index 468e0c4b..cacea8d8 100644 --- a/qui/devices/actionable_widgets.py +++ b/qui/devices/actionable_widgets.py @@ -31,6 +31,7 @@ import qubesadmin import qubesadmin.devices import qubesadmin.vm +from qubesadmin import exc import gi @@ -324,6 +325,21 @@ async def widget_action(self, *_args): self.device.attach_to_vm(backend.VM(new_dispvm)) + if self.device.device_class == "block" and self.vm.vm_object.features.get( + backend.FEATURE_OPEN_FILE_MANAGER, "1" + ): + try: + new_dispvm.run_service( + "qubes.StartApp+qubes-open-file-manager", + wait=False, + ) + except exc.QubesException as ex: + new_dispvm.log.exception( + "Failed to open file manager in %s: %s", + new_dispvm.name, + ex, + ) + class DetachAndAttachDisposableWidget(ActionableWidget, VMWithIcon): """Detach from all current attachments and attach to new disposable""" @@ -340,6 +356,21 @@ async def widget_action(self, *_args): self.device.attach_to_vm(backend.VM(new_dispvm)) + if self.device.device_class == "block" and self.vm.vm_object.features.get( + backend.FEATURE_OPEN_FILE_MANAGER, "1" + ): + try: + new_dispvm.run_service( + "qubes.StartApp+qubes-open-file-manager", + wait=False, + ) + except exc.QubesException as ex: + new_dispvm.log.exception( + "Failed to open file manager in %s: %s", + new_dispvm.name, + ex, + ) + class ToggleFeatureItem(ActionableWidget, SimpleActionWidget): def __init__( diff --git a/qui/devices/backend.py b/qui/devices/backend.py index 0f5074e1..d425c26f 100644 --- a/qui/devices/backend.py +++ b/qui/devices/backend.py @@ -39,6 +39,7 @@ FEATURE_HIDE_CHILDREN = "device-hide-children" FEATURE_ATTACH_WITH_MIC = "device-attach-with-mic" FEATURE_RESOLUTION = "device-qvc-resolution" # dev_id=resolution, space delimited +FEATURE_OPEN_FILE_MANAGER = "device-open-file-manager-on-attach" class VM: diff --git a/qui/devices/tests/__init__.py b/qui/devices/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qui/devices/tests/test_actionable_widgets.py b/qui/devices/tests/test_actionable_widgets.py new file mode 100644 index 00000000..20816977 --- /dev/null +++ b/qui/devices/tests/test_actionable_widgets.py @@ -0,0 +1,158 @@ +# -*- encoding: utf8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2026 Sahil Kumar +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# pylint: disable=redefined-outer-name +import asyncio +import pytest +from unittest.mock import patch, Mock +from qubesadmin import exc +from qubesadmin.tests.mock_app import MockQubesComplete +from qui.devices.actionable_widgets import ( + AttachDisposableWidget, + DetachAndAttachDisposableWidget, +) + + +@pytest.fixture +def mock_qapp(): + app = MockQubesComplete() + return app + + +def make_mock_device(device_class="block"): + device = Mock() + device.device_class = device_class + device.interfaces = "" + device.is_valid_for_vm = Mock(return_value=True) + device.attach_to_vm = Mock() + device.detach_from_vm = Mock() + return device + + +def make_mock_vm(qubes_app): + vm = Mock() + vm.icon_name = "appvm-green" + vm.name = "test-vm" + vm.vm_object = qubes_app._qubes["test-vm"] # pylint: disable=protected-access + return vm + + +class TestAttachDisposableWidget: + + def test_block_device_opens_file_manager(self, mock_qapp): + """run_service is called for block device attach to dispvm""" + mock_vm = make_mock_vm(mock_qapp) + mock_device = make_mock_device("block") + mock_dispvm = Mock() + mock_dispvm.name = "disp123" + mock_dispvm.log = Mock() + mock_dispvm.devices_denied = "" + + with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm): + widget = AttachDisposableWidget(mock_vm, mock_device) + asyncio.run(widget.widget_action()) + + mock_dispvm.run_service.assert_called_once_with( + "qubes.StartApp+qubes-open-file-manager", + wait=False, + ) + + def test_non_block_device_no_file_manager(self, mock_qapp): + """run_service is NOT called for non-block device""" + mock_vm = make_mock_vm(mock_qapp) + mock_device = make_mock_device("usb") + mock_dispvm = Mock() + mock_dispvm.name = "disp123" + mock_dispvm.devices_denied = "" + + with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm): + widget = AttachDisposableWidget(mock_vm, mock_device) + asyncio.run(widget.widget_action()) + + mock_dispvm.run_service.assert_not_called() + + def test_file_manager_exception_logged(self, mock_qapp): + """QubesException from run_service is logged via dispvm.log""" + mock_vm = make_mock_vm(mock_qapp) + mock_device = make_mock_device("block") + mock_dispvm = Mock() + mock_dispvm.name = "disp123" + mock_dispvm.log = Mock() + mock_dispvm.devices_denied = "" + mock_dispvm.run_service.side_effect = exc.QubesException("failed") + + with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm): + widget = AttachDisposableWidget(mock_vm, mock_device) + asyncio.run(widget.widget_action()) + + mock_dispvm.log.exception.assert_called_once() + + def test_feature_flag_disables_file_manager(self, mock_qapp): + """run_service is NOT called when feature flag is disabled""" + mock_vm = make_mock_vm(mock_qapp) + mock_vm.vm_object.features["device-open-file-manager-on-attach"] = "" + mock_device = make_mock_device("block") + mock_dispvm = Mock() + mock_dispvm.name = "disp123" + mock_dispvm.devices_denied = "" + with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm): + widget = AttachDisposableWidget(mock_vm, mock_device) + asyncio.run(widget.widget_action()) + mock_dispvm.run_service.assert_not_called() + + +class TestDetachAndAttachDisposableWidget: + + def test_block_device_opens_file_manager(self, mock_qapp): + """run_service is called for block device on detach+attach""" + mock_vm = make_mock_vm(mock_qapp) + mock_device = make_mock_device("block") + mock_dispvm = Mock() + mock_dispvm.name = "disp456" + mock_dispvm.log = Mock() + mock_dispvm.devices_denied = "" + + with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm): + widget = DetachAndAttachDisposableWidget(mock_vm, mock_device) + asyncio.run(widget.widget_action()) + + mock_dispvm.run_service.assert_called_once_with( + "qubes.StartApp+qubes-open-file-manager", + wait=False, + ) + + def test_file_manager_exception_logged(self, mock_qapp): + """QubesException from run_service is logged via dispvm.log""" + mock_vm = make_mock_vm(mock_qapp) + mock_device = make_mock_device("block") + mock_dispvm = Mock() + mock_dispvm.name = "disp456" + mock_dispvm.log = Mock() + mock_dispvm.devices_denied = "" + mock_dispvm.run_service.side_effect = exc.QubesException("failed") + + with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm): + widget = DetachAndAttachDisposableWidget(mock_vm, mock_device) + asyncio.run(widget.widget_action()) + + mock_dispvm.log.exception.assert_called_once() + + def test_feature_flag_disables_file_manager(self, mock_qapp): + """run_service is NOT called when feature flag is disabled""" + mock_vm = make_mock_vm(mock_qapp) + mock_vm.vm_object.features["device-open-file-manager-on-attach"] = "" + mock_device = make_mock_device("block") + mock_dispvm = Mock() + mock_dispvm.name = "disp456" + mock_dispvm.devices_denied = "" + with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm): + widget = DetachAndAttachDisposableWidget(mock_vm, mock_device) + asyncio.run(widget.widget_action()) + mock_dispvm.run_service.assert_not_called()