diff --git a/icons/scalable/audio-dark.svg b/icons/scalable/audio-dark.svg
new file mode 100644
index 00000000..07504379
--- /dev/null
+++ b/icons/scalable/audio-dark.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/icons/scalable/audio-light.svg b/icons/scalable/audio-light.svg
new file mode 100644
index 00000000..cd48934b
--- /dev/null
+++ b/icons/scalable/audio-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/icons/scalable/bluetooth-dark.svg b/icons/scalable/bluetooth-dark.svg
new file mode 100644
index 00000000..4ba32766
--- /dev/null
+++ b/icons/scalable/bluetooth-dark.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/icons/scalable/bluetooth-light.svg b/icons/scalable/bluetooth-light.svg
new file mode 100644
index 00000000..becb3fa7
--- /dev/null
+++ b/icons/scalable/bluetooth-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/icons/scalable/check-dark.svg b/icons/scalable/check-dark.svg
new file mode 100644
index 00000000..d4a1b705
--- /dev/null
+++ b/icons/scalable/check-dark.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/icons/scalable/check-light.svg b/icons/scalable/check-light.svg
new file mode 100644
index 00000000..6e390aac
--- /dev/null
+++ b/icons/scalable/check-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/icons/scalable/keyboard-dark.svg b/icons/scalable/keyboard-dark.svg
new file mode 100644
index 00000000..c27de6b9
--- /dev/null
+++ b/icons/scalable/keyboard-dark.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/icons/scalable/keyboard-light.svg b/icons/scalable/keyboard-light.svg
new file mode 100644
index 00000000..e6f32531
--- /dev/null
+++ b/icons/scalable/keyboard-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/icons/scalable/network-dark.svg b/icons/scalable/network-dark.svg
new file mode 100644
index 00000000..8c5b8505
--- /dev/null
+++ b/icons/scalable/network-dark.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/icons/scalable/network-light.svg b/icons/scalable/network-light.svg
new file mode 100644
index 00000000..bb5edb66
--- /dev/null
+++ b/icons/scalable/network-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/icons/scalable/printer-dark.svg b/icons/scalable/printer-dark.svg
new file mode 100644
index 00000000..da995b0a
--- /dev/null
+++ b/icons/scalable/printer-dark.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/icons/scalable/printer-light.svg b/icons/scalable/printer-light.svg
new file mode 100644
index 00000000..3891d89c
--- /dev/null
+++ b/icons/scalable/printer-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/qubes_config/global_config/device_blocks.py b/qubes_config/global_config/device_blocks.py
index a292e1d0..0ad81a85 100644
--- a/qubes_config/global_config/device_blocks.py
+++ b/qubes_config/global_config/device_blocks.py
@@ -301,7 +301,7 @@ def __init__(self):
self.root_device.children.append(
DeviceCategoryWrapper(
_("Image input devices"),
- DeviceCategory.Image_Input,
+ DeviceCategory.Camera,
_("Scanners and cameras"),
)
)
diff --git a/qui/devices/actionable_widgets.py b/qui/devices/actionable_widgets.py
index 853a5ae6..492ab285 100644
--- a/qui/devices/actionable_widgets.py
+++ b/qui/devices/actionable_widgets.py
@@ -23,8 +23,11 @@
Use generate_wrapper_widget to get a wrapped widget.
"""
+import asyncio
+import functools
import pathlib
-from typing import Iterable, Callable, Optional
+from typing import Iterable, Callable, Optional, List
+
import qubesadmin
import qubesadmin.devices
import qubesadmin.vm
@@ -87,7 +90,7 @@ def __init__(self, *args, **kwargs):
# should this widget be sensitive?
self.actionable: bool = True
- def widget_action(self, *_args):
+ async def widget_action(self, *_args):
"""What should happen when this widget is activated/clicked"""
@@ -127,23 +130,21 @@ def __init__(
vm: backend.VM,
size: int = 18,
variant: str = "dark",
- name_extension: Optional[str] = None,
+ name: Optional[str] = None,
):
"""
- Icon with VM name and optional text name extension in parentheses.
+ Icon with VM name or optional other text in parentheses.
:param vm: VM object
:param size: icon size
:param variant: light / dark string
- :param name_extension: optional text to be added after vm name
+ :param name: optional text to put instead of vm name
after colon
"""
super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
self.backend_icon = VariantIcon(vm.icon_name, variant, size)
self.backend_label = Gtk.Label(xalign=0)
- backend_label: str = vm.name
- if name_extension:
- backend_label += ": " + name_extension
+ backend_label: str = name or vm.name
self.backend_label.set_markup(backend_label)
self.pack_start(self.backend_icon, False, False, 4)
@@ -152,10 +153,15 @@ def __init__(
self.get_style_context().add_class("vm_item")
-class VMAttachmentDiagram(Gtk.Box):
+class VMInfoBox(Gtk.Box):
"""
- Device attachment scheme, in the following form:
- backend_vm (device name) [-> frontend_vm[, other_frontend+]]
+ Information about device.
+ For all devices, it has:
+ Device attachment scheme, in the following form:
+ backend_vm (device name) [-> frontend_vm[, other_frontend+]]
+ If relevant, also:
+ - information that the device attaches with microphone
+ - information that the device is a child of another device
"""
def __init__(self, device: backend.Device, variant: str = "dark"):
@@ -164,7 +170,7 @@ def __init__(self, device: backend.Device, variant: str = "dark"):
backend_vm = device.backend_domain
frontend_vms = list(device.attachments)
# backend is always there
- backend_vm_icon = VMWithIcon(backend_vm, name_extension=device.id_string)
+ backend_vm_icon = VMWithIcon(backend_vm, name=device.port)
backend_vm_icon.get_style_context().add_class("main_device_vm")
self.pack_start(backend_vm_icon, False, False, 4)
@@ -235,7 +241,7 @@ def __init__(self, vm: backend.VM, device: backend.Device):
self.vm = vm
self.device = device
- def widget_action(self, *_args):
+ async def widget_action(self, *_args):
self.device.attach_to_vm(self.vm)
@@ -247,8 +253,27 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
self.vm = vm
self.device = device
- def widget_action(self, *_args):
- self.device.detach_from_vm(self.vm)
+ async def widget_action(self, *_args):
+ self.device.detach_from_vm(self.vm, False)
+
+
+class DetachWithWidget(ActionableWidget, SimpleActionWidget):
+ """Detach device from a VM with another device"""
+
+ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"):
+ second_device_names = ", ".join(
+ [dev.name for dev in device.devices_to_attach_with_me]
+ )
+ super().__init__(
+ "detach",
+ "Detach from " + vm.name + " with " + second_device_names + "",
+ variant,
+ )
+ self.vm = vm
+ self.device = device
+
+ async def widget_action(self, *_args):
+ self.device.detach_from_vm(self.vm, True)
class DetachAndShutdownWidget(ActionableWidget, SimpleActionWidget):
@@ -261,8 +286,8 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
self.vm = vm
self.device = device
- def widget_action(self, *_args):
- self.device.detach_from_vm(self.vm)
+ async def widget_action(self, *_args):
+ self.device.detach_from_vm(self.vm, True)
self.vm.vm_object.shutdown()
@@ -274,9 +299,9 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
self.vm = vm
self.device = device
- def widget_action(self, *_args):
+ async def widget_action(self, *_args):
for vm in self.device.attachments:
- self.device.detach_from_vm(vm)
+ self.device.detach_from_vm(vm, True)
self.device.attach_to_vm(self.vm)
@@ -288,7 +313,7 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
self.vm = vm
self.device = device
- def widget_action(self, *_args):
+ async def widget_action(self, *_args):
new_dispvm = qubesadmin.vm.DispVM.from_appvm(self.vm.vm_object.app, self.vm)
new_dispvm.start()
@@ -303,7 +328,7 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark"
self.vm = vm
self.device = device
- def widget_action(self, *_args):
+ async def widget_action(self, *_args):
self.device.detach_from_vm(self.vm)
new_dispvm = qubesadmin.vm.DispVM.from_appvm(self.vm.vm_object.app, self.vm)
new_dispvm.start()
@@ -311,7 +336,48 @@ def widget_action(self, *_args):
self.device.attach_to_vm(backend.VM(new_dispvm))
-#### Other actions
+class ToggleFeatureItem(ActionableWidget, SimpleActionWidget):
+ def __init__(
+ self, icon_name, text, state, device, feature_name, variant: str = "dark"
+ ):
+ """
+ Widget representing a toggleable feature.
+ :param icon_name: name of the 'on' icon
+ :param text: text on the widget
+ :param state: whether the item is toggled or not toggled
+ :param device: Device object
+ :param feature_name: name of the feature
+ :param variant: color variant
+ """
+ super().__init__(icon_name if state else "", text, variant)
+ self.device = device
+ self.feature_name = feature_name
+
+ async def widget_action(self, *_args):
+ """Toggle the state of the feature: either add the device ID to it or remove
+ it."""
+ self.device.backend_domain.toggle_feature_value(
+ self.feature_name, str(self.device.id_string)
+ )
+
+
+#### Start sys-usb
+
+
+class StartSysUsb(ActionableWidget, SimpleActionWidget):
+ def __init__(self, sysusb: backend.VM, variant: str = "dark"):
+ super().__init__(
+ icon_name=sysusb.icon_name,
+ text="List USB Devices " "(start sys-usb)",
+ variant=variant,
+ )
+ self.sysusb = sysusb
+
+ async def widget_action(self, *_args):
+ self.sysusb.vm_object.start()
+
+
+#### Configuration-related actions
class DeviceSettingsWidget(ActionableWidget, SimpleActionWidget):
@@ -321,10 +387,60 @@ class DeviceSettingsWidget(ActionableWidget, SimpleActionWidget):
def __init__(self, device: backend.Device, variant: str = "dark"):
super().__init__("settings", "Device settings", variant)
+ self.variant = variant
self.device = device
- def widget_action(self, *_args):
- pass
+ def get_child_widgets(self):
+ if self.device.device_group == "Camera":
+ yield ToggleFeatureItem(
+ "check",
+ "Attach with Microphone",
+ bool(self.device.devices_to_attach_with_me),
+ self.device,
+ backend.FEATURE_ATTACH_WITH_MIC,
+ self.variant,
+ )
+
+ if self.device.has_children:
+ yield ToggleFeatureItem(
+ "check",
+ "Show child devices",
+ self.device.show_children,
+ self.device,
+ backend.FEATURE_HIDE_CHILDREN,
+ self.variant,
+ )
+
+ gaw_item = GlobalAttachmentWidget(self.device, self.variant)
+ yield gaw_item
+
+ def toggle_feature(self, feature_name, *_args):
+ feature = self.device.backend_domain.features.get(feature_name, "")
+ all_devs: List[str] = feature.split(" ")
+
+ if self.device.id_string in all_devs:
+ all_devs.remove(self.device.id_string)
+ else:
+ all_devs.append(self.device.id_string)
+
+ new_feature = " ".join(all_devs)
+ self.device.backend_domain.features[feature_name] = new_feature
+
+ async def widget_action(self, *_args):
+ """No-op, only shows child item."""
+
+
+class GlobalAttachmentWidget(ActionableWidget, SimpleActionWidget):
+ """
+ Open Global Config at Device Attachments
+ """
+
+ def __init__(self, device: backend.Device, variant: str = "dark"):
+ super().__init__("settings", "Auto Attach Settings...", variant)
+ self.device = device
+
+ async def widget_action(self, *_args):
+ await asyncio.create_subprocess_exec("qubes-global-config", "-o", "attachments")
class GlobalSettingsWidget(ActionableWidget, SimpleActionWidget):
@@ -336,8 +452,8 @@ def __init__(self, device: backend.Device, variant: str = "dark"):
super().__init__("settings", "Global device settings", variant)
self.device = device
- def widget_action(self, *_args):
- pass
+ async def widget_action(self, *_args):
+ await asyncio.create_subprocess_exec("qubes-global-config", "-o", "attachments")
class HelpWidget(ActionableWidget, SimpleActionWidget):
@@ -349,7 +465,7 @@ def __init__(self, device: backend.Device, variant: str = "dark"):
super().__init__("question-icon", "Help", variant)
self.device = device
- def widget_action(self, *_args):
+ async def widget_action(self, *_args):
pass
@@ -361,41 +477,36 @@ def __init__(self, device: backend.Device, variant: str = "dark"):
"""General information about the device - name, in the future also
a button to rename the device."""
super().__init__(orientation=Gtk.Orientation.VERTICAL)
- # FUTURE: this is proposed layout for new API
- # self.device_label = Gtk.Label()
- # self.device_label.set_markup(device.name)
- # self.device_label.get_style_context().add_class('device_name')
- # self.edit_icon = VariantIcon('edit', 'dark', 24)
- # self.detailed_description_label = Gtk.Label()
- # self.detailed_description_label.set_text(device.description)
- # self.backend_icon = VariantIcon(device.vm_icon, 'dark', 24)
- # self.backend_label = Gtk.Label(xalign=0)
- # self.backend_label.set_markup(str(device.backend_domain))
- #
- # self.title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- # self.title_box.add(self.device_label)
- # self.title_box.add(self.edit_icon)
- #
- # self.attachment_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- # self.attachment_box.add(self.backend_icon)
- # self.attachment_box.add(self.backend_label)
- #
- # self.add(self.title_box)
- # self.add(self.detailed_description_label)
- # self.add(self.attachment_box)
-
self.device_label = Gtk.Label()
self.device_label.set_markup(device.name)
self.device_label.get_style_context().add_class("device_name")
self.device_label.set_xalign(Gtk.Align.CENTER)
self.device_label.set_halign(Gtk.Align.CENTER)
- self.diagram = VMAttachmentDiagram(device, variant)
+ self.diagram = VMInfoBox(device, variant)
self.diagram.set_halign(Gtk.Align.CENTER)
self.add(self.device_label)
self.add(self.diagram)
+ if device.parent:
+ parent_label = Gtk.Label()
+ parent_label.set_halign(Gtk.Align.CENTER)
+ parent_label.set_markup(
+ "This device is a child of " + str(device.parent) + ""
+ )
+ self.add(parent_label)
+
+ if device.devices_to_attach_with_me:
+ mic_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ mic_label = Gtk.Label()
+ mic_label.set_markup("This device will attach with microphone")
+ mic_box.add(mic_label)
+ mic_img = VariantIcon("mic", variant, 18)
+ mic_box.add(mic_img)
+ mic_box.set_halign(Gtk.Align.CENTER)
+ self.add(mic_box)
+
self.actionable = False
@@ -443,7 +554,7 @@ def __init__(self, device: backend.Device, variant: str = "dark"):
self.attach(self.device_icon, 0, 0, 1, 1)
self.attach(self.device_label, 1, 0, 3, 1)
- self.vm_diagram = VMAttachmentDiagram(device, self.variant)
+ self.vm_diagram = VMInfoBox(device, self.variant)
self.attach(self.vm_diagram, 1, 1, 3, 1)
def get_child_widgets(self, vms, disp_vm_templates) -> Iterable[ActionableWidget]:
@@ -451,22 +562,41 @@ def get_child_widgets(self, vms, disp_vm_templates) -> Iterable[ActionableWidget
Get type-appropriate list of child widgets.
:return: iterable of ActionableWidgets, ready to be packed in somewhere
"""
+
+ attached_vms = [vm for vm in vms if vm in self.device.attachments]
+ assigned_vms = [
+ vm
+ for vm in vms
+ if vm in self.device.assignments and vm not in self.device.attachments
+ ]
+ other_vms = [
+ vm
+ for vm in vms
+ if vm not in self.device.attachments and vm not in self.device.assignments
+ ]
+
# all devices have a header
yield DeviceHeaderWidget(self.device, self.variant)
yield SeparatorItem()
# if attached
- if self.device.attachments:
- for vm in self.device.attachments:
+ if attached_vms:
+ for vm in attached_vms:
yield DetachWidget(vm, self.device, self.variant)
if vm.should_be_cleaned_up:
yield DetachAndShutdownWidget(vm, self.device, self.variant)
-
+ if self.device.devices_to_attach_with_me:
+ yield DetachWithWidget(vm, self.device, self.variant)
yield SeparatorItem()
+ if assigned_vms:
+ yield InfoHeader("Detach and attach to preferred qube:")
+ for vm in assigned_vms:
+ yield DetachAndAttachWidget(vm, self.device, self.variant)
+
yield InfoHeader("Detach and attach to other qube:")
- for vm in vms:
+ for vm in other_vms:
yield DetachAndAttachWidget(vm, self.device, self.variant)
yield SeparatorItem()
@@ -477,8 +607,13 @@ def get_child_widgets(self, vms, disp_vm_templates) -> Iterable[ActionableWidget
yield DetachAndAttachDisposableWidget(vm, self.device, self.variant)
else:
+ if assigned_vms:
+ yield InfoHeader("Attach to preferred qube:")
+ for vm in assigned_vms:
+ yield AttachWidget(vm, self.device)
+
yield InfoHeader("Attach to qube:")
- for vm in vms:
+ for vm in other_vms:
yield AttachWidget(vm, self.device)
yield SeparatorItem()
@@ -488,6 +623,8 @@ def get_child_widgets(self, vms, disp_vm_templates) -> Iterable[ActionableWidget
for vm in disp_vm_templates:
yield AttachDisposableWidget(vm, self.device, self.variant)
+ yield DeviceSettingsWidget(self.device, self.variant)
+
def generate_wrapper_widget(
widget_class: Callable, signal: str, inside_widget: ActionableWidget
@@ -501,6 +638,10 @@ def generate_wrapper_widget(
"""
widget = widget_class()
widget.add(inside_widget)
- widget.connect(signal, inside_widget.widget_action)
+
+ def run_async(func, *_args):
+ asyncio.create_task(func())
+
+ widget.connect(signal, functools.partial(run_async, inside_widget.widget_action))
widget.set_sensitive(inside_widget.actionable)
return widget
diff --git a/qui/devices/backend.py b/qui/devices/backend.py
index 27ad9eaf..38f98643 100644
--- a/qui/devices/backend.py
+++ b/qui/devices/backend.py
@@ -17,14 +17,14 @@
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program; if not, see .
-from typing import Set, Dict, Optional
+from typing import Set, Dict, Optional, List
import qubesadmin
import qubesadmin.exc
import qubesadmin.devices
import qubesadmin.vm
from qubesadmin.utils import size_to_human
-from qubesadmin.device_protocol import DeviceAssignment
+from qubesadmin.device_protocol import DeviceAssignment, DeviceCategory
import gi
@@ -36,6 +36,9 @@
t = gettext.translation("desktop-linux-manager", fallback=True)
_ = t.gettext
+FEATURE_HIDE_CHILDREN = "device-hide-children"
+FEATURE_ATTACH_WITH_MIC = "device-attach-with-mic"
+
class VM:
"""
@@ -47,6 +50,8 @@ def __init__(self, vm: qubesadmin.vm.QubesVM):
self._vm = vm
self.name = vm.name
self.vm_class = vm.klass
+ self.is_running = True # in most cases, this is just True, unless we're at
+ # sysusb
def __str__(self):
return self.name
@@ -96,26 +101,59 @@ def should_be_cleaned_up(self):
"""
return getattr(self._vm, "auto_cleanup", False)
+ def toggle_feature_value(self, feature_name, value):
+ """
+ If provided value is part of a comma-separated list in feature_name, remove it.
+ If it is not, add it.
+ """
+ feature = self._vm.features.get(feature_name, "")
+ all_devs: List[str] = [f for f in feature.split(" ") if f]
+
+ if value in all_devs:
+ all_devs.remove(value)
+ else:
+ all_devs.append(value)
+
+ new_feature = " ".join(all_devs)
+ self._vm.features[feature_name] = new_feature
+
class Device:
+ @classmethod
+ def id_from_device(cls, dev: qubesadmin.devices.DeviceInfo) -> str:
+ return str(dev.port) + ":" + str(dev.device_id)
+
def __init__(self, dev: qubesadmin.devices.DeviceInfo, gtk_app: Gtk.Application):
self.gtk_app: Gtk.Application = gtk_app
self._dev: qubesadmin.devices.DeviceInfo = dev
self.__hash = hash(dev)
- self._port: str = ""
+ self._port: str = str(dev.port)
# Monotonic connection timestamp only for new devices
self.connection_timestamp: float = None
self._dev_name: str = getattr(dev, "description", "unknown")
- if dev.devclass == "block" and "size" in dev.data:
+ if dev.devclass == "block" and "size" in getattr(dev, "data", {}):
self._dev_name += " (" + size_to_human(int(dev.data["size"])) + ")"
self._ident: str = getattr(dev, "port_id", "unknown")
self._description: str = getattr(dev, "description", "unknown")
self._devclass: str = getattr(dev, "devclass", "unknown")
+
+ main_category = None
+ for interface in dev.interfaces:
+ if interface.category.name != "Other":
+ main_category = interface.category
+ break
+ else:
+ main_category = DeviceCategory.Other
+
+ self._category: DeviceCategory = main_category
+
self._data: Dict = getattr(dev, "data", {})
self._device_id = getattr(dev, "device_id", "*")
+ self.parent = str(getattr(dev, "parent_device", None) or "")
self.attachments: Set[VM] = set()
+ self.assignments: Set[VM] = set()
backend_domain = getattr(dev, "backend_domain", None)
if backend_domain:
self._backend_domain: Optional[VM] = VM(backend_domain)
@@ -128,6 +166,12 @@ def __init__(self, dev: qubesadmin.devices.DeviceInfo, gtk_app: Gtk.Application)
)
except qubesadmin.exc.QubesException:
self.vm_icon: str = "appvm-black"
+ self._full_id = self.id_from_device(dev)
+
+ self.devices_to_attach_with_me: List[Device] = []
+ self.has_children: bool = False
+ self.show_children: bool = True
+ self.hide_this_device: bool = False
def __str__(self):
return self._dev_name
@@ -146,7 +190,7 @@ def name(self) -> str:
@property
def id_string(self) -> str:
"""Unique id string"""
- return self._ident
+ return self._full_id
@property
def description(self) -> str:
@@ -166,10 +210,31 @@ def device_class(self) -> str:
@property
def device_icon(self) -> str:
"""Device icon"""
- if self.device_class == "block":
- return "harddrive"
- if self.device_class == "mic":
- return "mic"
+ match self._category:
+ case DeviceCategory.Network:
+ return "network"
+ case DeviceCategory.Keyboard:
+ return "keyboard"
+ case DeviceCategory.Mouse:
+ return "mouse"
+ case DeviceCategory.Input:
+ return "keyboard"
+ case DeviceCategory.Printer:
+ return "printer"
+ case DeviceCategory.Camera:
+ return "camera"
+ case DeviceCategory.Audio:
+ return "audio"
+ case DeviceCategory.Microphone:
+ return "mic"
+ case DeviceCategory.USB_Storage:
+ return "harddrive"
+ case DeviceCategory.Block_Storage:
+ return "harddrive"
+ case DeviceCategory.Storage:
+ return "harddrive"
+ case DeviceCategory.Bluetooth:
+ return "bluetooth"
return ""
@property
@@ -190,59 +255,25 @@ def notification_id(self) -> str:
@property
def device_group(self) -> str:
"""Device group for purposes of menus."""
- if self._devclass == "block":
- return "Data (Block) Devices"
- if self._devclass == "usb":
- return "USB Devices"
- if self._devclass == "mic":
- return "Microphones"
- # TODO: those below come from new API, may need an update
- if self._devclass == "Other":
- return "Other Devices"
- if self._devclass == "Communication":
- return "Other Devices" # eg. modems
- if self._devclass in ("Input", "Keyboard", "Mouse"):
- return "Input Devices"
- if self._devclass in ("Printer", "Scanner"):
- return "Printers and Scanners"
- if self._devclass == "Multimedia":
- return "Other Devices"
- # Multimedia = Audio, Video, Displays etc.
- if self._devclass == "Wireless":
- return "Other Devices"
- if self._devclass == "Bluetooth":
- return "Bluetooth Devices"
- if self._devclass == "Mass_Data":
- return "Other Devices"
- if self._devclass == "Network":
- return "Other Devices"
- if self._devclass == "Memory":
- return "Other Devices"
- if self._devclass.startswith("PCI"):
- return "PCI Devices"
- if self._devclass == "Docking Station":
- return "Docking Station"
- if self._devclass == "Processor":
- return "Other Devices"
- return "Other Devices"
+ return str(self._category.name).replace("_", " ")
@property
def sorting_key(self) -> str:
"""Key used for sorting devices in menus"""
return self.device_group + self._devclass + self.name
- def attach_to_vm(self, vm: VM):
+ def attach_to_vm(self, vm: VM, with_aux_devices: bool = True):
"""
- Perform attachment to provided VM.
+ Perform attachment to provided VM. If with_aux_devices is False,
+ ignore devices_to_attach_with_me
"""
try:
assignment = DeviceAssignment.new(
self.backend_domain,
- port_id=self.id_string,
+ port_id=self._ident,
devclass=self.device_class,
device_id=self._device_id,
)
-
vm.vm_object.devices[self.device_class].attach(assignment)
self.gtk_app.emit_notification(
_("Attaching device"),
@@ -250,6 +281,13 @@ def attach_to_vm(self, vm: VM):
Gio.NotificationPriority.NORMAL,
notification_id=self.notification_id,
)
+ if self.devices_to_attach_with_me and with_aux_devices:
+ for device in self.devices_to_attach_with_me:
+ if device is self:
+ # this should never happen, but....
+ continue
+ device.detach_from_all()
+ device.attach_to_vm(vm, with_aux_devices=False)
except Exception as ex: # pylint: disable=broad-except
self.gtk_app.emit_notification(
@@ -262,9 +300,10 @@ def attach_to_vm(self, vm: VM):
notification_id=self.notification_id,
)
- def detach_from_vm(self, vm: VM):
+ def detach_from_vm(self, vm: VM, with_aux_devices: bool = True):
"""
- Detach device from listed VM.
+ Detach device from listed VM. If with_aux_devices is False,
+ ignore devices_to_attach_with_me.
"""
self.gtk_app.emit_notification(
_("Detaching device"),
@@ -277,6 +316,12 @@ def detach_from_vm(self, vm: VM):
self.backend_domain, self._ident, self.device_class
)
vm.vm_object.devices[self.device_class].detach(assignment)
+ if self.devices_to_attach_with_me and with_aux_devices:
+ for device in self.devices_to_attach_with_me:
+ if device is self:
+ # this should never happen, but....
+ continue
+ device.detach_from_all(with_aux_devices=False)
except qubesadmin.exc.QubesException as ex:
self.gtk_app.emit_notification(
_("Error"),
@@ -288,9 +333,10 @@ def detach_from_vm(self, vm: VM):
notification_id=self.notification_id,
)
- def detach_from_all(self):
+ def detach_from_all(self, with_aux_devices: bool = True):
"""
- Detach from all VMs
+ Detach from all VMs. If with_aux_devices is False,
+ ignore devices_to_attach_with_me.
"""
for vm in self.attachments:
- self.detach_from_vm(vm)
+ self.detach_from_vm(vm, with_aux_devices)
diff --git a/qui/devices/device_widget.py b/qui/devices/device_widget.py
index f3e65b6e..9b15d85b 100644
--- a/qui/devices/device_widget.py
+++ b/qui/devices/device_widget.py
@@ -23,7 +23,7 @@
get_fullscreen_window_hack,
) # isort:skip
-from typing import Set, List, Dict
+from typing import Set, List, Dict, Optional
import asyncio
import sys
import time
@@ -80,10 +80,21 @@ def __init__(
super().__init__()
for child_widget in main_item.get_child_widgets(vms, dispvm_templates):
- item = actionable_widgets.generate_wrapper_widget(
+ child_item = actionable_widgets.generate_wrapper_widget(
Gtk.MenuItem, "activate", child_widget
)
- self.add(item)
+ if hasattr(child_widget, "get_child_widgets"):
+ submenu = Gtk.Menu()
+ submenu.set_reserve_toggle_size(False)
+
+ for menu_item_widget in child_widget.get_child_widgets():
+ menu_item = actionable_widgets.generate_wrapper_widget(
+ Gtk.MenuItem, "activate", menu_item_widget
+ )
+ submenu.add(menu_item)
+ submenu.show_all()
+ child_item.set_submenu(submenu)
+ self.add(child_item)
self.show_all()
@@ -100,6 +111,10 @@ def __init__(self, app_name, qapp, dispatcher):
self.devices: Dict[str, backend.Device] = {}
self.vms: Set[backend.VM] = set()
self.dispvm_templates: Set[backend.VM] = set()
+ self.parent_ports_to_hide = []
+ self.sysusb: backend.VM | None = None
+ self.dev_update_queue: Set = set()
+ self.vm_update_queue: Set = set()
self.dispatcher: qubesadmin.events.EventsDispatcher = dispatcher
self.qapp: qubesadmin.Qubes = qapp
@@ -109,6 +124,7 @@ def __init__(self, app_name, qapp, dispatcher):
self.initialize_vm_data()
self.initialize_dev_data()
+ self.initialize_features()
for devclass in DEV_TYPES:
self.dispatcher.add_handler(
@@ -117,8 +133,15 @@ def __init__(self, app_name, qapp, dispatcher):
self.dispatcher.add_handler(
"device-detach:" + devclass, self.device_detached
)
+ self.dispatcher.add_handler("device-added:" + devclass, self.device_added)
self.dispatcher.add_handler(
- "device-list-change:" + devclass, self.device_list_update
+ "device-removed:" + devclass, self.device_removed
+ )
+ self.dispatcher.add_handler(
+ "device-assign:" + devclass, self.device_assigned
+ )
+ self.dispatcher.add_handler(
+ "device-unassign:" + devclass, self.device_unassigned
)
self.dispatcher.add_handler("domain-shutdown", self.vm_shutdown)
@@ -137,6 +160,15 @@ def __init__(self, app_name, qapp, dispatcher):
"property-del:template_for_dispvms", self.vm_dispvm_template_change
)
+ for feature in [backend.FEATURE_HIDE_CHILDREN, backend.FEATURE_ATTACH_WITH_MIC]:
+
+ self.dispatcher.add_handler(
+ f"domain-feature-set:{feature}", self.update_single_feature
+ )
+ self.dispatcher.add_handler(
+ f"domain-feature-delete:{feature}", self.update_single_feature
+ )
+
self.widget_icon = Gtk.StatusIcon()
self.widget_icon.set_from_icon_name("qubes-devices")
self.widget_icon.connect("button-press-event", self.show_menu)
@@ -144,45 +176,54 @@ def __init__(self, app_name, qapp, dispatcher):
"Qubes Devices\nView and manage devices."
)
- def device_list_update(self, vm, _event, **_kwargs):
+ def _update_queue(self, vm, device, device_class):
+ """Handle certain operations that should not be done too often."""
+ # update children
+ if vm not in self.vm_update_queue:
+ self.vm_update_queue.add(vm)
+ asyncio.create_task(self.update_parents(vm))
+ if device not in self.dev_update_queue:
+ self.dev_update_queue.add(device)
+ asyncio.create_task(self.update_assignments(device_class))
+
+ async def update_assignments(self, dev_class):
+ """Scan vm list for new assignments"""
+ await asyncio.sleep(0.3)
+
+ if not self.dev_update_queue:
+ return
+ devs = self.dev_update_queue.copy()
+ self.dev_update_queue.clear()
- changed_devices: Dict[str, backend.Device] = {}
+ for domain in self.qapp.domains:
+ try:
+ for device in domain.devices[dev_class].get_attached_devices():
+ dev = backend.Device.id_from_device(device)
+ if dev in devs and dev in self.devices:
+ self.devices[dev].attachments.add(backend.VM(domain))
+
+ for device in domain.devices[dev_class].get_assigned_devices():
+ dev = backend.Device.id_from_device(device)
+ if dev in devs and dev in self.devices:
+ self.devices[dev].assignments.add(backend.VM(domain))
+ except qubesadmin.exc.QubesException:
+ # we have no permission to access VM's devices
+ continue
- # create list of all current devices from the changed VM
- try:
- for devclass in DEV_TYPES:
- for device in vm.devices[devclass]:
- changed_devices[str(device.port)] = backend.Device(device, self)
-
- except qubesadmin.exc.QubesException:
- changed_devices = {} # VM was removed
-
- for dev_port, dev in changed_devices.items():
- if dev_port not in self.devices:
- dev.connection_timestamp = time.monotonic()
- self.devices[dev_port] = dev
- self.emit_notification(
- _("Device available"),
- _("Device {} is available.").format(dev.description),
- Gio.NotificationPriority.NORMAL,
- notification_id=dev.notification_id,
- )
+ async def update_parents(self, vm):
+ await asyncio.sleep(0.3)
- dev_to_remove = []
- for dev_port, dev in self.devices.items():
- if dev.backend_domain != vm:
- continue
- if dev_port not in changed_devices:
- dev_to_remove.append((dev_port, dev))
-
- for dev_port, dev in dev_to_remove:
- self.emit_notification(
- _("Device removed"),
- _("Device {} has been removed.").format(dev.description),
- Gio.NotificationPriority.NORMAL,
- notification_id=dev.notification_id,
- )
- del self.devices[dev_port]
+ if vm not in self.vm_update_queue:
+ return
+ self.vm_update_queue.remove(vm)
+
+ self.update_single_feature(
+ None,
+ None,
+ backend.FEATURE_HIDE_CHILDREN,
+ value=vm.features.get(backend.FEATURE_HIDE_CHILDREN, ""),
+ oldvalue="",
+ )
def initialize_vm_data(self):
for vm in self.qapp.domains:
@@ -192,36 +233,227 @@ def initialize_vm_data(self):
self.vms.add(wrapped_vm)
if wrapped_vm.is_dispvm_template:
self.dispvm_templates.add(wrapped_vm)
+ if vm.name == "sys-usb":
+ self.sysusb = wrapped_vm
+ self.sysusb.is_running = vm.is_running()
except qubesadmin.exc.QubesException:
# we don't have access to VM state
pass
+ def device_added(self, vm, _event, device):
+ dev_id = backend.Device.id_from_device(device)
+ dev = backend.Device(device, self)
+ dev.connection_timestamp = time.monotonic()
+ self.devices[dev_id] = dev
+
+ if dev.parent:
+ for potential_parent in self.devices.values():
+ if potential_parent.port == dev.parent:
+ potential_parent.has_children = True
+ break
+
+ # connect with mic
+ mic_feature = vm.features.get(backend.FEATURE_ATTACH_WITH_MIC, "").split(" ")
+ if dev_id in mic_feature:
+ microphone = self.devices.get("dom0:mic:dom0:mic::m000000", None)
+ microphone.devices_to_attach_with_me.append(dev)
+ dev.devices_to_attach_with_me = [microphone]
+
+ self.emit_notification(
+ _("Device available"),
+ _("Device {} is available.").format(dev.description),
+ Gio.NotificationPriority.NORMAL,
+ notification_id=dev.notification_id,
+ )
+
+ self._update_queue(vm, dev_id, dev.device_class)
+
+ def device_removed(self, vm, _event, port):
+ for potential_dev_id, potential_dev in self.devices.items():
+ if (
+ potential_dev.backend_domain.name != vm.name
+ or potential_dev.port != str(port)
+ ):
+ continue
+ dev, dev_id = potential_dev, potential_dev_id
+ break
+ else:
+ # we never knew the device anyway
+ return
+
+ microphone = self.devices.get("dom0:mic:dom0:mic::m000000", None)
+
+ self.emit_notification(
+ _("Device removed"),
+ _("Device {} has been removed.").format(dev.description),
+ Gio.NotificationPriority.NORMAL,
+ notification_id=dev.notification_id,
+ )
+ if dev in microphone.devices_to_attach_with_me:
+ microphone.devices_to_attach_with_me.remove(dev)
+ if dev.port in self.parent_ports_to_hide:
+ self.parent_ports_to_hide.remove(dev.port)
+ del self.devices[dev_id]
+
def initialize_dev_data(self):
# list all devices
for domain in self.qapp.domains:
for devclass in DEV_TYPES:
try:
for device in domain.devices[devclass]:
- self.devices[str(device.port)] = backend.Device(device, self)
+ dev_id = backend.Device.id_from_device(device)
+ self.devices[dev_id] = backend.Device(device, self)
except qubesadmin.exc.QubesException:
# we have no permission to access VM's devices
continue
- # list existing device attachments
+ # list children devices
+ for device in self.devices.values():
+ if device.parent:
+ for potential_parent in self.devices.values():
+ if potential_parent.port == device.parent:
+ potential_parent.has_children = True
+
+ # list existing device attachments and assignments
for domain in self.qapp.domains:
for devclass in DEV_TYPES:
try:
for device in domain.devices[devclass].get_attached_devices():
- dev = str(device.port)
+ dev = backend.Device.id_from_device(device)
if dev in self.devices:
# occassionally ghost UnknownDevices appear when a
# device was removed but not detached from a VM
# FUTURE: is this still true after api changes?
self.devices[dev].attachments.add(backend.VM(domain))
+
+ for device in domain.devices[devclass].get_assigned_devices():
+ dev = backend.Device.id_from_device(device)
+ if dev in self.devices:
+ self.devices[dev].assignments.add(backend.VM(domain))
except qubesadmin.exc.QubesException:
# we have no permission to access VM's devices
continue
+ def device_assigned(self, vm, _event, device, **_kwargs):
+ dev_id = backend.Device.id_from_device(device)
+ if dev_id not in self.devices:
+ return
+ self.devices[dev_id].assignments.add(backend.VM(vm))
+
+ def device_unassigned(self, vm, _event, device, **_kwargs):
+ dev_id = backend.Device.id_from_device(device)
+ if dev_id not in self.devices:
+ return
+ try:
+ self.devices[dev_id].assignments.remove(backend.VM(vm))
+ except KeyError:
+ # it's ok, somehow we got an unassign for a device we didn't store as
+ # assigned. Cheers!
+ return
+
+ def update_single_feature(self, _vm, _event, feature, value=None, oldvalue=None):
+ if not value:
+ new = set()
+ else:
+ new = set(value.split(" "))
+ if not oldvalue:
+ old = set()
+ else:
+ old = set(oldvalue.split(" "))
+
+ add = new - old
+ remove = old - new
+
+ microphone = self.devices.get("dom0:mic:dom0:mic::m000000", None)
+
+ for dev_name in remove:
+ if feature == backend.FEATURE_ATTACH_WITH_MIC:
+ dev = self.devices.get(dev_name, None)
+ if dev:
+ dev.devices_to_attach_with_me = []
+ if dev in microphone.devices_to_attach_with_me:
+ microphone.devices_to_attach_with_me.remove(dev)
+ if feature == backend.FEATURE_HIDE_CHILDREN:
+ dev = self.devices.get(dev_name, None)
+ if dev and dev.port in self.parent_ports_to_hide:
+ dev.show_children = True
+ self.parent_ports_to_hide.remove(dev.port)
+ self.hide_child_devices(dev.port, True)
+
+ for dev_name in add:
+ if feature == backend.FEATURE_ATTACH_WITH_MIC and microphone:
+ dev = self.devices.get(dev_name, None)
+ if dev:
+ dev.devices_to_attach_with_me = [microphone]
+ microphone.devices_to_attach_with_me.append(dev)
+ if feature == backend.FEATURE_HIDE_CHILDREN:
+ dev = self.devices.get(dev_name, None)
+ if dev:
+ dev.show_children = False
+ self.parent_ports_to_hide.append(dev.port)
+ self.hide_child_devices(dev.port, False)
+
+ def initialize_features(self, *_args, **_kwargs):
+ """
+ Initialize all feature-related states
+ :return:
+ """
+ domains = self.qapp.domains
+
+ microphone = self.devices.get("dom0:mic:dom0:mic::m000000", None)
+ # clear existing feature mappings
+ for dev in self.devices.values():
+ dev.devices_to_attach_with_me = []
+ dev.hide_this_device = False
+ dev.show_children = True
+
+ mic_dev_strings = []
+ if microphone:
+ for domain in domains:
+ mic_feature = domain.features.get(
+ backend.FEATURE_ATTACH_WITH_MIC, False
+ )
+ if isinstance(mic_feature, str):
+ mic_dev_strings.extend(
+ [dev for dev in mic_feature.split(" ") if dev]
+ )
+
+ microphone.devices_to_attach_with_me = []
+
+ for dev in mic_dev_strings:
+ if dev in self.devices:
+ self.devices[dev].devices_to_attach_with_me = [microphone]
+ microphone.devices_to_attach_with_me.append(self.devices[dev])
+
+ self.parent_ports_to_hide = []
+ parent_ids_to_hide = []
+ for domain in domains:
+ children_feature = domain.features.get(backend.FEATURE_HIDE_CHILDREN, False)
+ if isinstance(children_feature, str):
+ parent_ids_to_hide.extend([s for s in children_feature.split(" ") if s])
+
+ for dev in self.devices.values():
+ if dev.id_string in parent_ids_to_hide:
+ self.parent_ports_to_hide.append(dev.port)
+ dev.show_children = False
+
+ self.hide_child_devices()
+
+ def hide_child_devices(
+ self, parent_port: Optional[str] = None, state: bool = False
+ ):
+ """Hide (if state=False) or show (if state=True) all children of the device
+ with provided parent_port, or all devices
+ whose parents are in self.parent_ports_to_hide"""
+ if not parent_port:
+ for port in self.parent_ports_to_hide:
+ self.hide_child_devices(port, state)
+
+ for device in self.devices.values():
+ if str(device.parent) == parent_port:
+ device.hide_this_device = not state
+ self.hide_child_devices(str(device.port), state)
+
def device_attached(self, vm, _event, device, **_kwargs):
try:
if not vm.is_running() or device.devclass not in DEV_TYPES:
@@ -230,12 +462,13 @@ def device_attached(self, vm, _event, device, **_kwargs):
# we don't have access to VM state
return
- if str(device.port) not in self.devices:
- self.devices[str(device.port)] = backend.Device(device, self)
+ dev_id = backend.Device.id_from_device(device)
+ if dev_id not in self.devices:
+ self.devices[dev_id] = backend.Device(device, self)
vm_wrapped = backend.VM(vm)
- self.devices[str(device.port)].attachments.add(vm_wrapped)
+ self.devices[dev_id].attachments.add(vm_wrapped)
def device_detached(self, vm, _event, port, **_kwargs):
try:
@@ -248,31 +481,43 @@ def device_detached(self, vm, _event, port, **_kwargs):
port = str(port)
vm_wrapped = backend.VM(vm)
- if port in self.devices:
- self.devices[port].attachments.discard(vm_wrapped)
+ for device in self.devices.values():
+ if device.port == port:
+ device.attachments.discard(vm_wrapped)
def vm_start(self, vm, _event, **_kwargs):
wrapped_vm = backend.VM(vm)
if wrapped_vm.is_attachable:
self.vms.add(wrapped_vm)
+ if wrapped_vm == self.sysusb:
+ self.sysusb.is_running = True
for devclass in DEV_TYPES:
try:
for device in vm.devices[devclass].get_attached_devices():
- dev = str(device.port)
- if dev in self.devices:
- self.devices[dev].attachments.add(wrapped_vm)
+ dev_id = backend.Device.id_from_device(device)
+ if dev_id in self.devices:
+ self.devices[dev_id].attachments.add(wrapped_vm)
except qubesadmin.exc.QubesDaemonAccessError:
# we don't have access to devices
return
def vm_shutdown(self, vm, _event, **_kwargs):
wrapped_vm = backend.VM(vm)
+ if wrapped_vm == self.sysusb:
+ self.sysusb.is_running = False
+
self.vms.discard(wrapped_vm)
self.dispvm_templates.discard(wrapped_vm)
+ devs_to_remove = []
for dev in self.devices.values():
+ if dev.backend_domain == wrapped_vm:
+ devs_to_remove.append(dev.port)
+ continue
dev.attachments.discard(wrapped_vm)
+ for dev_port in devs_to_remove:
+ self.device_removed(vm, None, port=dev_port)
def vm_dispvm_template_change(self, vm, _event, **_kwargs):
"""Is template for dispvms property changed"""
@@ -282,28 +527,6 @@ def vm_dispvm_template_change(self, vm, _event, **_kwargs):
else:
self.dispvm_templates.discard(wrapped_vm)
- #
- # def on_label_changed(self, vm, _event, **_kwargs):
- # if not vm: # global properties changed
- # return
- # try:
- # name = vm.name
- # except qubesadmin.exc.QubesPropertyAccessError:
- # return # the VM was deleted before its status could be updated
- # for domain in self.vms:
- # if str(domain) == name:
- # try:
- # domain.icon = vm.label.icon
- # except qubesadmin.exc.QubesPropertyAccessError:
- # domain.icon = 'appvm-block'
- #
- # for device in self.devices.values():
- # if device.backend_domain == name:
- # try:
- # device.vm_icon = vm.label.icon
- # except qubesadmin.exc.QubesPropertyAccessError:
- # device.vm_icon = 'appvm-black'
-
@staticmethod
def load_css(widget) -> str:
"""Load appropriate css. This should be called whenever menu is shown,
@@ -334,7 +557,10 @@ def show_menu(self, _unused, _event):
menu_items = []
sorted_vms = sorted(self.vms)
sorted_dispvms = sorted(self.dispvm_templates)
- sorted_devices = sorted(self.devices.values(), key=lambda x: x.sorting_key)
+ sorted_devices = sorted(
+ [dev for dev in self.devices.values() if not dev.hide_this_device],
+ key=lambda x: x.sorting_key,
+ )
for i, dev in enumerate(sorted_devices):
if i == 0 or dev.device_group != sorted_devices[i - 1].device_group:
@@ -358,6 +584,14 @@ def show_menu(self, _unused, _event):
menu_items.append(device_item)
+ if not self.sysusb.is_running:
+ sysusb_item = actionable_widgets.generate_wrapper_widget(
+ Gtk.MenuItem,
+ "activate",
+ actionable_widgets.StartSysUsb(self.sysusb, theme),
+ )
+ menu_items.append(sysusb_item)
+
for item in menu_items:
tray_menu.add(item)
diff --git a/rpm_spec/qubes-desktop-linux-manager.spec.in b/rpm_spec/qubes-desktop-linux-manager.spec.in
index db2340bb..68720181 100644
--- a/rpm_spec/qubes-desktop-linux-manager.spec.in
+++ b/rpm_spec/qubes-desktop-linux-manager.spec.in
@@ -252,8 +252,14 @@ gtk-update-icon-cache %{_datadir}/icons/Adwaita &>/dev/null || :
/usr/share/icons/hicolor/scalable/apps/arrow-dark.svg
/usr/share/icons/hicolor/scalable/apps/arrow-light.svg
+/usr/share/icons/hicolor/scalable/apps/audio-dark.svg
+/usr/share/icons/hicolor/scalable/apps/audio-light.svg
+/usr/share/icons/hicolor/scalable/apps/bluetooth-dark.svg
+/usr/share/icons/hicolor/scalable/apps/bluetooth-light.svg
/usr/share/icons/hicolor/scalable/apps/camera-dark.svg
/usr/share/icons/hicolor/scalable/apps/camera-light.svg
+/usr/share/icons/hicolor/scalable/apps/check-dark.svg
+/usr/share/icons/hicolor/scalable/apps/check-light.svg
/usr/share/icons/hicolor/scalable/apps/check_maybe.svg
/usr/share/icons/hicolor/scalable/apps/check_no.svg
/usr/share/icons/hicolor/scalable/apps/check_yes.svg
@@ -269,12 +275,18 @@ gtk-update-icon-cache %{_datadir}/icons/Adwaita &>/dev/null || :
/usr/share/icons/hicolor/scalable/apps/help-light.svg
/usr/share/icons/hicolor/scalable/apps/key-dark.svg
/usr/share/icons/hicolor/scalable/apps/key-light.svg
+/usr/share/icons/hicolor/scalable/apps/keyboard-dark.svg
+/usr/share/icons/hicolor/scalable/apps/keyboard-light.svg
/usr/share/icons/hicolor/scalable/apps/laptop-dark.svg
/usr/share/icons/hicolor/scalable/apps/laptop-light.svg
/usr/share/icons/hicolor/scalable/apps/mic-dark.svg
/usr/share/icons/hicolor/scalable/apps/mic-light.svg
/usr/share/icons/hicolor/scalable/apps/mouse-dark.svg
/usr/share/icons/hicolor/scalable/apps/mouse-light.svg
+/usr/share/icons/hicolor/scalable/apps/network-dark.svg
+/usr/share/icons/hicolor/scalable/apps/network-light.svg
+/usr/share/icons/hicolor/scalable/apps/printer-dark.svg
+/usr/share/icons/hicolor/scalable/apps/printer-light.svg
/usr/share/icons/hicolor/scalable/apps/bug-play.svg
/usr/share/icons/hicolor/scalable/apps/scroll-text.svg
/usr/share/icons/hicolor/scalable/apps/qubes-ask.svg