Skip to content

Commit 9fee2cf

Browse files
committed
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
1 parent d56bb9e commit 9fee2cf

4 files changed

Lines changed: 190 additions & 0 deletions

File tree

qui/devices/actionable_widgets.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import qubesadmin
3232
import qubesadmin.devices
3333
import qubesadmin.vm
34+
from qubesadmin import exc
3435

3536
import gi
3637

@@ -324,6 +325,21 @@ async def widget_action(self, *_args):
324325

325326
self.device.attach_to_vm(backend.VM(new_dispvm))
326327

328+
if self.device.device_class == "block" and self.vm.vm_object.features.get(
329+
backend.FEATURE_OPEN_FILE_MANAGER, "1"
330+
):
331+
try:
332+
new_dispvm.run_service(
333+
"qubes.StartApp+qubes-open-file-manager",
334+
wait=False,
335+
)
336+
except exc.QubesException as ex:
337+
new_dispvm.log.exception(
338+
"Failed to open file manager in %s: %s",
339+
new_dispvm.name,
340+
ex,
341+
)
342+
327343

328344
class DetachAndAttachDisposableWidget(ActionableWidget, VMWithIcon):
329345
"""Detach from all current attachments and attach to new disposable"""
@@ -340,6 +356,21 @@ async def widget_action(self, *_args):
340356

341357
self.device.attach_to_vm(backend.VM(new_dispvm))
342358

359+
if self.device.device_class == "block" and self.vm.vm_object.features.get(
360+
backend.FEATURE_OPEN_FILE_MANAGER, "1"
361+
):
362+
try:
363+
new_dispvm.run_service(
364+
"qubes.StartApp+qubes-open-file-manager",
365+
wait=False,
366+
)
367+
except exc.QubesException as ex:
368+
new_dispvm.log.exception(
369+
"Failed to open file manager in %s: %s",
370+
new_dispvm.name,
371+
ex,
372+
)
373+
343374

