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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
rpm/
pkgs/
debian/changelog.*
.coverage
4 changes: 4 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ci/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ pylint
sphinx
PyYAML
qasync
Pillow
numpy
1 change: 1 addition & 0 deletions debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Package: qubes-manager
Architecture: any
Depends:
python3-qubesadmin (>= 4.3.7),
python3-qubesimgconverter,
python3-pyqt6,
python3-pyinotify,
python3-qasync,
Expand Down
1 change: 1 addition & 0 deletions debian/install
Original file line number Diff line number Diff line change
Expand Up @@ -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/*

Expand Down
122 changes: 91 additions & 31 deletions qubesmanager/appmenu_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -33,44 +35,54 @@
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)

@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).ident
for i in range(self.app_list.selected_list.count())]
self.app_list.selected_list.item(i).whatsThis()
for i in range(self.app_list.selected_list.count())
]

whitelist = set(self.whitelisted + currently_selected)

Expand All @@ -81,25 +93,68 @@

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

Check warning on line 117 in qubesmanager/appmenu_select.py

View check run for this annotation

Codecov / codecov/patch

qubesmanager/appmenu_select.py#L117

Added line #L117 was not covered by tests

template_icons_path = path.join(
path.expanduser("~"),
".local",
"share",
"qubes-appmenus",
f"{main_template}",
"apps.tempicons",
)
Comment on lines +119 to +126
Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't it be better to extend qvm-appmenus to print the path too? Maybe add some "field" like OriginalIconPath?
Especially, the current implementation doesn't handle applications installed in the AppVM itself (you can put applications into ~/.local/share/applications and they will be available same as those in /usr/share/applications in the template).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wouldn't it be better to extend qvm-appmenus to print the path too? Maybe add some "field" like OriginalIconPath? Especially, the current implementation doesn't handle applications installed in the AppVM itself (you can put applications into ~/.local/share/applications and they will be available same as those in /usr/share/applications in the template).

Yes. It does not work with icons for flatpak, snap and similar cases (will show the VM's label icon instead). But the fix for qvm-appmenus will be for desktop-linux-common repository. I suggest we leave this for a supplementary PR. After qvm-appemnus is patched to provide OriginalIconPath, I will come back and fix it here as well. This PR is already long and complex and I want to work on the pending Qube Comment PRs as well (after some of the pending PRs are concluded).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

P.S. One more consideration. The GUIVM icons in ~/.local/share/qubes-appmenus/VMNAME/apps.icons provided via qvm-appmenus are already tinted (applicable for Qube specific Flatpak, Snap, ...). And we have to consider this as well if we ever want to patch qvm-appmenus to provide real original icon.

Copy link
Member

Choose a reason for hiding this comment

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

The GUIVM icons in ~/.local/share/qubes-appmenus/VMNAME/apps.icons

Yes, but there is also apps.tempicons dir, similar to icons from the template.


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)

Check warning on line 144 in qubesmanager/appmenu_select.py

View check run for this annotation

Codecov / codecov/patch

qubesmanager/appmenu_select.py#L139-L144

Added lines #L139 - L144 were not covered by tests
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 = []

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)

Expand All @@ -113,16 +168,21 @@
self.app_list.selected_list.sortItems()

def save_appmenu_select_changes(self):
new_whitelisted = [self.app_list.selected_list.item(i).ident
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(

Check warning on line 182 in qubesmanager/appmenu_select.py

View check run for this annotation

Codecov / codecov/patch

qubesmanager/appmenu_select.py#L182

Added line #L182 was not covered by tests
QtCore.QCoreApplication.translate(
"exception", "Failed to set menu items"
)
) from ex

return True
73 changes: 73 additions & 0 deletions qubesmanager/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# The Qubes OS Project, https://www.qubes-os.org/
#
# Copyright (C) 2024 Marta Marczykowska-Górecka
# <[email protected]>
#
# 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()
16 changes: 8 additions & 8 deletions qubesmanager/tests/test_vm_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
24 changes: 24 additions & 0 deletions qubesmanager/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import sys
from qubesadmin import events
from qubesadmin import exc
import qubesimgconverter
import xdg.BaseDirectory
import pathlib
import shutil
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions rpm_spec/qmgr.spec.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/*
Expand Down