Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Some improvements; see commit history for details #9

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
20 changes: 20 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
watchdog = "*"
pyxdg = "*"
pygobject = "*"
gbulb = "*"
qubesadmin = "*" # install this manually, or use the following
# qubesadmin = {git = "https://github.com/QubesOS/qubes-core-admin-client"}

[dev-packages]
pytest = "*"
# pytest-asyncio = "*"
pylint = "*"

[requires]
python_version = ">=3.8"
313 changes: 313 additions & 0 deletions Pipfile.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ position or via executing `qubes-app-menu` with desired params in CLI.

![](readme_img/menu_howto.png)

## How to test
```
pipenv shell
QUBES_MENU_TEST=1 python qubes_menu
```

You also need to install `qubesadmin` from https://github.com/QubesOS/qubes-core-admin-client. Run `python setup.py` in the dowloaded repo in pipenv shell to install.

## Technical details

### New features
Expand Down
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
asyncio_mode=strict
4 changes: 4 additions & 0 deletions qubes_menu/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .appmenu import main

if __name__ == "__main__":
main()
16 changes: 8 additions & 8 deletions qubes_menu/appmenu.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,23 +264,23 @@ def exit_app(self):
task.cancel()


from .tests.mock_app import new_mock_qapp
import os


def main():
"""
Start the menu app
"""

qapp = qubesadmin.Qubes()
if "QUBES_MENU_TEST" in os.environ:
qapp = new_mock_qapp(qapp)
qapp.domains[qapp.local_name] = qapp.domains['dom0']
dispatcher = qubesadmin.events.EventsDispatcher(qapp)
app = AppMenu(qapp, dispatcher)
app.run(sys.argv)

if f'--{constants.RESTART_PARAM_LONG}' in sys.argv or \
f'-{constants.RESTART_PARAM_SHORT}' in sys.argv:
sys.argv = [x for x in sys.argv if x not in
(f'--{constants.RESTART_PARAM_LONG}',
f'-{constants.RESTART_PARAM_SHORT}')]
app = AppMenu(qapp, dispatcher)
app.run(sys.argv)


if __name__ == '__main__':
sys.exit(main())
87 changes: 43 additions & 44 deletions qubes_menu/desktop_file_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
"""
Helper class that manages all events related to .desktop files.
"""
import pyinotify
import logging
import asyncio
import os
import shlex

from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import xdg.DesktopEntry
import xdg.BaseDirectory
import xdg.Menu
Expand Down Expand Up @@ -125,6 +127,39 @@ def is_qubes_specific(self):
return 'X-Qubes-VM' in self.categories



class FileChangeHandler(FileSystemEventHandler):
def __init__(self, manager: 'DesktopFileManager'):
self.manager = manager
super().__init__()

def try_load(self, filename):
try:
self.manager.load_file(filename)
except FileNotFoundError:
self.manager.remove_file(filename)

def on_created(self, event):
"""On file create, attempt to load it. This can lead to spurious
warnings due to 0-byte files being loaded, but in some cases
is necessary to correctly process files."""
self.try_load(event.src_path)

def on_deleted(self, event):
"""
On file delete, remove the tile and all its children menu entries
"""
self.manager.remove_file(event.src_path)

def on_modified(self, event):
"""On modify, simply attempt to load the file again."""
self.try_load(event.src_path)

def on_moved(self, event):
self.manager.remove_file(event.src_path)
self.try_load(event.dest_path)


