Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions qui/devices/actionable_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import qubesadmin
import qubesadmin.devices
import qubesadmin.vm
from qubesadmin import exc

import gi

Expand Down Expand Up @@ -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"""
Expand All @@ -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__(
Expand Down
1 change: 1 addition & 0 deletions qui/devices/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Empty file added qui/devices/tests/__init__.py
Empty file.
158 changes: 158 additions & 0 deletions qui/devices/tests/test_actionable_widgets.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I don't think this is 100% wrong - it definitely works - but a more future-resilient way is using the existing mocks and overwriting only the method you want to verify. E.g. here you can use any of the existing mock vms in the MockQubesComplete object and later when you want to check that run_service was called, just patch the run_service object. That approach should be more resilient to later changes in the code, while your approach means that potentially if some part of the code changes and e.g. asks for more VM properties, this mock has to be re-done.

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()