From f8248f35068388bc75a3d28873a72b1fa9aead24 Mon Sep 17 00:00:00 2001 From: Ali Mirjamali Date: Fri, 7 Mar 2025 17:36:42 +0330 Subject: [PATCH 1/3] Fix Drang-and-Drop for Qube Settings, Apps tab The existing patch failed, most probably during migration to PyQt6 Switch from `.ident` property to `.whatsThis()` and `.setWhatsThis()` --- .gitignore | 1 + qubesmanager/appmenu_select.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index dc443f40b..064ea19de 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ rpm/ pkgs/ debian/changelog.* +.coverage diff --git a/qubesmanager/appmenu_select.py b/qubesmanager/appmenu_select.py index 4d3c002ed..4e60ba637 100755 --- a/qubesmanager/appmenu_select.py +++ b/qubesmanager/appmenu_select.py @@ -33,7 +33,7 @@ def __init__(self, name, ident, tooltip=None, parent=None): else: tooltip += "\n" + additional_description self.setToolTip(tooltip) - self.ident = ident + self.setWhatsThis(ident) # Using identity as tooltip which also enables drag-and-drop self.setWhatsThis(ident) @@ -69,7 +69,7 @@ def fill_apps_list(self, template=None): self.whitelisted = [] currently_selected = [ - self.app_list.selected_list.item(i).ident + self.app_list.selected_list.item(i).whatsThis() for i in range(self.app_list.selected_list.count())] whitelist = set(self.whitelisted + currently_selected) @@ -97,9 +97,9 @@ def fill_apps_list(self, template=None): available_appmenus = [] for app in available_appmenus: - if app.ident in whitelist: + if app.whatsThis() in whitelist: self.app_list.selected_list.addItem(app) - whitelist.remove(app.ident) + whitelist.remove(app.whatsThis()) else: self.app_list.available_list.addItem(app) @@ -113,7 +113,7 @@ def fill_apps_list(self, template=None): self.app_list.selected_list.sortItems() def save_appmenu_select_changes(self): - new_whitelisted = [self.app_list.selected_list.item(i).ident + new_whitelisted = [self.app_list.selected_list.item(i).whatsThis() for i in range(self.app_list.selected_list.count())] if set(new_whitelisted) == set(self.whitelisted): From f2d0a17689450bc084236c04ab81da5faf204de2 Mon Sep 17 00:00:00 2001 From: Ali Mirjamali Date: Fri, 7 Mar 2025 21:18:31 +0330 Subject: [PATCH 2/3] Add `qubesimgconverter.Image.tint` binding for QImage Since PyQt6.QtGui.QImage does not provide tinting and the available effects from `QPainter` does not produce the same result, a helper function is necessary. --- debian/control | 1 + debian/install | 1 + qubesmanager/tests/test_utils.py | 73 ++++++++++++++++++++++++++++++++ qubesmanager/utils.py | 24 +++++++++++ rpm_spec/qmgr.spec.in | 2 + 5 files changed, 101 insertions(+) create mode 100644 qubesmanager/tests/test_utils.py diff --git a/debian/control b/debian/control index cf86c27b4..687b67a9e 100644 --- a/debian/control +++ b/debian/control @@ -23,6 +23,7 @@ Package: qubes-manager Architecture: any Depends: python3-qubesadmin (>= 4.3.7), + python3-qubesimgconverter, python3-pyqt6, python3-pyinotify, python3-qasync, diff --git a/debian/install b/debian/install index 411e25ff7..cbd682c3f 100644 --- a/debian/install +++ b/debian/install @@ -61,6 +61,7 @@ /usr/lib/*/dist-packages/qubesmanager/tests/test_qube_manager.py /usr/lib/*/dist-packages/qubesmanager/tests/test_vm_settings.py /usr/lib/*/dist-packages/qubesmanager/tests/test_clone_vm.py +/usr/lib/*/dist-packages/qubesmanager/tests/test_utils.py /usr/lib/*/dist-packages/qubesmanager-*.egg-info/* diff --git a/qubesmanager/tests/test_utils.py b/qubesmanager/tests/test_utils.py new file mode 100644 index 000000000..64541889d --- /dev/null +++ b/qubesmanager/tests/test_utils.py @@ -0,0 +1,73 @@ +# The Qubes OS Project, https://www.qubes-os.org/ +# +# Copyright (C) 2024 Marta Marczykowska-Górecka +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +from PyQt6 import QtGui # pylint: disable=import-error +from qubesmanager import utils +import unittest + + +class TestCaseQImage(unittest.TestCase): + def setUp(self): + self.rgba = ( + b"\x00\x00\x00\xff" + b"\xff\x00\x00\xff" + b"\x00\xff\x00\xff" + b"\x00\x00\x00\xff" + ) + self.width = 2 + self.height = 2 + + def test_00_empty_image(self): + empty_image = QtGui.QImage() + tinted_image = utils.tint_qimage(empty_image, "0x0000ff") + self.assertIsInstance( + tinted_image, + QtGui.QImage, + "Tint of empty QImage failed", + ) + + def test_01_tint(self): + source = QtGui.QImage( + self.rgba, + self.width, + self.height, + QtGui.QImage.Format.Format_RGBA8888, + ) + tinted_image = utils.tint_qimage(source, "0x0000ff") + self.assertIsInstance( + tinted_image, + QtGui.QImage, + "Tinting of a 2x2 RGBA QImage did not return a QImage", + ) + internal_data = tinted_image.constBits() + internal_data.setsize(self.width * self.height * 4) + raw_data = bytes(internal_data) + self.assertEqual( + raw_data, + b"\x00\x00\x3f\xff" + b"\x00\x00\xff\xff" + b"\x00\x00\xff\xff" + b"\x00\x00\x3f\xff", + "Tinting of refrence image returned wrong results", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/qubesmanager/utils.py b/qubesmanager/utils.py index 51184a1b6..9709ab08c 100644 --- a/qubesmanager/utils.py +++ b/qubesmanager/utils.py @@ -32,6 +32,7 @@ import sys from qubesadmin import events from qubesadmin import exc +import qubesimgconverter import xdg.BaseDirectory import pathlib import shutil @@ -599,3 +600,26 @@ def run_synchronous(window_class): qt_app.exit() return window + + +def tint_qimage(source: QtGui.QImage, color: str) -> QtGui.QImage: + """Use qubesimgconverter.Image.tint() to tint a PyQt6.QtGui.QImage""" + assert isinstance(source, QtGui.QImage) + size_bytes = source.width() * source.height() * 4 + if not size_bytes: + # Could not tint empty image + return source + source = source.convertToFormat(QtGui.QImage.Format.Format_RGBA8888) + internal_data = source.constBits() + internal_data.setsize(size_bytes) + raw_data = bytes(internal_data) + tinted_image = qubesimgconverter.Image( + raw_data, (source.width(), source.height()) + ).tint(color) + destination = QtGui.QImage( + tinted_image.data, + tinted_image.width, + tinted_image.height, + QtGui.QImage.Format.Format_RGBA8888, + ) + return destination diff --git a/rpm_spec/qmgr.spec.in b/rpm_spec/qmgr.spec.in index 22ceef0ba..75c420084 100644 --- a/rpm_spec/qmgr.spec.in +++ b/rpm_spec/qmgr.spec.in @@ -11,6 +11,7 @@ Requires: python%{python3_pkgversion} Requires: python%{python3_pkgversion}-pyqt6 Requires: python%{python3_pkgversion}-inotify Requires: python%{python3_pkgversion}-qubesadmin >= 4.3.7 +Requires: python%{python3_pkgversion}-qubesimgconverter Requires: python%{python3_pkgversion}-qasync Requires: python%{python3_pkgversion}-pyxdg Requires: qubes-desktop-linux-common >= 4.1.2 @@ -121,6 +122,7 @@ rm -rf $RPM_BUILD_ROOT %{python3_sitelib}/qubesmanager/tests/test_qube_manager.py %{python3_sitelib}/qubesmanager/tests/test_vm_settings.py %{python3_sitelib}/qubesmanager/tests/test_clone_vm.py +%{python3_sitelib}/qubesmanager/tests/test_utils.py %dir %{python3_sitelib}/qubesmanager-*.egg-info %{python3_sitelib}/qubesmanager-*.egg-info/* From 52f68791b155d6680b524b82ce8dd167cb63086e Mon Sep 17 00:00:00 2001 From: Ali Mirjamali Date: Sat, 8 Mar 2025 03:03:00 +0330 Subject: [PATCH 3/3] Add Icons to Qubes VM Settings Apps tab The current patch adds regular tinted icons. Option for fade in/out to regular images (not tinted) icons on mouse hover is on TODO list. fixes: https://github.com/QubesOS/qubes-issues/issues/9829 --- .gitlab-ci.yml | 4 + ci/requirements.txt | 2 + qubesmanager/appmenu_select.py | 114 +++++++++++++++++++------ qubesmanager/tests/test_vm_settings.py | 16 ++-- 4 files changed, 101 insertions(+), 35 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 21a42d08a..bf79edb7e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,6 +4,8 @@ checks:pylint: - pip3 install --quiet -r ci/requirements.txt - git clone https://github.com/QubesOS/qubes-core-admin-client ~/core-admin-client - (cd ~/core-admin-client;python3 setup.py egg_info) + - git clone https://github.com/QubesOS/qubes-linux-utils ~/linux-utils + - (cd ~/linux-utils/imgconverter;sudo python3 setup.py install) script: - PYTHONPATH=~/core-admin-client python3 -m pylint qubesmanager stage: checks @@ -27,6 +29,8 @@ checks:tests: - pip3 install --quiet -r ci/requirements.txt - git clone https://github.com/QubesOS/qubes-core-admin-client ~/core-admin-client - (cd ~/core-admin-client;python3 setup.py egg_info) + - git clone https://github.com/QubesOS/qubes-linux-utils ~/linux-utils + - (cd ~/linux-utils/imgconverter;sudo python3 setup.py install) script: - make ui - make res diff --git a/ci/requirements.txt b/ci/requirements.txt index 70b29c187..5249b0ee9 100644 --- a/ci/requirements.txt +++ b/ci/requirements.txt @@ -9,3 +9,5 @@ pylint sphinx PyYAML qasync +Pillow +numpy diff --git a/qubesmanager/appmenu_select.py b/qubesmanager/appmenu_select.py index 4e60ba637..d29577156 100755 --- a/qubesmanager/appmenu_select.py +++ b/qubesmanager/appmenu_select.py @@ -18,12 +18,14 @@ # import subprocess -from PyQt6 import QtWidgets, QtCore # pylint: disable=import-error +from PyQt6 import QtWidgets, QtCore, QtGui # pylint: disable=import-error from qubesadmin import exc +from qubesmanager.utils import tint_qimage +from os import path -# TODO description in tooltip -# TODO icon # pylint: disable=too-few-public-methods + + class AppListWidgetItem(QtWidgets.QListWidgetItem): def __init__(self, name, ident, tooltip=None, parent=None): super().__init__(name, parent) @@ -39,38 +41,48 @@ def __init__(self, name, ident, tooltip=None, parent=None): @classmethod def from_line(cls, line): - ident, name, comment = line.split('|', maxsplit=3) + ident, name, comment = line.split("|", maxsplit=3) return cls(name=name, ident=ident, tooltip=comment) @classmethod def from_ident(cls, ident): - name = 'Application missing in template! ({})'.format(ident) - comment = 'The listed application was available at some point to ' \ - 'this qube, but not any more. The most likely cause is ' \ - 'template change. Install the application in the template ' \ - 'if you want to restore it.' + name = "Application missing in template! ({})".format(ident) + comment = ( + "The listed application was available at some point to " + "this qube, but not any more. The most likely cause is " + "template change. Install the application in the template " + "if you want to restore it." + ) return cls(name=name, ident=ident, tooltip=comment) class AppmenuSelectManager: def __init__(self, vm, apps_multiselect): self.vm = vm - self.app_list = apps_multiselect # this is a multiselect wiget + self.app_list = apps_multiselect # this is a multiselect wiget self.whitelisted = None self.has_missing = False self.fill_apps_list(template=None) def fill_apps_list(self, template=None): try: - self.whitelisted = [line for line in subprocess.check_output( - ['qvm-appmenus', '--get-whitelist', self.vm.name] - ).decode().strip().split('\n') if line] + self.whitelisted = [ + line + for line in subprocess.check_output( + ["qvm-appmenus", "--get-whitelist", self.vm.name] + ) + .decode() + .strip() + .split("\n") + if line + ] except exc.QubesException: self.whitelisted = [] currently_selected = [ self.app_list.selected_list.item(i).whatsThis() - for i in range(self.app_list.selected_list.count())] + for i in range(self.app_list.selected_list.count()) + ] whitelist = set(self.whitelisted + currently_selected) @@ -81,18 +93,61 @@ def fill_apps_list(self, template=None): self.app_list.clear() - command = ['qvm-appmenus', '--get-available', - '--i-understand-format-is-unstable', '--file-field', - 'Comment'] + command = [ + "qvm-appmenus", + "--get-available", + "--i-understand-format-is-unstable", + "--file-field", + "Comment", + "--file-field", + "Icon", + ] if template: - command.extend(['--template', template.name]) + command.extend(["--template", template.name]) command.append(self.vm.name) + if not hasattr(self.vm, "template"): + # TemplateVMs and StandaloneVMs + main_template = self.vm.name + elif not hasattr(self.vm.template, "template"): + # AppVMs + main_template = self.vm.template.name + else: + # DispVMs + main_template = self.vm.template.template.name + + template_icons_path = path.join( + path.expanduser("~"), + ".local", + "share", + "qubes-appmenus", + f"{main_template}", + "apps.tempicons", + ) + try: - available_appmenus = [ - AppListWidgetItem.from_line(line) - for line in subprocess.check_output( - command).decode().splitlines()] + available_appmenus = [] + for line in subprocess.check_output(command).decode().splitlines(): + ident, name, comment, icon_path = line.split("|", maxsplit=4) + app_item = AppListWidgetItem.from_line( + "|".join([ident, name, comment]) + ) + icon_path = icon_path.replace( + "%VMDIR%/apps.icons", template_icons_path + ) + if path.exists(icon_path): + icon = QtGui.QIcon(icon_path) + qpixmap = icon.pixmap(QtCore.QSize(512, 512)) + qimage = QtGui.QImage(qpixmap) + qimage = tint_qimage(qimage, self.vm.label.color) + qpixmap = QtGui.QPixmap(qimage) + icon = QtGui.QIcon(qpixmap) + else: + # for .desktop files with missing icons + icon = QtGui.QIcon.fromTheme(self.vm.icon) + app_item.setIcon(icon) + available_appmenus.append(app_item) + except exc.QubesException: available_appmenus = [] @@ -113,16 +168,21 @@ def fill_apps_list(self, template=None): self.app_list.selected_list.sortItems() def save_appmenu_select_changes(self): - new_whitelisted = [self.app_list.selected_list.item(i).whatsThis() - for i in range(self.app_list.selected_list.count())] + new_whitelisted = [ + self.app_list.selected_list.item(i).whatsThis() + for i in range(self.app_list.selected_list.count()) + ] if set(new_whitelisted) == set(self.whitelisted): return False try: - self.vm.features['menu-items'] = " ".join(new_whitelisted) + self.vm.features["menu-items"] = " ".join(new_whitelisted) except exc.QubesException as ex: - raise RuntimeError(QtCore.QCoreApplication.translate( - "exception", 'Failed to set menu items')) from ex + raise RuntimeError( + QtCore.QCoreApplication.translate( + "exception", "Failed to set menu items" + ) + ) from ex return True diff --git a/qubesmanager/tests/test_vm_settings.py b/qubesmanager/tests/test_vm_settings.py index 571d7e6c2..d8c55498b 100644 --- a/qubesmanager/tests/test_vm_settings.py +++ b/qubesmanager/tests/test_vm_settings.py @@ -48,16 +48,16 @@ def mock_subprocess_complex(command): vm_name = command[-1] if command[1] == '--get-available': if vm_name == 'test-vm-set': - return (b'test.desktop|Test App|\n' - b'test2.desktop|Test2 App| test2\n' - b'test3.desktop|Test3 App|\n' - b'myvm.desktop|My VM app|\n') + return (b'test.desktop|Test App||\n' + b'test2.desktop|Test2 App| test2|\n' + b'test3.desktop|Test3 App||\n' + b'myvm.desktop|My VM app||\n') elif vm_name == 'fedora-36': - return b'tpl.desktop|Template App|\n' + return b'tpl.desktop|Template App||\n' else: - return (b'test.desktop|Test App|\n' - b'test2.desktop|Test2 App| test2\n' - b'test3.desktop|Test3 App|\n') + return (b'test.desktop|Test App||\n' + b'test2.desktop|Test2 App| test2|\n' + b'test3.desktop|Test3 App||\n') elif command[1] == '--get-whitelist': if vm_name == 'test-vm-set': return b'test.desktop\nmissing.desktop'