class DesktopFileManager:
"""
Class that loads, caches and observes changes in .desktop files.
Expand All @@ -133,40 +168,10 @@ class DesktopFileManager:
Path(xdg.BaseDirectory.xdg_data_home) / 'applications',
Path('/usr/share/applications')]

# pylint: disable=invalid-name
class EventProcessor(pyinotify.ProcessEvent):
"""pyinotify helper class"""
def __init__(self, parent):
self.parent = parent
super().__init__()

def process_IN_CREATE(self, event):
"""On file create, attempt to load it. This can lead to spurious
warnings due to 0-byte files being loaded, but in some cases
is necessary to correctly process files."""
try:
self.parent.load_file(event.pathname)
except FileNotFoundError:
self.parent.remove_file(event.pathname)

def process_IN_DELETE(self, event):
"""
On file delete, remove the tile and all its children menu entries
"""
self.parent.remove_file(event.pathname)

def process_IN_MODIFY(self, event):
"""On modify, simply attempt to laod the file again."""
try:
self.parent.load_file(event.pathname)
except FileNotFoundError:
self.parent.remove_file(event.pathname)

def __init__(self, qapp):
self.qapp = qapp
self.watch_manager = None
self.notifier = None
self.watches = []
self.observer = None
self._callbacks: List[Callable] = []

# directories used by Qubes menu tools, not necessarily all possible
Expand Down Expand Up @@ -273,18 +278,12 @@ def _initialize_watchers(self):
"""
Initialize all watcher entities.
"""
self.watch_manager = pyinotify.WatchManager()
event_handler = FileChangeHandler(self)
observer = Observer()

# pylint: disable=no-member
mask = pyinotify.IN_CREATE | pyinotify.IN_DELETE | pyinotify.IN_MODIFY

loop = asyncio.get_event_loop()

self.notifier = pyinotify.AsyncioNotifier(
self.watch_manager, loop,
default_proc_fun=DesktopFileManager.EventProcessor(self))
self.observer = observer

for path in self.desktop_dirs:
self.watches.append(
self.watch_manager.add_watch(
str(path), mask, rec=True, auto_add=True))
observer.schedule(event_handler, str(path), recursive=True)

observer.start()
35 changes: 6 additions & 29 deletions qubes_menu/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@
import pytest
import unittest.mock
from qubesadmin.tests import TestVM, TestVMCollection
from .mock_app import new_mock_qapp


class TestApp(object):
TestVM.__test__ = False # pytest, this is not a test suite


class TestApp:
def __init__(self):
self.domains = TestVMCollection(
[
Expand All @@ -38,31 +42,4 @@ def _invalidate_cache(self, *_args, **_kwargs):

@pytest.fixture
def test_qapp():
app = TestApp()
app.domains = TestVMCollection(
[
('dom0', TestVM('dom0', klass='AdminVM', label='black',
icon='adminvm-black', features={})),
('test-vm',
TestVM('test-vm', klass='AppVM', label='blue', icon='appvm-blue',
netvm=TestVM('sys-firewall'), template=TestVM('template'),
features={})),
('sys-firewall',
TestVM('sys-firewall', klass='DisposableVM', label='green',
icon='servicevm-green', netvm=TestVM('sys-net'),
template=TestVM('template'), features={})),
('sys-net',
TestVM('sys-net', klass='StandaloneVM', label='red',
icon='servicevm-red', provides_network=True,
template=TestVM('template'), features={'servicevm': 1})),
('template',
TestVM('template', klass='TemplateVM', label='red',
icon='templatevm-red', features={})),
('template-dvm',
TestVM('template-dvm', klass='AppVM', label='red',
icon='templatevm-red', template_for_dispvms=True,
netvm=TestVM('sys-net'), template=TestVM('template'),
features={})),
]
)
return app
return new_mock_qapp(TestApp())
31 changes: 31 additions & 0 deletions qubes_menu/tests/mock_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from qubesadmin.tests import TestVM, TestVMCollection


def new_mock_qapp(app):
app.domains = TestVMCollection(
[
('dom0', TestVM('dom0', klass='AdminVM', label='black',
icon='adminvm-black', features={})),
('test-vm',
TestVM('test-vm', klass='AppVM', label='blue', icon='appvm-blue',
netvm=TestVM('sys-firewall'), template=TestVM('template'),
features={})),
('sys-firewall',
TestVM('sys-firewall', klass='DisposableVM', label='green',
icon='servicevm-green', netvm=TestVM('sys-net'),
template=TestVM('template'), features={})),
('sys-net',
TestVM('sys-net', klass='StandaloneVM', label='red',
icon='servicevm-red', provides_network=True,
template=TestVM('template'), features={'servicevm': 1})),
('template',
TestVM('template', klass='TemplateVM', label='red',
icon='templatevm-red', features={})),
('template-dvm',
TestVM('template-dvm', klass='AppVM', label='red',
icon='templatevm-red', template_for_dispvms=True,
netvm=TestVM('sys-net'), template=TestVM('template'),
features={})),
]
)
return app
1 change: 0 additions & 1 deletion qubes_menu/tests/test_vmmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
from ..application_page import VMTypeToggle


@pytest.mark.asyncio
def test_vm_manager(test_qapp):
dispatcher = qubesadmin.events.EventsDispatcher(test_qapp)
vm_manager = VMManager(test_qapp, dispatcher)
Expand Down
33 changes: 23 additions & 10 deletions qubes_menu/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"""
Miscellaneous Qubes Menu utility functions.
"""
import gi
import os, gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GdkPixbuf, GLib

Expand All @@ -33,19 +33,32 @@ def load_icon(icon_name, size: Gtk.IconSize = Gtk.IconSize.LARGE_TOOLBAR):
"""
_, width, height = Gtk.icon_size_lookup(size)
try:
# icon name is a path
return GdkPixbuf.Pixbuf.new_from_file_at_size(icon_name, width, height)
except (GLib.Error, TypeError):
except (TypeError, GLib.Error):
pass

if "QUBES_MENU_TEST" in os.environ:
try:
# icon name is a path
image: GdkPixbuf.Pixbuf = Gtk.IconTheme.get_default().load_icon(
icon_name, width, 0)
return image
return GdkPixbuf.Pixbuf.new_from_file_at_size(os.path.join("./icons", icon_name + ".svg"), width, height)
except (TypeError, GLib.Error):
# icon not found in any way
pixbuf: GdkPixbuf.Pixbuf = GdkPixbuf.Pixbuf.new(
GdkPixbuf.Colorspace.RGB, True, 8, width, height)
pixbuf.fill(0x000)
return pixbuf
pass

try:
# icon name is symbol
image: GdkPixbuf.Pixbuf = Gtk.IconTheme.get_default().load_icon(
icon_name, width, 0)
return image
except (TypeError, GLib.Error):
pass

print(icon_name)
# icon not found in any way
pixbuf: GdkPixbuf.Pixbuf = GdkPixbuf.Pixbuf.new(
GdkPixbuf.Colorspace.RGB, True, 8, width, height)
pixbuf.fill(0xff00ffff) # magenta
return pixbuf


def show_error(title, text):
Expand Down