344375
class ToggleFeatureItem(ActionableWidget, SimpleActionWidget):
345376
def __init__(

qui/devices/backend.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
FEATURE_HIDE_CHILDREN = "device-hide-children"
4040
FEATURE_ATTACH_WITH_MIC = "device-attach-with-mic"
4141
FEATURE_RESOLUTION = "device-qvc-resolution" # dev_id=resolution, space delimited
42+
FEATURE_OPEN_FILE_MANAGER = "device-open-file-manager-on-attach"
4243

4344

4445
class VM:

qui/devices/tests/__init__.py

Whitespace-only changes.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# -*- encoding: utf8 -*-
2+
#
3+
# The Qubes OS Project, http://www.qubes-os.org
4+
#
5+
# Copyright (C) 2026 Sahil Kumar
6+
#
7+
# This program is free software; you can redistribute it and/or modify
8+
# it under the terms of the GNU Lesser General Public License as published by
9+
# the Free Software Foundation; either version 2.1 of the License, or
10+
# (at your option) any later version.
11+
# pylint: disable=redefined-outer-name
12+
import asyncio
13+
import pytest
14+
from unittest.mock import patch, Mock
15+
from qubesadmin import exc
16+
from qubesadmin.tests.mock_app import MockQubesComplete
17+
from qui.devices.actionable_widgets import (
18+
AttachDisposableWidget,
19+
DetachAndAttachDisposableWidget,
20+
)
21+
22+
23+
@pytest.fixture
24+
def mock_qapp():
25+
app = MockQubesComplete()
26+
return app
27+
28+
29+
def make_mock_device(device_class="block"):
30+
device = Mock()
31+
device.device_class = device_class
32+
device.interfaces = ""
33+
device.is_valid_for_vm = Mock(return_value=True)
34+
device.attach_to_vm = Mock()
35+
device.detach_from_vm = Mock()
36+
return device
37+
38+
39+
def make_mock_vm(qubes_app):
40+
vm = Mock()
41+
vm.icon_name = "appvm-green"
42+
vm.name = "test-vm"
43+
vm.vm_object = qubes_app._qubes["test-vm"] # pylint: disable=protected-access
44+
return vm
45+
46+
47+
class TestAttachDisposableWidget:
48+
49+
def test_block_device_opens_file_manager(self, mock_qapp):
50+
"""run_service is called for block device attach to dispvm"""
51+
mock_vm = make_mock_vm(mock_qapp)
52+
mock_device = make_mock_device("block")
53+
mock_dispvm = Mock()
54+
mock_dispvm.name = "disp123"
55+
mock_dispvm.log = Mock()
56+
mock_dispvm.devices_denied = ""
57+
58+
with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm):
59+
widget = AttachDisposableWidget(mock_vm, mock_device)
60+
asyncio.run(widget.widget_action())
61+
62+
mock_dispvm.run_service.assert_called_once_with(
63+
"qubes.StartApp+qubes-open-file-manager",
64+
wait=False,
65+
)
66+
67+
def test_non_block_device_no_file_manager(self, mock_qapp):
68+
"""run_service is NOT called for non-block device"""
69+
mock_vm = make_mock_vm(mock_qapp)
70+
mock_device = make_mock_device("usb")
71+
mock_dispvm = Mock()
72+
mock_dispvm.name = "disp123"
73+
mock_dispvm.devices_denied = ""
74+
75+
with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm):
76+
widget = AttachDisposableWidget(mock_vm, mock_device)
77+
asyncio.run(widget.widget_action())
78+
79+
mock_dispvm.run_service.assert_not_called()
80+
81+
def test_file_manager_exception_logged(self, mock_qapp):
82+
"""QubesException from run_service is logged via dispvm.log"""
83+
mock_vm = make_mock_vm(mock_qapp)
84+
mock_device = make_mock_device("block")
85+
mock_dispvm = Mock()
86+
mock_dispvm.name = "disp123"
87+
mock_dispvm.log = Mock()
88+
mock_dispvm.devices_denied = ""
89+
mock_dispvm.run_service.side_effect = exc.QubesException("failed")
90+
91+
with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm):
92+
widget = AttachDisposableWidget(mock_vm, mock_device)
93+
asyncio.run(widget.widget_action())
94+
95+
mock_dispvm.log.exception.assert_called_once()
96+
97+
def test_feature_flag_disables_file_manager(self, mock_qapp):
98+
"""run_service is NOT called when feature flag is disabled"""
99+
mock_vm = make_mock_vm(mock_qapp)
100+
mock_vm.vm_object.features["device-open-file-manager-on-attach"] = ""
101+
mock_device = make_mock_device("block")
102+
mock_dispvm = Mock()
103+
mock_dispvm.name = "disp123"
104+
mock_dispvm.devices_denied = ""
105+
with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm):
106+
widget = AttachDisposableWidget(mock_vm, mock_device)
107+
asyncio.run(widget.widget_action())
108+
mock_dispvm.run_service.assert_not_called()
109+
110+
111+
class TestDetachAndAttachDisposableWidget:
112+
113+
def test_block_device_opens_file_manager(self, mock_qapp):
114+
"""run_service is called for block device on detach+attach"""
115+
mock_vm = make_mock_vm(mock_qapp)
116+
mock_device = make_mock_device("block")
117+
mock_dispvm = Mock()
118+
mock_dispvm.name = "disp456"
119+
mock_dispvm.log = Mock()
120+
mock_dispvm.devices_denied = ""
121+
122+
with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm):
123+
widget = DetachAndAttachDisposableWidget(mock_vm, mock_device)
124+
asyncio.run(widget.widget_action())
125+
126+
mock_dispvm.run_service.assert_called_once_with(
127+
"qubes.StartApp+qubes-open-file-manager",
128+
wait=False,
129+
)
130+
131+
def test_file_manager_exception_logged(self, mock_qapp):
132+
"""QubesException from run_service is logged via dispvm.log"""
133+
mock_vm = make_mock_vm(mock_qapp)
134+
mock_device = make_mock_device("block")
135+
mock_dispvm = Mock()
136+
mock_dispvm.name = "disp456"
137+
mock_dispvm.log = Mock()
138+
mock_dispvm.devices_denied = ""
139+
mock_dispvm.run_service.side_effect = exc.QubesException("failed")
140+
141+
with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm):
142+
widget = DetachAndAttachDisposableWidget(mock_vm, mock_device)
143+
asyncio.run(widget.widget_action())
144+
145+
mock_dispvm.log.exception.assert_called_once()
146+
147+
def test_feature_flag_disables_file_manager(self, mock_qapp):
148+
"""run_service is NOT called when feature flag is disabled"""
149+
mock_vm = make_mock_vm(mock_qapp)
150+
mock_vm.vm_object.features["device-open-file-manager-on-attach"] = ""
151+
mock_device = make_mock_device("block")
152+
mock_dispvm = Mock()
153+
mock_dispvm.name = "disp456"
154+
mock_dispvm.devices_denied = ""
155+
with patch("qubesadmin.vm.DispVM.from_appvm", return_value=mock_dispvm):
156+
widget = DetachAndAttachDisposableWidget(mock_vm, mock_device)
157+
asyncio.run(widget.widget_action())
158+
mock_dispvm.run_service.assert_not_called()

0 commit comments

Comments
 (0)