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