From 854ad2a102f562febdac8a351c902b9252d804fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Tue, 23 Jul 2019 17:16:44 -0300 Subject: [PATCH] supporting snaps --- CHANGELOG.md | 19 ++ README.md | 13 +- fpakman/__init__.py | 5 +- fpakman/app.py | 70 ++++-- fpakman/core/constants.py | 8 +- fpakman/core/controller.py | 311 +++++++++++-------------- fpakman/core/disk.py | 26 +-- fpakman/core/flatpak/__init__.py | 0 fpakman/core/flatpak/constants.py | 5 + fpakman/core/flatpak/controller.py | 150 ++++++++++++ fpakman/core/{ => flatpak}/flatpak.py | 13 +- fpakman/core/flatpak/model.py | 62 +++++ fpakman/core/flatpak/worker.py | 84 +++++++ fpakman/core/model.py | 88 ++----- fpakman/core/resource.py | 5 +- fpakman/core/snap/__init__.py | 0 fpakman/core/snap/constants.py | 4 + fpakman/core/snap/controller.py | 133 +++++++++++ fpakman/core/snap/model.py | 60 +++++ fpakman/core/snap/snap.py | 145 ++++++++++++ fpakman/core/snap/worker.py | 87 +++++++ fpakman/core/structure.py | 12 - fpakman/core/system.py | 22 +- fpakman/core/worker.py | 119 +--------- fpakman/resources/img/exclamation.svg | 144 ++++++++++++ fpakman/resources/img/snapcraft.png | Bin 0 -> 1138 bytes fpakman/resources/locale/en | 29 ++- fpakman/resources/locale/es | 27 ++- fpakman/resources/locale/pt | 29 ++- fpakman/util/cache.py | 3 +- fpakman/view/qt/apps_table.py | 136 +++++++---- fpakman/view/qt/history.py | 4 +- fpakman/view/qt/info.py | 36 +-- fpakman/view/qt/systray.py | 23 +- fpakman/view/qt/thread.py | 188 +++++++++------ fpakman/view/qt/view_model.py | 4 +- fpakman/view/qt/window.py | 320 ++++++++++++++++++++------ requirements.txt | 3 +- setup.py | 2 +- 39 files changed, 1739 insertions(+), 650 deletions(-) create mode 100644 fpakman/core/flatpak/__init__.py create mode 100644 fpakman/core/flatpak/constants.py create mode 100644 fpakman/core/flatpak/controller.py rename fpakman/core/{ => flatpak}/flatpak.py (93%) create mode 100644 fpakman/core/flatpak/model.py create mode 100644 fpakman/core/flatpak/worker.py create mode 100644 fpakman/core/snap/__init__.py create mode 100644 fpakman/core/snap/constants.py create mode 100644 fpakman/core/snap/controller.py create mode 100644 fpakman/core/snap/model.py create mode 100644 fpakman/core/snap/snap.py create mode 100644 fpakman/core/snap/worker.py delete mode 100644 fpakman/core/structure.py create mode 100755 fpakman/resources/img/exclamation.svg create mode 100755 fpakman/resources/img/snapcraft.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 3452a69a..e66ad35a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.4.0] +### Features +- supporting snaps +- search filters by application type and updates +- "Refresh" option when right-clicking an installed snap application (see **Comments**) +- snap / flatpak usage can be enabled / disabled via this new environment variables and arguments: FPAKMAN_FLATPAK (--flatpak), FPAKMAN_SNAP (--snap) +### Improvements +- automatically shows all updates after refreshing +- more accurate search results. +- system notifications when an application is installed, removed or downgraded. Also when an error occurs. +- showing management panel when right-clicking the tray icon. +- "Updates" label replaced by an exclamation icon and moved to the right. +- new environments variables / arguments associated with performance: FPAKMAN_DOWNLOAD_ICONS (--download-icons), FPAKMAN_CHECK_PACKAGING_ONCE (--check-packaging-once) +- minor GUI improvements +### Comments +- currently snap daemon (2.40) automatically upgrades your installed applications in background. Although it's possible to check for new updates +programmatically, it requires root access and would mess up with the user experience if every 5 minutes the application asked for the password. But not to let the +user with empty hands, it was added a "Refresh" option when right-clicking an installed snap application. It will update the application if its not already updated by the daemon. + ## [0.3.1] - 2019-07-13 ### Improvements - Console output now is optional and not shown by default diff --git a/README.md b/README.md index 565a7342..04d23e45 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## fpakman -Non-official graphical user interface for Flatpak application management. It is a tray icon that let the user known when new updates are available and +Non-official graphical user interface for Flatpak / Snap application management. It is a tray icon that let the user known when new updates are available and an application management panel where you can search, update, install and uninstall applications. ### Developed with: @@ -48,8 +48,19 @@ You can change some application settings via environment variables or arguments - **FPAKMAN_CACHE_EXPIRATION**: define a custom expiration time in SECONDS for cached API data. Default: 3600 (1 hour). - **FPAKMAN_ICON_EXPIRATION**: define a custom expiration time in SECONDS for cached icons. Default: 300 (5 minutes). - **FPAKMAN_DISK_CACHE**: enables / disables disk cache. When disk cache is enabled, the installed applications data are loaded faster. Use **0** (disable) or **1** (enable, default). +- **FPAKMAN_DOWNLOAD_ICONS**: Enables / disables app icons download. It may improve the application speed depending on how applications data are being retrieved. Use **0** (disable) or **1** (enable, default). +- **FPAKMAN_FLATPAK**: enables / disables flatpak usage. Use **0** (disable) or **1** (enabled, default) +- **FPAKMAN_SNAP**: enables / disables snap usage. Use **0** (disable) or **1** (enabled, default) +- **FPAKMAN_CHECK_PACKAGING_ONCE**: If the available supported packaging types should be checked ONLY once. It improves the application speed if enabled, but can generate errors if you uninstall any packaging technology while using it, and every time a supported packaging type is installed it will only be available after a restart. Use **0** (disable, default) or **1** (enable). +### How to improve the application performance +- If you don't care about a specific packaging technology and don't want **fpakman** to deal with it, just disable it via the specific argument or environment variable. For instance, if I don't care +about **snaps**, I can initialize the application setting "snap=0" (**fpakman --snap=0**). This will improve the application response time, since it won't need to do any verifications associated +with the technology that I don't care every time an action is executed. +- If you don't care about restarting **fpakman** every time a new supported packaging technology is installed, set "check-packaging-once=1" (**fpakman --check-packaging-once=1**). This can reduce the application response time up to 80% in some scenarios, since it won't need to recheck if the packaging type is available for every action you request. +- If you don't mind to see the applications icons, you can set "download-icons=0" (**fpakman --download-icons=0**). The application may have a slight response improvement, since it will reduce the parallelism within it. ### Roadmap - Support for other packaging technologies - Memory and performance improvements +- Design diff --git a/fpakman/__init__.py b/fpakman/__init__.py index 0dcd6ff2..514e9b52 100644 --- a/fpakman/__init__.py +++ b/fpakman/__init__.py @@ -1,2 +1,5 @@ -__version__ = '0.3.1' +__version__ = '0.4.0' __app_name__ = 'fpakman' + +import os +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/fpakman/app.py b/fpakman/app.py index 2ec1d15c..9a855b19 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -1,17 +1,24 @@ import argparse import os import sys +from pathlib import Path +import requests from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication from colorama import Fore from fpakman import __version__, __app_name__ from fpakman.core import resource +from fpakman.core.controller import GenericApplicationManager from fpakman.core.disk import DiskCacheLoaderFactory -from fpakman.core.structure import prepare_folder_structure +from fpakman.core.flatpak.constants import FLATPAK_CACHE_PATH +from fpakman.core.flatpak.controller import FlatpakManager +from fpakman.core.flatpak.model import FlatpakApplication +from fpakman.core.snap.constants import SNAP_CACHE_PATH +from fpakman.core.snap.controller import SnapManager +from fpakman.core.snap.model import SnapApplication from fpakman.util import util -from fpakman.core.controller import FlatpakManager, GenericApplicationManager from fpakman.util.cache import Cache from fpakman.util.memory import CacheCleaner from fpakman.view.qt.systray import TrayIcon @@ -31,8 +38,12 @@ parser.add_argument('-e', '--cache-exp', action="store", default=int(os.getenv(' parser.add_argument('-ie', '--icon-exp', action="store", default=int(os.getenv('FPAKMAN_ICON_EXPIRATION', 60 * 5)), type=int, help='cached icons expiration time in SECONDS. Default: %(default)s') parser.add_argument('-l', '--locale', action="store", default=os.getenv('FPAKMAN_LOCALE', 'en'), help='Translation key. Default: %(default)s') parser.add_argument('-i', '--check-interval', action="store", default=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60)), type=int, help='Updates check interval in SECONDS. Default: %(default)s') -parser.add_argument('-n', '--update-notification', action="store", default=os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1), type=int, help='Enables / disables system notifications for new updates. Default: %(default)s') -parser.add_argument('-dc', '--disk-cache', action="store", default=os.getenv('FPAKMAN_DISK_CACHE', 1), type=int, help='Enables / disables disk cache. When disk cache is enabled, the installed applications data are loaded faster. Default: %(default)s') +parser.add_argument('-n', '--update-notification', action="store", choices=[0, 1], default=os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1), type=int, help='Enables / disables system notifications for new updates. Default: %(default)s') +parser.add_argument('-dc', '--disk-cache', action="store", choices=[0, 1], default=os.getenv('FPAKMAN_DISK_CACHE', 1), type=int, help='Enables / disables disk cache. When disk cache is enabled, the installed applications data are loaded faster. Default: %(default)s') +parser.add_argument('-di', '--download-icons', action="store", choices=[0, 1], default=os.getenv('FPAKMAN_DOWNLOAD_ICONS', 1), type=int, help='Enables / disables app icons download. It may improve the application speed, depending of how applications data are retrieved by their extensions.') +parser.add_argument('--flatpak', action="store", default=os.getenv('FPAKMAN_FLATPAK', 1), choices=[0, 1], type=int, help='Enables / disables flatpak usage. Default: %(default)s') +parser.add_argument('--snap', action="store", default=os.getenv('FPAKMAN_SNAP', 1), choices=[0, 1], type=int, help='Enables / disables snap usage. Default: %(default)s') +parser.add_argument('-co', '--check-packaging-once', action="store", default=os.getenv('FPAKMAN_CHECK_PACKAGING_ONCE', 0), choices=[0, 1], type=int, help='If the available supported packaging types should be checked ONLY once. It improves the application speed if enabled, but can generate errors if you uninstall any packaging technology while using it, and every time a supported packaging type is installed it will only be available after a restart. Default: %(default)s') args = parser.parse_args() if args.cache_exp < 0: @@ -49,33 +60,66 @@ if args.check_interval <= 0: log_msg("'check-interval' set as '{}'. It must be >= 0. Aborting...".format(args.check_interval), Fore.RED) exit(1) +if not args.flatpak: + log_msg("'flatpak' is disabled.", Fore.YELLOW) + +if not args.snap: + log_msg("'snap' is disabled.", Fore.YELLOW) + if args.update_notification == 0: log_msg('updates notifications are disabled', Fore.YELLOW) +if args.download_icons == 0: + log_msg("'download-icons' is disabled", Fore.YELLOW) + +if args.check_packaging_once == 1: + log_msg("'check-packaging-once' is enabled", Fore.YELLOW) + locale_keys = util.get_locale_keys(args.locale) -prepare_folder_structure(disk_cache=args.disk_cache) - +http_session = requests.Session() caches = [] -flatpak_api_cache = Cache(expiration_time=args.cache_exp) -caches.append(flatpak_api_cache) +cache_map = {} +managers = [] + +if args.flatpak: + flatpak_api_cache = Cache(expiration_time=args.cache_exp) + cache_map[FlatpakApplication] = flatpak_api_cache + managers.append(FlatpakManager(app_args=args, api_cache=flatpak_api_cache, disk_cache=args.disk_cache, http_session=http_session)) + caches.append(flatpak_api_cache) + + if args.disk_cache: + Path(FLATPAK_CACHE_PATH).mkdir(parents=True, exist_ok=True) + +if args.snap: + snap_api_cache = Cache(expiration_time=args.cache_exp) + cache_map[SnapApplication] = snap_api_cache + managers.append(SnapManager(app_args=args, disk_cache=args.disk_cache, api_cache=snap_api_cache, http_session=http_session)) + caches.append(snap_api_cache) + + if args.disk_cache: + Path(SNAP_CACHE_PATH).mkdir(parents=True, exist_ok=True) icon_cache = Cache(expiration_time=args.icon_exp) caches.append(icon_cache) +disk_loader_factory = DiskCacheLoaderFactory(disk_cache=args.disk_cache, cache_map=cache_map) +manager = GenericApplicationManager(managers, disk_loader_factory=disk_loader_factory, app_args=args) + app = QApplication(sys.argv) +app.setApplicationName(__app_name__) +app.setApplicationVersion(__version__) app.setWindowIcon(QIcon(resource.get_path('img/logo.svg'))) -disk_loader_factory = DiskCacheLoaderFactory(disk_cache=args.disk_cache, flatpak_api_cache=flatpak_api_cache) -manager = GenericApplicationManager([FlatpakManager(flatpak_api_cache, disk_cache=args.disk_cache)], disk_loader_factory=disk_loader_factory) - - trayIcon = TrayIcon(locale_keys=locale_keys, manager=manager, check_interval=args.check_interval, icon_cache=icon_cache, disk_cache=args.disk_cache, - update_notification=bool(args.update_notification)) + update_notification=bool(args.update_notification), + download_icons=args.download_icons, + screen_size=app.primaryScreen().size()) + trayIcon.show() CacheCleaner(caches).start() diff --git a/fpakman/core/constants.py b/fpakman/core/constants.py index 687356af..1ac4c1b8 100644 --- a/fpakman/core/constants.py +++ b/fpakman/core/constants.py @@ -1,2 +1,6 @@ -FLATHUB_URL = 'https://flathub.org' -FLATHUB_API_URL = FLATHUB_URL + '/api/v1' +from pathlib import Path + +from fpakman import __app_name__ + +HOME_PATH = Path.home() +CACHE_PATH = '{}/.cache/{}'.format(HOME_PATH, __app_name__) diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 1b1c4029..eabf44a7 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -1,30 +1,29 @@ -import os -import shutil +import time from abc import ABC, abstractmethod -from datetime import datetime +from argparse import Namespace from threading import Lock -from typing import List +from typing import List, Dict -import requests - -from fpakman.core import flatpak, disk from fpakman.core.disk import DiskCacheLoader, DiskCacheLoaderFactory -from fpakman.core.model import FlatpakApplication, ApplicationData, ApplicationStatus, Application -from fpakman.util.cache import Cache +from fpakman.core.model import Application +from fpakman.core.system import FpakmanProcess class ApplicationManager(ABC): + def __init__(self, app_args): + self.app_args = app_args + @abstractmethod - def search(self, word: str, disk_loader: DiskCacheLoader) -> List[Application]: + def search(self, word: str, disk_loader: DiskCacheLoader) -> Dict[str, List[Application]]: pass @abstractmethod - def read_installed(self, disk_loader: DiskCacheLoader, keep_workers: bool) -> List[Application]: + def read_installed(self, disk_loader: DiskCacheLoader) -> List[Application]: pass @abstractmethod - def downgrade_app(self, app: Application, root_password: str): + def downgrade_app(self, app: Application, root_password: str) -> FpakmanProcess: pass @abstractmethod @@ -36,15 +35,15 @@ class ApplicationManager(ABC): pass @abstractmethod - def update_and_stream(self, app: Application): + def update_and_stream(self, app: Application) -> FpakmanProcess: pass @abstractmethod - def uninstall_and_stream(self, app: Application): + def uninstall_and_stream(self, app: Application, root_password: str) -> FpakmanProcess: pass @abstractmethod - def get_app_type(self) -> str: + def get_app_type(self): pass @abstractmethod @@ -56,7 +55,7 @@ class ApplicationManager(ABC): pass @abstractmethod - def install_and_stream(self, app: Application): + def install_and_stream(self, app: Application, root_password: str) -> FpakmanProcess: pass @abstractmethod @@ -67,194 +66,127 @@ class ApplicationManager(ABC): def cache_to_disk(self, app: Application, icon_bytes: bytes, only_icon: bool): pass + @abstractmethod + def requires_root(self, action: str, app: Application): + pass -from fpakman.core.worker import FlatpakAsyncDataLoaderManager + @abstractmethod + def refresh(self, app: Application, root_password: str) -> FpakmanProcess: + pass + + @abstractmethod + def prepare(self): + """ + Callback executed before the ApplicationManager starts to work. + :return: + """ + pass -class FlatpakManager(ApplicationManager): +class GenericApplicationManager(ApplicationManager): - def __init__(self, api_cache: Cache, disk_cache: bool): - self.api_cache = api_cache - self.http_session = requests.Session() + def __init__(self, managers: List[ApplicationManager], disk_loader_factory: DiskCacheLoaderFactory, app_args: Namespace): + super(ApplicationManager, self).__init__() + self.managers = managers + self.map = {m.get_app_type(): m for m in self.managers} + self.disk_loader_factory = disk_loader_factory self.lock_read = Lock() - self.disk_cache = disk_cache - self.async_data_loader = FlatpakAsyncDataLoaderManager(api_cache=self.api_cache, manager=self) - flatpak.set_default_remotes() + self._enabled_map = {} if app_args.check_packaging_once else None + self.prepare() - def get_app_type(self): - return FlatpakApplication + def _sort(self, apps: List[Application], word: str) -> List[Application]: - def _map_to_model(self, app: dict, installed: bool, disk_loader: DiskCacheLoader) -> FlatpakApplication: + exact_name_matches, contains_name_matches, desc_name_matches, others = [], [], [], [] - model = FlatpakApplication(arch=app.get('arch'), - branch=app.get('branch'), - origin=app.get('origin'), - runtime=app.get('runtime'), - ref=app.get('ref'), - commit=app.get('commit'), - base_data=ApplicationData(id=app.get('id'), - name=app.get('name'), - version=app.get('version'), - latest_version=app.get('latest_version'))) - model.installed = installed + for app in apps: + lower_name = app.base_data.name.lower() - api_data = self.api_cache.get(app['id']) - - expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow() - - if not api_data or expired_data: - if not app['runtime']: - disk_loader.add(model) # preloading cached disk data - model.status = ApplicationStatus.LOADING_DATA - self.async_data_loader.load(model) - - else: - model.fill_cached_data(api_data) - - return model - - def search(self, word: str, disk_loader: DiskCacheLoader) -> List[FlatpakApplication]: + if word == lower_name: + exact_name_matches.append(app) + elif word in lower_name: + contains_name_matches.append(app) + elif app.base_data.description and word in app.base_data.description.lower(): + desc_name_matches.append(app) + else: + others.append(app) res = [] - apps_found = flatpak.search(word) - - if apps_found: - already_read = set() - installed_apps = self.read_installed(disk_loader=disk_loader, keep_workers=True) - - if installed_apps: - for app_found in apps_found: - for installed_app in installed_apps: - if app_found['id'] == installed_app.base_data.id: - res.append(installed_app) - already_read.add(app_found['id']) - - for app_found in apps_found: - if app_found['id'] not in already_read: - res.append(self._map_to_model(app_found, False, disk_loader)) - - disk_loader.stop = True - disk_loader.join() - self.async_data_loader.stop_current_workers() + for app_list in (exact_name_matches, contains_name_matches, desc_name_matches, others): + app_list.sort(key=lambda a: a.base_data.name.lower()) + res.extend(app_list) return res - def read_installed(self, disk_loader: DiskCacheLoader, keep_workers: bool = False) -> List[FlatpakApplication]: + def _is_enabled(self, man: ApplicationManager): + if self._enabled_map is not None: + enabled = self._enabled_map.get(man.get_app_type()) + + if enabled is None: + enabled = man.is_enabled() + self._enabled_map[man.get_app_type()] = enabled + + return enabled + else: + return man.is_enabled() + + def search(self, word: str, disk_loader: DiskCacheLoader = None) -> Dict[str, List[Application]]: + self.lock_read.acquire() + try: + res = {'installed': [], 'new': []} + + norm_word = word.strip().lower() + disk_loader = None + + for man in self.managers: + if self._is_enabled(man): + if not disk_loader: + disk_loader = self.disk_loader_factory.new() + disk_loader.start() + + apps_found = man.search(word=norm_word, disk_loader=disk_loader) + res['installed'].extend(apps_found['installed']) + res['new'].extend(apps_found['new']) + + disk_loader.stop = True + disk_loader.join() + + for key in res: + res[key] = self._sort(res[key], norm_word) + + return res + finally: + self.lock_read.release() + + def read_installed(self, disk_loader: DiskCacheLoader = None) -> List[Application]: self.lock_read.acquire() try: - installed = flatpak.list_installed() + installed = [] - if installed: - installed.sort(key=lambda p: p['name'].lower()) + disk_loader = None - available_updates = flatpak.list_updates_as_str() + for man in self.managers: + if self._is_enabled(man): + if not disk_loader: + disk_loader = self.disk_loader_factory.new() + disk_loader.start() - models = [] + installed.extend(man.read_installed(disk_loader=disk_loader)) - for app in installed: - model = self._map_to_model(app, True, disk_loader) - model.update = app['id'] in available_updates - models.append(model) + disk_loader.stop = True + disk_loader.join() - if not keep_workers: - self.async_data_loader.stop_current_workers() - - return models - - return [] + installed.sort(key=lambda a: a.base_data.name.lower()) + return installed finally: self.lock_read.release() def can_downgrade(self): return True - def downgrade_app(self, app: FlatpakApplication, root_password: str): - - commits = flatpak.get_app_commits(app.ref, app.origin) - - commit_idx = commits.index(app.commit) - - # downgrade is not possible if the app current commit in the first one: - if commit_idx == len(commits) - 1: - return None - - return flatpak.downgrade_and_stream(app.ref, commits[commit_idx + 1], root_password) - - def clean_cache_for(self, app: FlatpakApplication): - self.api_cache.delete(app.base_data.id) - - if app.supports_disk_cache() and os.path.exists(app.get_disk_cache_path()): - shutil.rmtree(app.get_disk_cache_path()) - - def update_and_stream(self, app: FlatpakApplication): - return flatpak.update_and_stream(app.ref) - - def uninstall_and_stream(self, app: FlatpakApplication): - return flatpak.uninstall_and_stream(app.ref) - - def get_info(self, app: FlatpakApplication) -> dict: - app_info = flatpak.get_app_info_fields(app.base_data.id, app.branch) - app_info['name'] = app.base_data.name - app_info['type'] = 'runtime' if app.runtime else 'app' - app_info['description'] = app.base_data.description - return app_info - - def get_history(self, app: FlatpakApplication) -> List[dict]: - return flatpak.get_app_commits_data(app.ref, app.origin) - - def install_and_stream(self, app: FlatpakApplication): - return flatpak.install_and_stream(app.base_data.id, app.origin) - - def is_enabled(self): - return flatpak.is_installed() - - def cache_to_disk(self, app: FlatpakApplication, icon_bytes: bytes, only_icon: bool): - if self.disk_cache and app.supports_disk_cache(): - disk.save(app, icon_bytes, only_icon) - - -class GenericApplicationManager(ApplicationManager): - - def __init__(self, managers: List[ApplicationManager], disk_loader_factory: DiskCacheLoaderFactory): - self.managers = managers - self.map = {m.get_app_type(): m for m in self.managers} - self.disk_loader_factory = disk_loader_factory - - def search(self, word: str, disk_loader: DiskCacheLoader = None) -> List[Application]: - apps = [] - disk_loader = self.disk_loader_factory.new() - disk_loader.start() - - for man in self.managers: - if man.is_enabled(): - apps.extend(man.search(word, disk_loader)) - - disk_loader.stop = True - disk_loader.join() - return apps - - def read_installed(self, disk_loader: DiskCacheLoader = None, keep_workers: bool = False) -> List[Application]: - installed = [] - - disk_loader = self.disk_loader_factory.new() - disk_loader.start() - - for man in self.managers: - if man.is_enabled(): - installed.extend(man.read_installed(disk_loader=disk_loader, keep_workers=keep_workers)) - - disk_loader.stop = True - disk_loader.join() - - return installed - - def can_downgrade(self): - return True - - def downgrade_app(self, app: Application, root_password: str): + def downgrade_app(self, app: Application, root_password: str) -> FpakmanProcess: man = self._get_manager_for(app) if man and man.can_downgrade(): @@ -268,23 +200,23 @@ class GenericApplicationManager(ApplicationManager): if man: return man.clean_cache_for(app) - def update_and_stream(self, app: Application): + def update_and_stream(self, app: Application) -> FpakmanProcess: man = self._get_manager_for(app) if man: return man.update_and_stream(app) - def uninstall_and_stream(self, app: Application): + def uninstall_and_stream(self, app: Application, root_password: str) -> FpakmanProcess: man = self._get_manager_for(app) if man: - return man.uninstall_and_stream(app) + return man.uninstall_and_stream(app, root_password) - def install_and_stream(self, app: Application): + def install_and_stream(self, app: Application, root_password: str) -> FpakmanProcess: man = self._get_manager_for(app) if man: - return man.install_and_stream(app) + return man.install_and_stream(app, root_password) def get_info(self, app: Application): man = self._get_manager_for(app) @@ -306,7 +238,7 @@ class GenericApplicationManager(ApplicationManager): def _get_manager_for(self, app: Application) -> ApplicationManager: man = self.map[app.__class__] - return man if man and man.is_enabled() else None + return man if man and self._is_enabled(man) else None def cache_to_disk(self, app: Application, icon_bytes: bytes, only_icon: bool): if self.disk_loader_factory.disk_cache and app.supports_disk_cache(): @@ -314,3 +246,22 @@ class GenericApplicationManager(ApplicationManager): if man: return man.cache_to_disk(app, icon_bytes=icon_bytes, only_icon=only_icon) + + def requires_root(self, action: str, app: Application): + man = self._get_manager_for(app) + + if man: + return man.requires_root(action, app) + + def refresh(self, app: Application, root_password: str) -> FpakmanProcess: + man = self._get_manager_for(app) + + if man: + return man.refresh(app, root_password) + + def prepare(self): + + if self.managers: + for man in self.managers: + if self._is_enabled(man): + man.prepare() diff --git a/fpakman/core/disk.py b/fpakman/core/disk.py index 3097a686..f81b55e6 100644 --- a/fpakman/core/disk.py +++ b/fpakman/core/disk.py @@ -2,20 +2,20 @@ import json import os from pathlib import Path from threading import Thread, Lock -from typing import List +from typing import List, Dict -from fpakman.core.model import Application, FlatpakApplication +from fpakman.core.model import Application from fpakman.util.cache import Cache class DiskCacheLoader(Thread): - def __init__(self, enabled: bool, flatpak_api_cache: Cache, apps: List[Application] = []): + def __init__(self, enabled: bool, cache_map: Dict[type, Cache], apps: List[Application] = []): super(DiskCacheLoader, self).__init__(daemon=True) self.apps = apps self.stop = False self.lock = Lock() - self.flatpak_api_cache = flatpak_api_cache + self.cache_map = cache_map self.enabled = enabled def run(self): @@ -44,19 +44,17 @@ class DiskCacheLoader(Thread): with open(app.get_disk_data_path()) as f: cached_data = json.loads(f.read()) app.fill_cached_data(cached_data) - - if isinstance(app, FlatpakApplication): - self.flatpak_api_cache.add_non_existing(app.base_data.id, cached_data) + self.cache_map.get(app.__class__).add_non_existing(app.base_data.id, cached_data) class DiskCacheLoaderFactory: - def __init__(self, disk_cache: bool, flatpak_api_cache: Cache): + def __init__(self, disk_cache: bool, cache_map: Dict[type, Cache]): self.disk_cache = disk_cache - self.flatpak_api_cache = flatpak_api_cache + self.cache_map = cache_map def new(self): - return DiskCacheLoader(enabled=self.disk_cache, flatpak_api_cache=self.flatpak_api_cache) + return DiskCacheLoader(enabled=self.disk_cache, cache_map=self.cache_map) def save(app: Application, icon_bytes: bytes = None, only_icon: bool = False): @@ -65,12 +63,10 @@ def save(app: Application, icon_bytes: bytes = None, only_icon: bool = False): if not only_icon: Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True) + data = app.get_data_to_cache() - if isinstance(app, FlatpakApplication): - data = app.get_data_to_cache() - - with open(app.get_disk_data_path(), 'w+') as f: - f.write(json.dumps(data)) + with open(app.get_disk_data_path(), 'w+') as f: + f.write(json.dumps(data)) if icon_bytes: Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True) diff --git a/fpakman/core/flatpak/__init__.py b/fpakman/core/flatpak/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fpakman/core/flatpak/constants.py b/fpakman/core/flatpak/constants.py new file mode 100644 index 00000000..6f43f83b --- /dev/null +++ b/fpakman/core/flatpak/constants.py @@ -0,0 +1,5 @@ +from fpakman.core.constants import CACHE_PATH + +FLATHUB_URL = 'https://flathub.org' +FLATHUB_API_URL = FLATHUB_URL + '/api/v1' +FLATPAK_CACHE_PATH = '{}/flatpak/installed'.format(CACHE_PATH) diff --git a/fpakman/core/flatpak/controller.py b/fpakman/core/flatpak/controller.py new file mode 100644 index 00000000..e9be83f2 --- /dev/null +++ b/fpakman/core/flatpak/controller.py @@ -0,0 +1,150 @@ +import os +import shutil +from argparse import Namespace +from datetime import datetime +from typing import List, Dict + +from fpakman.core import disk +from fpakman.core.controller import ApplicationManager +from fpakman.core.disk import DiskCacheLoader +from fpakman.core.flatpak import flatpak +from fpakman.core.flatpak.model import FlatpakApplication +from fpakman.core.flatpak.worker import FlatpakAsyncDataLoader +from fpakman.core.model import ApplicationData +from fpakman.core.system import FpakmanProcess +from fpakman.util.cache import Cache + + +class FlatpakManager(ApplicationManager): + + def __init__(self, app_args: Namespace, api_cache: Cache, disk_cache: bool, http_session): + super(FlatpakManager, self).__init__(app_args=app_args) + self.api_cache = api_cache + self.http_session = http_session + self.disk_cache = disk_cache + + def get_app_type(self): + return FlatpakApplication + + def _map_to_model(self, app_json: dict, installed: bool, disk_loader: DiskCacheLoader) -> FlatpakApplication: + + app = FlatpakApplication(arch=app_json.get('arch'), + branch=app_json.get('branch'), + origin=app_json.get('origin'), + runtime=app_json.get('runtime'), + ref=app_json.get('ref'), + commit=app_json.get('commit'), + base_data=ApplicationData(id=app_json.get('id'), + name=app_json.get('name'), + version=app_json.get('version'), + latest_version=app_json.get('latest_version'))) + app.installed = installed + + api_data = self.api_cache.get(app_json['id']) + + expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow() + + if not api_data or expired_data: + if not app_json['runtime']: + disk_loader.add(app) # preloading cached disk data + FlatpakAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, http_session=self.http_session).start() + + else: + app.fill_cached_data(api_data) + + return app + + def search(self, word: str, disk_loader: DiskCacheLoader) -> Dict[str, List[FlatpakApplication]]: + + res = {'installed': [], 'new': []} + apps_found = flatpak.search(word) + + if apps_found: + already_read = set() + installed_apps = self.read_installed(disk_loader=disk_loader) + + if installed_apps: + for app_found in apps_found: + for installed_app in installed_apps: + if app_found['id'] == installed_app.base_data.id: + res['installed'].append(installed_app) + already_read.add(app_found['id']) + + if len(apps_found) > len(already_read): + for app_found in apps_found: + if app_found['id'] not in already_read: + res['new'].append(self._map_to_model(app_found, False, disk_loader)) + + return res + + def read_installed(self, disk_loader: DiskCacheLoader) -> List[FlatpakApplication]: + installed = flatpak.list_installed() + models = [] + + if installed: + + available_updates = flatpak.list_updates_as_str() + + for app_json in installed: + model = self._map_to_model(app_json=app_json, installed=True, disk_loader=disk_loader) + model.update = app_json['id'] in available_updates + models.append(model) + + return models + + def can_downgrade(self): + return True + + def downgrade_app(self, app: FlatpakApplication, root_password: str) -> FpakmanProcess: + + commits = flatpak.get_app_commits(app.ref, app.origin) + + commit_idx = commits.index(app.commit) + + # downgrade is not possible if the app current commit in the first one: + if commit_idx == len(commits) - 1: + return None + + return FpakmanProcess(subproc=flatpak.downgrade_and_stream(app.ref, commits[commit_idx + 1], root_password), + success_phrase='Updates complete.') + + def clean_cache_for(self, app: FlatpakApplication): + self.api_cache.delete(app.base_data.id) + + if app.supports_disk_cache() and os.path.exists(app.get_disk_cache_path()): + shutil.rmtree(app.get_disk_cache_path()) + + def update_and_stream(self, app: FlatpakApplication) -> FpakmanProcess: + return FpakmanProcess(subproc=flatpak.update_and_stream(app.ref)) + + def uninstall_and_stream(self, app: FlatpakApplication, root_password: str = None) -> FpakmanProcess: + return FpakmanProcess(subproc=flatpak.uninstall_and_stream(app.ref)) + + def get_info(self, app: FlatpakApplication) -> dict: + app_info = flatpak.get_app_info_fields(app.base_data.id, app.branch) + app_info['name'] = app.base_data.name + app_info['type'] = 'runtime' if app.runtime else 'app' + app_info['description'] = app.base_data.description + return app_info + + def get_history(self, app: FlatpakApplication) -> List[dict]: + return flatpak.get_app_commits_data(app.ref, app.origin) + + def install_and_stream(self, app: FlatpakApplication, root_password: str) -> FpakmanProcess: + return FpakmanProcess(subproc=flatpak.install_and_stream(app.base_data.id, app.origin)) + + def is_enabled(self): + return flatpak.is_installed() + + def cache_to_disk(self, app: FlatpakApplication, icon_bytes: bytes, only_icon: bool): + if self.disk_cache and app.supports_disk_cache(): + disk.save(app, icon_bytes, only_icon) + + def requires_root(self, action: str, app: FlatpakApplication): + return action == 'downgrade' + + def refresh(self, app: FlatpakApplication, root_password: str) -> FpakmanProcess: + raise Exception("'refresh' is not supported for {}".format(app.__class__.__name__)) + + def prepare(self): + flatpak.set_default_remotes() diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak/flatpak.py similarity index 93% rename from fpakman/core/flatpak.py rename to fpakman/core/flatpak/flatpak.py index 5c27bb2e..8de12ddf 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak/flatpak.py @@ -4,7 +4,6 @@ from typing import List from fpakman.core import system from fpakman.core.exception import NoInternetException -from fpakman.core.model import Application BASE_CMD = 'flatpak' @@ -115,16 +114,8 @@ def list_updates_as_str(): return system.run_cmd('{} update'.format(BASE_CMD), ignore_return_code=True) -def downgrade_and_stream(app_ref: str, commit: str, root_password: str): - - pwdin, downgrade_cmd = None, [] - - if root_password is not None: - downgrade_cmd.extend(['sudo', '-S']) - pwdin = system.stream_cmd(['echo', root_password]) - - downgrade_cmd.extend([BASE_CMD, 'update', '--commit={}'.format(commit), app_ref, '-y']) - return subprocess.Popen(downgrade_cmd, stdin=pwdin, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout +def downgrade_and_stream(app_ref: str, commit: str, root_password: str) -> subprocess.Popen: + return system.cmd_as_root([BASE_CMD, 'update', '--commit={}'.format(commit), app_ref, '-y'], root_password) def get_app_commits(app_ref: str, origin: str) -> List[str]: diff --git a/fpakman/core/flatpak/model.py b/fpakman/core/flatpak/model.py new file mode 100644 index 00000000..8562becd --- /dev/null +++ b/fpakman/core/flatpak/model.py @@ -0,0 +1,62 @@ +from fpakman.core import resource +from fpakman.core.flatpak.constants import FLATPAK_CACHE_PATH +from fpakman.core.model import Application, ApplicationData + + +class FlatpakApplication(Application): + + def __init__(self, base_data: ApplicationData, branch: str, arch: str, origin: str, runtime: bool, ref: str, commit: str): + super(FlatpakApplication, self).__init__(base_data=base_data) + self.ref = ref + self.branch = branch + self.arch = arch + self.origin = origin + self.runtime = runtime + self.commit = commit + + def is_incomplete(self): + return self.base_data.description is None and self.base_data.icon_url + + def has_history(self): + return True + + def has_info(self): + return self.installed + + def can_be_downgraded(self): + return True + + def can_be_uninstalled(self): + return True + + def can_be_installed(self): + return True + + def get_type(self): + return 'flatpak' + + def can_be_refreshed(self): + return False + + def get_default_icon_path(self): + return resource.get_path('img/flathub.svg') + + def is_library(self): + return self.runtime + + def get_disk_cache_path(self): + return '{}/{}'.format(FLATPAK_CACHE_PATH, self.base_data.id) + + def get_data_to_cache(self): + return { + 'description': self.base_data.description, + 'icon_url': self.base_data.icon_url, + 'latest_version': self.base_data.latest_version, + 'version': self.base_data.version, + 'name': self.base_data.name + } + + def fill_cached_data(self, data: dict): + for attr in self.get_data_to_cache().keys(): + if not getattr(self.base_data, attr): + setattr(self.base_data, attr, data[attr]) diff --git a/fpakman/core/flatpak/worker.py b/fpakman/core/flatpak/worker.py new file mode 100644 index 00000000..dc85e384 --- /dev/null +++ b/fpakman/core/flatpak/worker.py @@ -0,0 +1,84 @@ +import time +import traceback + +from colorama import Fore + +from fpakman.core.controller import ApplicationManager +from fpakman.core.flatpak.constants import FLATHUB_API_URL, FLATHUB_URL +from fpakman.core.flatpak.model import FlatpakApplication +from fpakman.core.model import ApplicationStatus +from fpakman.core.worker import AsyncDataLoader +from fpakman.util.cache import Cache + + +class FlatpakAsyncDataLoader(AsyncDataLoader): + + def __init__(self, app: FlatpakApplication, manager: ApplicationManager, http_session, api_cache: Cache, attempts: int = 2, timeout: int = 30): + super(FlatpakAsyncDataLoader, self).__init__(app=app) + self.manager = manager + self.http_session = http_session + self.attempts = attempts + self.api_cache = api_cache + self.to_persist = {} # stores all data loaded by the instance + self.timeout = timeout + + def run(self): + if self.app: + self.app.status = ApplicationStatus.LOADING_DATA + + for _ in range(0, self.attempts): + try: + res = self.http_session.get('{}/apps/{}'.format(FLATHUB_API_URL, self.app.base_data.id), timeout=self.timeout) + + if res.status_code == 200 and res.text: + data = res.json() + + if not self.app.base_data.version: + self.app.base_data.version = data.get('version') + + if not self.app.base_data.name: + self.app.base_data.name = data.get('name') + + self.app.base_data.description = data.get('description', data.get('summary', None)) + self.app.base_data.icon_url = data.get('iconMobileUrl', None) + self.app.base_data.latest_version = data.get('currentReleaseVersion', self.app.base_data.version) + + if not self.app.base_data.version and self.app.base_data.latest_version: + self.app.base_data.version = self.app.base_data.latest_version + + if self.app.base_data.icon_url and self.app.base_data.icon_url.startswith('/'): + self.app.base_data.icon_url = FLATHUB_URL + self.app.base_data.icon_url + + loaded_data = self.app.get_data_to_cache() + + self.api_cache.add(self.app.base_data.id, loaded_data) + self.app.status = ApplicationStatus.READY + + if self.app.supports_disk_cache(): + self.to_persist[self.app.base_data.id] = self.app + + break + else: + self.log_msg("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format( + self.app.base_data.id, res.status_code, res.content.decode()), Fore.RED) + except: + self.log_msg("Could not retrieve app data for id '{}'".format(self.app.base_data.id), Fore.YELLOW) + traceback.print_exc() + time.sleep(0.5) + + self.app.status = ApplicationStatus.READY + + def cache_to_disk(self): + if self.to_persist: + for app in self.to_persist.values(): + self.manager.cache_to_disk(app=app, icon_bytes=None, only_icon=False) + + self.to_persist = {} + + def clone(self) -> "FlatpakAsyncDataLoader": + return FlatpakAsyncDataLoader(manager=self.manager, + api_cache=self.api_cache, + attempts=self.attempts, + http_session=self.http_session, + timeout=self.timeout, + app=self.app) diff --git a/fpakman/core/model.py b/fpakman/core/model.py index c0a31113..df63b7d4 100644 --- a/fpakman/core/model.py +++ b/fpakman/core/model.py @@ -2,7 +2,6 @@ from abc import ABC, abstractmethod from enum import Enum from fpakman.core import resource -from fpakman.core.structure import flatpak_cache_path class ApplicationStatus(Enum): @@ -49,6 +48,10 @@ class Application(ABC): def can_be_installed(self): pass + @abstractmethod + def can_be_refreshed(self): + return self.installed + @abstractmethod def get_type(self): pass @@ -60,74 +63,12 @@ class Application(ABC): def is_library(self): pass - @abstractmethod - def supports_disk_cache(self): - pass - - @abstractmethod - def get_disk_cache_path(self): - pass - - @abstractmethod - def get_disk_icon_path(self): - pass - - @abstractmethod - def get_disk_data_path(self): - pass - - @abstractmethod - def get_data_to_cache(self): - pass - - @abstractmethod - def fill_cached_data(self, data: dict): - pass - - -class FlatpakApplication(Application): - - def __init__(self, base_data: ApplicationData, branch: str, arch: str, origin: str, runtime: bool, ref: str, commit: str): - super(FlatpakApplication, self).__init__(base_data=base_data) - self.ref = ref - self.branch = branch - self.arch = arch - self.origin = origin - self.runtime = runtime - self.commit = commit - - def is_incomplete(self): - return self.base_data.description is None and self.base_data.icon_url - - def has_history(self): - return True - - def has_info(self): - return True - - def can_be_downgraded(self): - return not self.runtime - - def can_be_uninstalled(self): - return True - - def can_be_installed(self): - return True - - def get_type(self): - return 'flatpak' - - def get_default_icon_path(self): - return resource.get_path('img/flathub.svg') - - def is_library(self): - return self.runtime - def supports_disk_cache(self): return self.installed and not self.is_library() + @abstractmethod def get_disk_cache_path(self): - return '{}/{}'.format(flatpak_cache_path, self.base_data.id) + pass def get_disk_icon_path(self): return '{}/icon.png'.format(self.get_disk_cache_path()) @@ -135,16 +76,13 @@ class FlatpakApplication(Application): def get_disk_data_path(self): return '{}/data.json'.format(self.get_disk_cache_path()) + @abstractmethod def get_data_to_cache(self): - return { - 'description': self.base_data.description, - 'icon_url': self.base_data.icon_url, - 'latest_version': self.base_data.latest_version, - 'version': self.base_data.version, - 'name': self.base_data.name - } + pass + @abstractmethod def fill_cached_data(self, data: dict): - for attr in self.get_data_to_cache().keys(): - if not getattr(self.base_data, attr): - setattr(self.base_data, attr, data[attr]) + pass + + def __str__(self): + return '{} (id={}, name={})'.format(self.__class__.__name__, self.base_data.id, self.base_data.name) diff --git a/fpakman/core/resource.py b/fpakman/core/resource.py index e9378c6d..9cde9f8c 100755 --- a/fpakman/core/resource.py +++ b/fpakman/core/resource.py @@ -1,7 +1,6 @@ -import os -app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +from fpakman import ROOT_DIR def get_path(resource_path): - return app_dir + '/resources/' + resource_path + return ROOT_DIR + '/resources/' + resource_path diff --git a/fpakman/core/snap/__init__.py b/fpakman/core/snap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fpakman/core/snap/constants.py b/fpakman/core/snap/constants.py new file mode 100644 index 00000000..10a2c460 --- /dev/null +++ b/fpakman/core/snap/constants.py @@ -0,0 +1,4 @@ +from fpakman.core.constants import CACHE_PATH + +SNAP_STORE_URL = 'https://snapcraft.io' +SNAP_CACHE_PATH = '{}/snap/installed'.format(CACHE_PATH) diff --git a/fpakman/core/snap/controller.py b/fpakman/core/snap/controller.py new file mode 100644 index 00000000..3c495064 --- /dev/null +++ b/fpakman/core/snap/controller.py @@ -0,0 +1,133 @@ +import os +import shutil +from argparse import Namespace +from datetime import datetime +from typing import Dict, List + +from fpakman.core import disk +from fpakman.core.controller import ApplicationManager +from fpakman.core.disk import DiskCacheLoader +from fpakman.core.model import ApplicationData, Application +from fpakman.core.snap import snap +from fpakman.core.snap.model import SnapApplication +from fpakman.core.snap.worker import SnapAsyncDataLoader +from fpakman.core.system import FpakmanProcess +from fpakman.util.cache import Cache + + +class SnapManager(ApplicationManager): + + def __init__(self, app_args: Namespace, api_cache: Cache, disk_cache: bool, http_session): + super(SnapManager, self).__init__(app_args=app_args) + self.api_cache = api_cache + self.http_session = http_session + self.disk_cache = disk_cache + + def map_json(self, app_json: dict, installed: bool, disk_loader: DiskCacheLoader) -> SnapApplication: + app = SnapApplication(publisher=app_json.get('publisher'), + rev=app_json.get('rev'), + notes=app_json.get('notes'), + app_type=app_json.get('type'), + base_data=ApplicationData(id=app_json.get('name'), + name=app_json.get('name'), + version=app_json.get('version'), + latest_version=app_json.get('version'), + description=app_json.get('description') + )) + + if app.publisher: + app.publisher = app.publisher.replace('*', '') + + app.installed = installed + + api_data = self.api_cache.get(app_json['name']) + expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow() + + if (not api_data or expired_data) and not app.is_library(): + if disk_loader and app.installed: + disk_loader.add(app) + + SnapAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, http_session=self.http_session, download_icons=self.app_args.download_icons).start() + else: + app.fill_cached_data(api_data) + + return app + + def search(self, word: str, disk_loader: DiskCacheLoader) -> Dict[str, List[SnapApplication]]: + installed = self.read_installed(disk_loader) + + res = {'installed': [], 'new': []} + + for app_json in snap.search(word): + + already_installed = None + + if installed: + already_installed = [i for i in installed if i.base_data.id == app_json.get('name')] + already_installed = already_installed[0] if already_installed else None + + if already_installed: + res['installed'].append(already_installed) + else: + res['new'].append(self.map_json(app_json, installed=False, disk_loader=disk_loader)) + + return res + + def read_installed(self, disk_loader: DiskCacheLoader) -> List[SnapApplication]: + res = [self.map_json(app_json, installed=True, disk_loader=disk_loader) for app_json in snap.read_installed()] + return res + + def downgrade_app(self, app: Application, root_password: str) -> FpakmanProcess: + return FpakmanProcess(subproc=snap.downgrade_and_stream(app.base_data.name, root_password), wrong_error_phrase=None) + + def clean_cache_for(self, app: SnapApplication): + self.api_cache.delete(app.base_data.name) + + if app.supports_disk_cache() and os.path.exists(app.get_disk_cache_path()): + shutil.rmtree(app.get_disk_cache_path()) + + def can_downgrade(self): + return True + + def update_and_stream(self, app: SnapApplication) -> FpakmanProcess: + pass + + def uninstall_and_stream(self, app: SnapApplication, root_password: str) -> FpakmanProcess: + return FpakmanProcess(subproc=snap.uninstall_and_stream(app.base_data.name, root_password)) + + def get_app_type(self): + return SnapApplication + + def get_info(self, app: SnapApplication) -> dict: + info = snap.get_info(app.base_data.name, attrs=('license', 'contact', 'commands', 'snap-id', 'tracking', 'installed')) + info['description'] = app.base_data.description + info['publisher'] = app.publisher + info['revision'] = app.rev + info['name'] = app.base_data.name + + if info.get('commands'): + info['commands'] = ' '.join(info['commands']) + + return info + + def get_history(self, app: Application) -> List[dict]: + return [] + + def install_and_stream(self, app: SnapApplication, root_password: str) -> FpakmanProcess: + return FpakmanProcess(subproc=snap.install_and_stream(app.base_data.name, app.install_cmd, root_password)) + + def is_enabled(self) -> bool: + return snap.is_installed() + + def cache_to_disk(self, app: Application, icon_bytes: bytes, only_icon: bool): + if self.disk_cache and app.supports_disk_cache(): + disk.save(app, icon_bytes, only_icon) + + def requires_root(self, action: str, app: SnapApplication): + return action != 'search' + + def refresh(self, app: SnapApplication, root_password: str) -> FpakmanProcess: + return FpakmanProcess(subproc=snap.refresh_and_stream(app.base_data.name, root_password)) + + def prepare(self): + pass diff --git a/fpakman/core/snap/model.py b/fpakman/core/snap/model.py new file mode 100644 index 00000000..2b8c32dd --- /dev/null +++ b/fpakman/core/snap/model.py @@ -0,0 +1,60 @@ +from fpakman.core import resource +from fpakman.core.model import Application, ApplicationData +from fpakman.core.snap.constants import SNAP_CACHE_PATH + + +class SnapApplication(Application): + + def __init__(self, base_data: ApplicationData, publisher: str, rev: str, notes: str, app_type: str, install_cmd: str = None): + super(SnapApplication, self).__init__(base_data=base_data) + self.publisher = publisher + self.rev = rev + self.notes = notes + self.type = app_type + self.install_cmd = install_cmd + + def has_history(self): + return False + + def has_info(self): + return True + + def can_be_downgraded(self): + return self.installed + + def can_be_uninstalled(self): + return self.installed + + def can_be_installed(self): + return not self.installed + + def can_be_refreshed(self): + return self.installed + + def get_type(self): + return 'snap' + + def get_default_icon_path(self): + return resource.get_path('img/snapcraft.png') + + def is_library(self): + return self.type in ('core', 'base', 'snapd') or self.base_data.name.startswith('gtk-') or self.base_data.name.startswith('gnome-') + + def get_disk_cache_path(self): + return '{}/{}'.format(SNAP_CACHE_PATH, self.base_data.name) + + def get_data_to_cache(self): + return { + "icon_url": self.base_data.icon_url, + 'install_cmd': self.install_cmd, + 'description': self.base_data.description + } + + def fill_cached_data(self, data: dict): + if data: + for base_attr in ('icon_url', 'description'): + if data.get(base_attr): + setattr(self.base_data, base_attr, data[base_attr]) + + if data.get('install_cmd'): + self.install_cmd = data['install_cmd'] diff --git a/fpakman/core/snap/snap.py b/fpakman/core/snap/snap.py new file mode 100644 index 00000000..ea2da302 --- /dev/null +++ b/fpakman/core/snap/snap.py @@ -0,0 +1,145 @@ +import re +import subprocess +from typing import List + +import requests +from bs4 import BeautifulSoup + +from fpakman.core import system +from fpakman.core.snap.constants import SNAP_STORE_URL + +BASE_CMD = 'snap' + + +def is_installed(): + version = get_snapd_version() + return False if version is None else True + + +def get_version(): + res = system.run_cmd('{} --version'.format(BASE_CMD), print_error=False) + return res.split('\n')[0].split(' ')[-1].strip() if res else None + + +def get_snapd_version(): + res = system.run_cmd('{} --version'.format(BASE_CMD), print_error=False) + + if not res: + return None + else: + lines = res.split('\n') + + if lines and len(lines) >= 2: + version = lines[1].split(' ')[-1].strip() + return version if version and version.lower() != 'unavailable' else None + else: + return None + + +def app_str_to_json(app: str) -> dict: + app_data = [word for word in app.split(' ') if word] + app_json = { + 'name': app_data[0], + 'version': app_data[1], + 'rev': app_data[2], + 'tracking': app_data[3], + 'publisher': app_data[4], + 'notes': app_data[5] + } + + app_json.update(get_info(app_json['name'], ('summary', 'type', 'description'))) + return app_json + + +def get_info(app_name: str, attrs: tuple = None): + full_info_lines = system.run_cmd('{} info {}'.format(BASE_CMD, app_name)) + + data = {} + + if full_info_lines: + re_attrs = r'\w+' if not attrs else '|'.join(attrs) + info_map = re.findall(r'({}):\s+(.+)'.format(re_attrs), full_info_lines) + + for info in info_map: + data[info[0]] = info[1].strip() + + if not attrs or 'description' in attrs: + desc = re.findall(r'\|\n+((\s+.+\n+)+)', full_info_lines) + data['description'] = ''.join([w.strip() for w in desc[0][0].strip().split('\n')]).replace('.', '.\n') if desc else None + + if not attrs or 'commands' in attrs: + commands = re.findall(r'commands:\s*\n*((\s+-\s.+\s*\n)+)', full_info_lines) + data['commands'] = commands[0][0].replace('-', '').strip().split('\n') if commands else None + + return data + + +def read_installed() -> List[dict]: + res = system.run_cmd('{} list'.format(BASE_CMD), print_error=False) + + apps = [] + + if res and len(res) > 0: + lines = res.split('\n') + + if not lines[0].startswith('error'): + for idx, app_str in enumerate(lines): + if idx > 0 and app_str: + apps.append(app_str_to_json(app_str)) + + return apps + + +def search(word: str) -> List[dict]: + apps = [] + + res = system.run_cmd('{} find "{}"'.format(BASE_CMD, word), print_error=False) + + if res: + res = res.split('\n') + + if not res[0].startswith('No matching'): + for idx, app_str in enumerate(res): + if idx > 0 and app_str: + app_data = [word for word in app_str.split(' ') if word] + apps.append({ + 'name': app_data[0], + 'version': app_data[1], + 'publisher': app_data[2], + 'notes': app_data[3] if app_data[3] != '-' else None, + 'summary': app_data[4] if len(app_data) == 5 else '', + 'rev': None, + 'tracking': None, + 'type': None + }) + + return apps + + +def uninstall_and_stream(app_name: str, root_password: str): + return system.cmd_as_root([BASE_CMD, 'remove', app_name], root_password) + + +def install_and_stream(app_name: str, custom_install_cmd: str, root_password: str) -> subprocess.Popen: + + install_cmd = [BASE_CMD, 'install', app_name] # default + + if custom_install_cmd: + install_cmd = custom_install_cmd.split(' ') + else: # tries to retrieve the snapstore proper installation command + res = requests.get('{}/{}'.format(SNAP_STORE_URL, app_name)) + + if res.status_code == 200: + soup = BeautifulSoup(res.text, 'html.parser') + input_install_cmd = soup.find("input", {"id": "snap-install"}) + install_cmd = input_install_cmd.get("value").strip().split(' ') + + return system.cmd_as_root(install_cmd, root_password) + + +def downgrade_and_stream(app_name: str, root_password: str) -> subprocess.Popen: + return system.cmd_as_root([BASE_CMD, 'revert', app_name], root_password) + + +def refresh_and_stream(app_name: str, root_password: str) -> subprocess.Popen: + return system.cmd_as_root([BASE_CMD, 'refresh', app_name], root_password) diff --git a/fpakman/core/snap/worker.py b/fpakman/core/snap/worker.py new file mode 100644 index 00000000..7c9d775e --- /dev/null +++ b/fpakman/core/snap/worker.py @@ -0,0 +1,87 @@ +import time +import traceback + +from bs4 import BeautifulSoup +from colorama import Fore + +from fpakman.core.controller import ApplicationManager +from fpakman.core.model import ApplicationStatus +from fpakman.core.snap import snap +from fpakman.core.snap.constants import SNAP_STORE_URL +from fpakman.core.snap.model import SnapApplication +from fpakman.core.worker import AsyncDataLoader +from fpakman.util.cache import Cache + + +class SnapAsyncDataLoader(AsyncDataLoader): + + def __init__(self, app: SnapApplication, manager: ApplicationManager, http_session, api_cache: Cache, download_icons: bool, attempts: int = 2, timeout: int = 30): + super(SnapAsyncDataLoader, self).__init__(app=app) + self.manager = manager + self.http_session = http_session + self.attempts = attempts + self.api_cache = api_cache + self.timeout = timeout + self.to_persist = {} # stores all data loaded by the instance + self.download_icons = download_icons + + def run(self): + if self.app: + self.app.status = ApplicationStatus.LOADING_DATA + if not self.app.base_data.description: + self.app.base_data.description = snap.get_info(self.app.base_data.name, ('description',)).get('description') + + for _ in range(0, self.attempts): + try: + res = self.http_session.get('{}/{}'.format(SNAP_STORE_URL, self.app.base_data.name), + timeout=self.timeout) + + if res.status_code == 200 and res.text: + soup = BeautifulSoup(res.text, 'html.parser') + input_install_cmd = soup.find("input", {"id": "snap-install"}) + + api_data = { + 'install_cmd': input_install_cmd.get("value").strip(), + 'description': self.app.base_data.description + } + + img_icon = None + + if self.download_icons: + img_icon = soup.find('img', {"class": "p-snap-heading__icon"}) + + if img_icon and img_icon.get("src") and 'snapcraft-missing-icon' not in img_icon.get('src'): + api_data['icon_url'] = img_icon.get("src") + + self.api_cache.add(self.app.base_data.id, api_data) + self.app.install_cmd = api_data['install_cmd'] + self.app.base_data.icon_url = api_data.get('icon_url') + self.app.status = ApplicationStatus.READY + + if self.app.supports_disk_cache(): + self.to_persist[self.app.base_data.id] = self.app + + break + else: + self.log_msg("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(self.app.base_data.id, res.status_code, res.content.decode()), Fore.RED) + except: + self.log_msg("Could not retrieve app data for id '{}'".format(app.base_data.id), Fore.YELLOW) + traceback.print_exc() + time.sleep(0.5) + + self.app.status = ApplicationStatus.READY + + def clone(self) -> "SnapAsyncDataLoader": + return SnapAsyncDataLoader(manager=self.manager, + api_cache=self.api_cache, + attempts=self.attempts, + http_session=self.http_session, + timeout=self.timeout, + app=self.app) + + def cache_to_disk(self): + if self.to_persist: + for app in self.to_persist.values(): + self.manager.cache_to_disk(app=app, icon_bytes=None, only_icon=False) + + self.to_persist = {} diff --git a/fpakman/core/structure.py b/fpakman/core/structure.py deleted file mode 100644 index fd90cf53..00000000 --- a/fpakman/core/structure.py +++ /dev/null @@ -1,12 +0,0 @@ -from pathlib import Path - -from fpakman import __app_name__ - -home_path = Path.home() -cache_path = '{}/.cache/{}'.format(home_path, __app_name__) -flatpak_cache_path = '{}/flatpak/installed'.format(cache_path) - - -def prepare_folder_structure(disk_cache: bool): - if disk_cache: - Path(flatpak_cache_path).mkdir(parents=True, exist_ok=True) diff --git a/fpakman/core/system.py b/fpakman/core/system.py index 38edbaba..24564e83 100644 --- a/fpakman/core/system.py +++ b/fpakman/core/system.py @@ -2,9 +2,18 @@ import os import subprocess from typing import List +from fpakman import __app_name__ from fpakman.core import resource +class FpakmanProcess: + + def __init__(self, subproc: subprocess.Popen, success_phrase: str = None, wrong_error_phrase: str = '[sudo] password for'): + self.subproc = subproc + self.success_pgrase = success_phrase + self.wrong_error_phrase = wrong_error_phrase + + def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False, print_error: bool = True) -> str: args = { "shell": True, @@ -28,4 +37,15 @@ def cmd_to_subprocess(cmd: List[str]): def notify_user(msg: str, icon_path: str = resource.get_path('img/logo.svg')): - os.system("notify-send {} '{}'".format("-i {}".format(icon_path) if icon_path else '', msg)) + os.system("notify-send -a {} {} '{}'".format(__app_name__, "-i {}".format(icon_path) if icon_path else '', msg)) + + +def cmd_as_root(cmd: List[str], root_password: str) -> subprocess.Popen: + pwdin, final_cmd = None, [] + + if root_password is not None: + final_cmd.extend(['sudo', '-S']) + pwdin = stream_cmd(['echo', root_password]) + + final_cmd.extend(cmd) + return subprocess.Popen(final_cmd, stdin=pwdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE) diff --git a/fpakman/core/worker.py b/fpakman/core/worker.py index f9648a37..ad0a64f3 100644 --- a/fpakman/core/worker.py +++ b/fpakman/core/worker.py @@ -1,29 +1,17 @@ -import traceback from io import StringIO from threading import Thread -from typing import List -import requests from colorama import Fore -from fpakman.core.constants import FLATHUB_API_URL, FLATHUB_URL -from fpakman.core.controller import ApplicationManager -from fpakman.core.model import FlatpakApplication, ApplicationStatus -from fpakman.util.cache import Cache +from fpakman.core.model import Application -class FlatpakAsyncDataLoader(Thread): +class AsyncDataLoader(Thread): - def __init__(self, manager: ApplicationManager, http_session, api_cache: Cache, attempts: int = 3, apps: List[FlatpakApplication] = []): - super(FlatpakAsyncDataLoader, self).__init__(daemon=True) - self.apps = apps - self.http_session = http_session - self.attempts = attempts - self.api_cache = api_cache + def __init__(self, app: Application): + super(AsyncDataLoader, self).__init__(daemon=True) self.id_ = '{}#{}'.format(self.__class__.__name__, id(self)) - self.stop = False - self.to_persist = {} # stores all data loaded by the instance - self.manager = manager + self.app = app def log_msg(self, msg: str, color: int = None): final_msg = StringIO() @@ -41,100 +29,3 @@ class FlatpakAsyncDataLoader(Thread): final_msg.seek(0) print(final_msg.read()) - - def run(self): - while True: - if self.apps: - app = self.apps[0] - app.status = ApplicationStatus.LOADING_DATA - - for _ in range(0, self.attempts): - try: - res = self.http_session.get('{}/apps/{}'.format(FLATHUB_API_URL, app.base_data.id), timeout=30) - - if res.status_code == 200 and res.text: - data = res.json() - - if not app.base_data.version: - app.base_data.version = data.get('version') - - if not app.base_data.name: - app.base_data.name = data.get('name') - - app.base_data.description = data.get('description', data.get('summary', None)) - app.base_data.icon_url = data.get('iconMobileUrl', None) - app.base_data.latest_version = data.get('currentReleaseVersion', app.base_data.version) - - if not app.base_data.version and app.base_data.latest_version: - app.base_data.version = app.base_data.latest_version - - if app.base_data.icon_url and app.base_data.icon_url.startswith('/'): - app.base_data.icon_url = FLATHUB_URL + app.base_data.icon_url - - loaded_data = app.get_data_to_cache() - - self.api_cache.add(app.base_data.id, loaded_data) - app.status = ApplicationStatus.READY - - if app.supports_disk_cache(): - self.to_persist[app.base_data.id] = app - - break - else: - self.log_msg("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(app.base_data.id, res.status_code, res.content.decode()), Fore.RED) - except: - self.log_msg("Could not retrieve app data for id '{}'".format(app.base_data.id), Fore.YELLOW) - traceback.print_exc() - - if self.apps: - del self.apps[0] - - elif self.stop: - self.cache_to_disk() - break # stop working - - def add(self, app: FlatpakApplication): - self.apps.append(app) - - def current_load(self): - return len(self.apps) - - def cache_to_disk(self): - - if self.to_persist: - for app in self.to_persist.values(): - self.manager.cache_to_disk(app=app, icon_bytes=None, only_icon=False) - - self.to_persist = {} - - -class FlatpakAsyncDataLoaderManager: - - def __init__(self, manager: ApplicationManager, api_cache: Cache, worker_load: int = 1, workers: List[FlatpakAsyncDataLoader] = []): - self.worker_load = worker_load - self.current_workers = workers - self.http_session = requests.Session() - self.api_cache = api_cache - self.manager = manager - - def load(self, app: FlatpakApplication): - - available_workers = [w for w in self.current_workers if w.current_load() < self.worker_load] - - if available_workers: - worker = available_workers[0] - else: # new worker - worker = FlatpakAsyncDataLoader(http_session=self.http_session, - api_cache=self.api_cache, - manager=self.manager) - worker.start() - self.current_workers.append(worker) - - worker.add(app) - - def stop_current_workers(self): - - for w in self.current_workers: - w.stop = True - - self.current_workers = [] diff --git a/fpakman/resources/img/exclamation.svg b/fpakman/resources/img/exclamation.svg new file mode 100755 index 00000000..cc8952cd --- /dev/null +++ b/fpakman/resources/img/exclamation.svg @@ -0,0 +1,144 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/snapcraft.png b/fpakman/resources/img/snapcraft.png new file mode 100755 index 0000000000000000000000000000000000000000..c0b792bf653d1871db4f56b739654be2fa783bc1 GIT binary patch literal 1138 zcmV-&1daQNP)hu$2}0wG!CRCNOmx~>G-*VQFDP_M3(*HL5k&0F znQ}9VL@`Ji+9?FYU`jx_^+t<4@PLM!7*a2gG$3Fv!9p2^bIx8q%oN+wDuwlS_F3ou z*Ixg>*FG%yZXHfTTq3m_=^f#I&vzO&kO9oak|X8Kn@*^wrmLgVFVCNMGwGwf8(2QG z^o|GwAlg_xK`d!j)g(&X7=MRl2QC`VxY68@*OR>Z#~RwtU*zM$cZNm)5Is=+pola8 zIRL6!uc}*6Z4%4++fBNOfNbR6(e~e`>mH^u5V)E|W`DSCQzFyzUICs}rNcF5rgaNJu!yC5DgY?YD{e8qS)i&-gE>%C z{b8=V)mC$8OhrT8h|1u@zqZn1+G#tl zH@?4k)r{Glr#_nxKxWks$3|8)FLG5otidd{te}K&U1eG{y`*aDP>!ctG!nR;0ykyR z+~TfvXKLA=GZlcAH=Y4t+~z|Gxt#+OJy1PMkX5RBT13VoTvXL2HCp2Yi`u^UWjF1u z7j9a;cMQ6NNPSgGllu4rz-tJT#F80`cB<+YH+duG1dH0%{Je8e@c$%;=7v&AS~6Wk zW~G-$3vxVp<;LFS1*QI))4RBQu6(!X0kznZZCr5YD-PTDozi8+Li)uc{2^JBqtsIU@frw01)e7~^;z%&mwff{9 zuC=w{}% z#xn&9fuo-5m6qofUq7ZDocp77$l!elR=m6*)o!C>fh^;iIm`0r-2m8Gzw_ply{icC zi`n6QvB^$-JfrsrzuUix-r)f)=6dStjqI-^dY6^|1A$54!(z~t)&Kwi07*qoM6N<$ Ef*XJx1^@s6 literal 0 HcmV?d00001 diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 60f3c90f..986a7860 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -9,7 +9,8 @@ manage_window.apps_table.row.actions.uninstall.popup.body=Remove {} ? manage_window.apps_table.row.actions.downgrade=Downgrade manage_window.apps_table.row.actions.downgrade.popup.body=Do you really want to downgrade {} ? manage_window.apps_table.row.actions.install=Install -manage_window.apps_table.upgrade_toggle.tooltip=Click here to check or uncheck the update +manage_window.apps_table.row.actions.refresh=Refresh +manage_window.apps_table.upgrade_toggle.tooltip=There is an update for this application. Click here to check or uncheck the update manage_window.checkbox.only_apps=Apps window_manage.input_search.placeholder=Search window_manage.input_search.tooltip=Type and press ENTER to search for applications @@ -23,7 +24,7 @@ manage_window.status.history=Retrieving history manage_window.status.searching=Searching manage_window.status.installing=Installing manage_window.bt.refresh.tooltip=Reload the data about installed applications -manage_window.bt.upgrade.tooltip=Upgrade all selected applications +manage_window.bt.upgrade.tooltip=Upgrade all checked applications manage_window.checkbox.show_details=Show details popup.root.title=Requires root privileges popup.root.password=Password @@ -41,6 +42,11 @@ tray.action.exit=Exit tray.action.about=About tray.action.refreshing=Refreshing notification.new_updates=Updates +notification.update_selected.success=app(s) updated successfully +notification.update_selected.failed=Update failed +notification.install.failed=installation failed +notification.uninstall.failed=uninstallation failed +notification.downgrade.failed=Failed to downgrade flatpak.info.arch=arch flatpak.info.branch=branch flatpak.info.collection=collection @@ -60,7 +66,15 @@ flatpak.info.sdk=sdk flatpak.info.subject=subject flatpak.info.type=type flatpak.info.version=version -about.info.desc=Non-official Flatpak application management graphical interface +snap.info.commands=commands +snap.info.contact=contact +snap.info.description=description +snap.info.license=license +snap.info.revision=revision +snap.info.tracking=tracking +snap.info.installed=installed +snap.info.publisher=publisher +about.info.desc=Non-official Flatpak / Snap application management graphical interface about.info.link=More information at about.info.license=Free license about.info.rate=If this tool is useful for you, give it a star on Github to keep it up @@ -74,5 +88,14 @@ latest_version=latest version description=description type=type installed=installed +uninstalled=not installed +downgraded=downgraded others=others internet.required=Internet connection is required +license.unset=unset +updates=updates +version.installed=installed version +version.latest=latest version +version.installed_outdated=the installed version is outdated +version.unknown=not informed +app.name=application name \ No newline at end of file diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 97e7e9b9..66bac0a9 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -10,6 +10,7 @@ manage_window.apps_table.row.actions.uninstall.popup.body=¿Eliminar {}? manage_window.apps_table.row.actions.downgrade=Revertir versión manage_window.apps_table.row.actions.downgrade.popup.body=¿Realmente quieres revertir la versión actual de {}? manage_window.apps_table.row.actions.install=Instalar +manage_window.apps_table.row.actions.refresh=Actualizar manage_window.apps_table.upgrade_toggle.tooltip=Haces clic aqui para marcar o desmarcar la actualización manage_window.checkbox.only_apps=Aplicativos window_manage.input_search.placeholder=Buscar @@ -24,7 +25,7 @@ manage_window.status.history=Obteniendo la historia manage_window.status.searching=Buscando manage_window.status.installing=Instalando manage_window.bt.refresh.tooltip=Recarga los datos sobre los aplicativos instalados -manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos seleccionados +manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos marcados manage_window.checkbox.show_details=Mostrar detalles popup.root.title=Requiere privilegios de root popup.root.password=Contraseña @@ -42,6 +43,11 @@ tray.action.exit=Salir tray.action.about=Sobre tray.action.refreshing=Recargando notification.new_updates=Actualizaciones +notification.update_selected.success=aplicativo(s) actualizado(s) con éxito +notification.update_selected.failed=La actualización falló +notification.install.failed=la instalación ha fallado +notification.uninstall.failed=la desinstalación ha fallado +notification.downgrade.failed=Error al revertir la versión flatpak.info.arch=arquitectura flatpak.info.branch=rama flatpak.info.collection=colección @@ -61,7 +67,15 @@ flatpak.info.sdk=sdk flatpak.info.subject=tema flatpak.info.type=tipo flatpak.info.version=versión -about.info.desc=Interfaz grafica no oficial para administración de aplicativos Flatpak +snap.info.commands=comandos +snap.info.contact=contacto +snap.info.description=descripción +snap.info.license=licencia +snap.info.revision=revisión +snap.info.tracking=tracking +snap.info.installed=instalado +snap.info.publisher=publicador +about.info.desc=Interfaz grafica no oficial para administración de aplicativos Flatpak / Snap about.info.link=Mas informaciones en about.info.license=Licencia gratuita about.info.rate=Si esta herramienta es útil para ti, dale una estrella en Github para mantenerla @@ -75,5 +89,14 @@ latest_version=ultima versión description=descripción type=tipo installed=instalado +uninstalled=no instalado +downgraded=versión revertida others=otros internet.required=Se requiere conexión a internet +license.unset=No está definida +updates=actualizaciones +version.installed=versión instalada +version.latest=versión más reciente +version.installed_outdated=la versión instalada está desactualizada +version.unknown=versión no informada +app.name=nombre del aplicativo \ No newline at end of file diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index cd4212ce..63b5841b 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -10,7 +10,8 @@ manage_window.apps_table.row.actions.uninstall.popup.body=Remover {}? manage_window.apps_table.row.actions.downgrade=Reverter versão manage_window.apps_table.row.actions.downgrade.popup.body=Você realmente quer reverter a versão atual de {} ? manage_window.apps_table.row.actions.install=Instalar -manage_window.apps_table.upgrade_toggle.tooltip=Clique aqui para marcar ou desmarcar a atualização +manage_window.apps_table.row.actions.refresh=Atualizar +manage_window.apps_table.upgrade_toggle.tooltip=Existe um atualização para esse aplicativo. Clique aqui para marcar ou desmarcar a atualização manage_window.checkbox.only_apps=Aplicativos window_manage.input_search.placeholder=Buscar window_manage.input_search.tooltip=Digite e pressione ENTER para buscar aplicativos @@ -24,7 +25,7 @@ manage_window.status.history=Obtendo histórico manage_window.status.searching=Buscando manage_window.status.installing=Instalando manage_window.bt.refresh.tooltip=Recarrega os dados sobre os aplicativos instalados -manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos selecionados +manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos marcados manage_window.checkbox.show_details=Mostrar detalhes popup.root.title=Requer privilégios de root popup.root.password=Senha @@ -42,6 +43,11 @@ tray.action.exit=Sair tray.action.about=Sobre tray.action.refreshing=Recarregando notification.new_updates=Atualizações +notification.update_selected.success=aplicativo(s) atualizado(s) com sucesso +notification.update_selected.failed=Erro ao atualizar +notification.install.failed=instalação falhou +notification.uninstall.failed=desinstalação falhou +notification.downgrade.failed=Erro ao reverter versão flatpak.info.arch=arquitetura flatpak.info.branch=ramo flatpak.info.collection=coleção @@ -61,7 +67,15 @@ flatpak.info.sdk=sdk flatpak.info.subject=assunto flatpak.info.type=tipo flatpak.info.version=versão -about.info.desc=Interface gráfica não oficial para gerenciamento de aplicativos Flatpak +snap.info.commands=comandos +snap.info.contact=contato +snap.info.description=descrição +snap.info.license=licença +snap.info.revision=revisão +snap.info.tracking=tracking +snap.info.installed=instalado +snap.info.publisher=publicador +about.info.desc=Interface gráfica não oficial para gerenciamento de aplicativos Flatpak / Snap about.info.link=Mais informações em about.info.license=Licença gratuita about.info.rate=Se essa ferramenta é útil para você, dê uma estrela no Github para mantê-la de pé @@ -75,5 +89,14 @@ latest_version=última versão description=descrição type=tipo installed=instalado +uninstalled=não instalado +downgraded=versão revertida others=outros internet.required=É necessário estar conectado a Internet +license.unset=Não definida +updates=atualizações +version.installed=versão instalada +version.latest=versão mais recente +version.installed_outdated=a versão instalada está desatualizada +version.unknown=versão não informada +app.name=nome do aplicativo \ No newline at end of file diff --git a/fpakman/util/cache.py b/fpakman/util/cache.py index 971909bb..c88cc4e2 100644 --- a/fpakman/util/cache.py +++ b/fpakman/util/cache.py @@ -19,7 +19,8 @@ class Cache: self.lock.release() def _add(self, key: str, val: object): - self._cache[key] = {'val': val, 'expires_at': datetime.utcnow() + timedelta(seconds=self.expiration_time) if self.expiration_time > 0 else None} + if key: + self._cache[key] = {'val': val, 'expires_at': datetime.utcnow() + timedelta(seconds=self.expiration_time) if self.expiration_time > 0 else None} def add_non_existing(self, key: str, val: object): diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 6e5f4f7b..71b61491 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -3,10 +3,10 @@ from threading import Lock from typing import List from PyQt5.QtCore import Qt, QUrl -from PyQt5.QtGui import QPixmap, QIcon, QColor, QCursor +from PyQt5.QtGui import QPixmap, QIcon, QCursor from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ - QHeaderView, QLabel + QHeaderView, QLabel, QHBoxLayout from fpakman.core import resource from fpakman.core.model import ApplicationStatus @@ -26,7 +26,7 @@ class UpdateToggleButton(QToolButton): self.icon_on = QIcon(resource.get_path('img/toggle_on.svg')) self.icon_off = QIcon(resource.get_path('img/toggle_off.svg')) self.setIcon(self.icon_on) - self.setStyleSheet('border: 0px;') + self.setStyleSheet('QToolButton { border: 0px; }') self.setToolTip(locale_keys['manage_window.apps_table.upgrade_toggle.tooltip']) if not checked: @@ -40,15 +40,15 @@ class UpdateToggleButton(QToolButton): class AppsTable(QTableWidget): - def __init__(self, parent: QWidget, icon_cache: Cache, disk_cache: bool): + def __init__(self, parent: QWidget, icon_cache: Cache, disk_cache: bool, download_icons: bool): super(AppsTable, self).__init__() self.setParent(parent) self.window = parent self.disk_cache = disk_cache + self.download_icons = download_icons self.column_names = [parent.locale_keys[key].capitalize() for key in ['name', 'version', - 'latest_version', - 'flatpak.info.description', + 'description', 'type', 'installed', 'manage_window.columns.update']] @@ -56,6 +56,7 @@ class AppsTable(QTableWidget): self.setFocusPolicy(Qt.NoFocus) self.setShowGrid(False) self.verticalHeader().setVisible(False) + self.horizontalHeader().setVisible(False) self.setSelectionBehavior(QTableView.SelectRows) self.setHorizontalHeaderLabels(self.column_names) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -74,13 +75,19 @@ class AppsTable(QTableWidget): menu_row = QMenu() + if app.model.has_info(): + action_info = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.info"]) + action_info.setIcon(QIcon(resource.get_path('img/info.svg'))) + action_info.triggered.connect(self._get_app_info) + menu_row.addAction(action_info) + if app.model.installed: - if app.model.has_info(): - action_info = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.info"]) - action_info.setIcon(QIcon(resource.get_path('img/info.svg'))) - action_info.triggered.connect(self._get_app_info) - menu_row.addAction(action_info) + if app.model.can_be_refreshed(): + action_history = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.refresh"]) + action_history.setIcon(QIcon(resource.get_path('img/refresh.svg'))) + action_history.triggered.connect(self._refresh_app) + menu_row.addAction(action_history) if app.model.has_history(): action_history = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.history"]) @@ -99,6 +106,7 @@ class AppsTable(QTableWidget): action_downgrade.triggered.connect(self._downgrade_app) action_downgrade.setIcon(QIcon(resource.get_path('img/downgrade.svg'))) menu_row.addAction(action_downgrade) + else: if app.model.can_be_installed(): action_install = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.install"]) @@ -119,15 +127,17 @@ class AppsTable(QTableWidget): for idx, app_v in enumerate(self.window.apps): if app_v.visible and app_v.status == ApplicationViewStatus.LOADING and app_v.model.status == ApplicationStatus.READY: - self.network_man.get(QNetworkRequest(QUrl(app_v.model.base_data.icon_url))) + + if self.download_icons: + self.network_man.get(QNetworkRequest(QUrl(app_v.model.base_data.icon_url))) app_name = self.item(idx, 0).text() if not app_name or app_name == '...': self.item(idx, 0).setText(app_v.model.base_data.name) - self.cellWidget(idx, 2).setText(app_v.model.base_data.latest_version) - self._set_col_description(self.item(idx, 3), app_v) + self._set_col_version(idx, app_v) + self._set_col_description(idx, app_v) app_v.status = ApplicationViewStatus.READY self.window.resize_and_center() @@ -156,6 +166,9 @@ class AppsTable(QTableWidget): locale_keys=self.window.locale_keys): self.window.downgrade_app(selected_app) + def _refresh_app(self): + self.window.refresh(self.get_selected_app()) + def _get_app_info(self): self.window.get_app_info(self.get_selected_app()) @@ -195,43 +208,67 @@ class AppsTable(QTableWidget): if app_views: for idx, app_v in enumerate(app_views): - col_name = self._gen_col_name(app_v) - self.setItem(idx, 0, col_name) - - col_version = QLabel(app_v.model.base_data.version) - col_version.setAlignment(Qt.AlignCenter) - self.setCellWidget(idx, 1, col_version) - - col_release = QLabel(app_v.get_async_attr('latest_version')) - col_release.setAlignment(Qt.AlignCenter) - self.setCellWidget(idx, 2, col_release) - - if app_v.model.base_data.version and app_v.model.base_data.latest_version and app_v.model.base_data.version < app_v.model.base_data.latest_version: - col_release.setStyleSheet("color: orange") - - col_description = QTableWidgetItem() - col_description.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self._set_col_description(col_description, app_v) - - self.setItem(idx, 3, col_description) - - col_type = QLabel(app_v.model.get_type()) - col_type.setAlignment(Qt.AlignCenter) - self.setCellWidget(idx, 4, col_type) - - col_installed = QLabel() - col_installed.setPixmap((QPixmap(resource.get_path('img/{}.svg'.format('checked' if app_v.model.installed else 'red_cross'))))) - col_installed.setAlignment(Qt.AlignCenter) - - self.setCellWidget(idx, 5, col_installed) + self._set_col_name(idx, app_v) + self._set_col_version(idx, app_v) + self._set_col_description(idx, app_v) + self._set_col_type(idx, app_v) + self._set_col_installed(idx, app_v) col_update = UpdateToggleButton(app_v, self.window, self.window.locale_keys, app_v.model.update) if app_v.model.update else None - self.setCellWidget(idx, 6, col_update) + self.setCellWidget(idx, 5, col_update) - def _gen_col_name(self, app_v: ApplicationView): + def _set_col_installed(self, idx: int, app_v: ApplicationView): + col_installed = QLabel() + + if app_v.model.installed: + img_name = 'checked' + tooltip = self.window.locale_keys['installed'] + else: + img_name = 'red_cross' + tooltip = self.window.locale_keys['uninstalled'] + + col_installed.setPixmap((QPixmap(resource.get_path('img/{}.svg'.format(img_name))))) + col_installed.setAlignment(Qt.AlignCenter) + col_installed.setToolTip(tooltip) + + self.setCellWidget(idx, 4, col_installed) + + def _set_col_type(self, idx: int, app_v: ApplicationView): + col_type = QLabel() + pixmap = QPixmap(app_v.model.get_default_icon_path()) + col_type.setPixmap(pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + col_type.setAlignment(Qt.AlignCenter) + col_type.setToolTip('{}: {}'.format(self.window.locale_keys['type'], app_v.model.get_type())) + self.setCellWidget(idx, 3, col_type) + + def _set_col_version(self, idx: int, app_v: ApplicationView): + label_version = QLabel(app_v.model.base_data.version if app_v.model.base_data.version else '?') + label_version.setAlignment(Qt.AlignCenter) + + col_version = QWidget() + col_version.setLayout(QHBoxLayout()) + col_version.layout().addWidget(label_version) + + if app_v.model.base_data.version: + tooltip = self.window.locale_keys['version.installed'] if app_v.model.installed else self.window.locale_keys['version'] + else: + tooltip = self.window.locale_keys['version.unknown'] + + if app_v.model.update: + label_version.setStyleSheet("color: #32CD32") + tooltip = self.window.locale_keys['version.installed_outdated'] + + if app_v.model.base_data.version and app_v.model.base_data.latest_version and app_v.model.base_data.version < app_v.model.base_data.latest_version: + tooltip = '{}. {}: {}'.format(tooltip, self.window.locale_keys['version.latest'], app_v.model.base_data.latest_version) + + col_version.setToolTip(tooltip) + self.setCellWidget(idx, 1, col_version) + + def _set_col_name(self, idx: int, app_v: ApplicationView): col = QTableWidgetItem() col.setText(app_v.model.base_data.name if app_v.model.base_data.name else '...') col.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + col.setToolTip(self.window.locale_keys['app.name'].lower()) if self.disk_cache and app_v.model.supports_disk_cache() and os.path.exists(app_v.model.get_disk_icon_path()): with open(app_v.model.get_disk_icon_path(), 'rb') as f: @@ -240,6 +277,7 @@ class AppsTable(QTableWidget): pixmap.loadFromData(icon_bytes) icon = QIcon(pixmap) self.icon_cache.add_non_existing(app_v.model.base_data.icon_url, {'icon': icon, 'bytes': icon_bytes}) + elif not app_v.model.base_data.icon_url: icon = QIcon(app_v.model.get_default_icon_path()) else: @@ -247,9 +285,11 @@ class AppsTable(QTableWidget): icon = icon_data['icon'] if icon_data else QIcon(app_v.model.get_default_icon_path()) col.setIcon(icon) - return col + self.setItem(idx, 0, col) - def _set_col_description(self, col: QTableWidgetItem, app_v: ApplicationView): + def _set_col_description(self, idx: int, app_v: ApplicationView): + col = QTableWidgetItem() + col.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) desc = app_v.get_async_attr('description', strip_html=True) if desc: @@ -258,6 +298,8 @@ class AppsTable(QTableWidget): if app_v.model.status == ApplicationStatus.READY: col.setToolTip(desc) + self.setItem(idx, 2, col) + def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): header_horizontal = self.horizontalHeader() for i in range(self.columnCount()): diff --git a/fpakman/view/qt/history.py b/fpakman/view/qt/history.py index ae77232e..12e0e73a 100644 --- a/fpakman/view/qt/history.py +++ b/fpakman/view/qt/history.py @@ -2,7 +2,7 @@ import operator from functools import reduce from PyQt5.QtCore import Qt -from PyQt5.QtGui import QIcon +from PyQt5.QtGui import QIcon, QColor from PyQt5.QtWidgets import QDialog, QVBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView @@ -37,7 +37,7 @@ class HistoryDialog(QDialog): item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) if current_app_commit: - item.setBackground(Qt.darkYellow if row != 0 else Qt.darkGreen) + item.setBackground(QColor('#ffbf00' if row != 0 else '#32CD32')) tip = '{}. {}.'.format(locale_keys['popup.history.selected.tooltip'], locale_keys['version.{}'.format('updated'if row == 0 else 'outdated')].capitalize()) item.setToolTip(tip) diff --git a/fpakman/view/qt/info.py b/fpakman/view/qt/info.py index 0ee09ecf..9794deb8 100644 --- a/fpakman/view/qt/info.py +++ b/fpakman/view/qt/info.py @@ -1,37 +1,47 @@ +from PyQt5.QtCore import QSize from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QDialog, QVBoxLayout, QFormLayout, QGroupBox, \ - QLineEdit, QLabel, QPlainTextEdit + QLineEdit, QLabel + +from fpakman.util import util class InfoDialog(QDialog): - def __init__(self, app: dict, app_icon: QIcon, locale_keys: dict): + def __init__(self, app: dict, app_icon: QIcon, locale_keys: dict, app_type: str, screen_size: QSize()): super(InfoDialog, self).__init__() self.setWindowTitle(app['name']) self.setWindowIcon(app_icon) + self.screen_size = screen_size layout = QVBoxLayout() self.setLayout(layout) - gbox_info_layout = QFormLayout() gbox_info = QGroupBox() + gbox_info.setMaximumHeight(self.screen_size.height() - self.screen_size.height() * 0.1) + gbox_info_layout = QFormLayout() gbox_info.setLayout(gbox_info_layout) + layout.addWidget(gbox_info) for attr in sorted(app.keys()): - if attr != 'name' and app[attr]: - if attr == 'description': - text = QPlainTextEdit() - text.appendHtml(app[attr]) - else: - text = QLineEdit() - text.setText(app[attr]) - text.setCursorPosition(0) - text.setStyleSheet("width: 400px") + val = app[attr] + text = QLineEdit() + text.setToolTip(val) + if attr == 'license' and val.strip() == 'unset': + val = locale_keys['license.unset'] + + if attr == 'description': + val = util.strip_html(val) + val = val[0:40] + '...' + + text.setText(val) + text.setCursorPosition(0) + text.setStyleSheet("width: 400px") text.setReadOnly(True) - label = QLabel("{}: ".format(locale_keys.get('flatpak.info.' + attr, attr)).capitalize()) + label = QLabel("{}: ".format(locale_keys.get(app_type + '.info.' + attr, attr)).capitalize()) label.setStyleSheet("font-weight: bold") gbox_info_layout.addRow(label, text) diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 0923fcfe..1731cfb0 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -2,14 +2,15 @@ import time from threading import Lock from typing import List -from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication, Qt +from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication, Qt, QSize from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu from fpakman.core import resource, system -from fpakman.core.controller import FlatpakManager, ApplicationManager +from fpakman.core.controller import ApplicationManager from fpakman.core.model import Application from fpakman.util.cache import Cache +from fpakman.view.qt import dialog from fpakman.view.qt.about import AboutDialog from fpakman.view.qt.window import ManageWindow @@ -39,12 +40,14 @@ class UpdateCheck(QThread): class TrayIcon(QSystemTrayIcon): - def __init__(self, locale_keys: dict, manager: ApplicationManager, icon_cache: Cache, disk_cache: bool, check_interval: int = 60, update_notification: bool = True): + def __init__(self, locale_keys: dict, manager: ApplicationManager, icon_cache: Cache, disk_cache: bool, download_icons: bool, screen_size: QSize, check_interval: int = 60, update_notification: bool = True): super(TrayIcon, self).__init__() self.locale_keys = locale_keys self.manager = manager self.icon_cache = icon_cache self.disk_cache = disk_cache + self.download_icons = download_icons + self.screen_size = screen_size self.icon_default = QIcon(resource.get_path('img/logo.png')) self.icon_update = QIcon(resource.get_path('img/logo_update.png')) @@ -74,6 +77,12 @@ class TrayIcon(QSystemTrayIcon): self.update_notification = update_notification self.lock_notify = Lock() + self.activated.connect(self.handle_click) + + def handle_click(self, reason): + if reason == self.Trigger: + self.show_manage_window() + def notify_updates(self, updates: List[Application]): self.lock_notify.acquire() @@ -110,12 +119,14 @@ class TrayIcon(QSystemTrayIcon): manager=self.manager, icon_cache=self.icon_cache, tray_icon=self, - disk_cache=self.disk_cache) + disk_cache=self.disk_cache, + download_icons=self.download_icons, + screen_size=self.screen_size) if self.manage_window.isMinimized(): self.manage_window.setWindowState(Qt.WindowNoState) - else: - self.manage_window.refresh() + elif not self.manage_window.isVisible(): + self.manage_window.refresh_apps() self.manage_window.show() def show_about(self): diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index 16335302..e30dd21b 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -1,3 +1,4 @@ +import subprocess import time from datetime import datetime, timedelta from typing import List @@ -8,14 +9,51 @@ from PyQt5.QtCore import QThread, pyqtSignal from fpakman.core.controller import ApplicationManager from fpakman.core.exception import NoInternetException from fpakman.core.model import ApplicationStatus +from fpakman.core.system import FpakmanProcess from fpakman.util.cache import Cache from fpakman.view.qt import dialog from fpakman.view.qt.view_model import ApplicationView -class UpdateSelectedApps(QThread): +class AsyncAction(QThread): - signal_finished = pyqtSignal(bool) + def notify_subproc_outputs(self, proc: FpakmanProcess, signal) -> bool: + """ + :param subproc: + :param signal: + :param success: + :return: if the subprocess succeeded + """ + signal.emit(' '.join(proc.subproc.args) + '\n') + + success, already_succeeded = True, False + + for output in proc.subproc.stdout: + line = output.decode().strip() + if line: + signal.emit(line) + + if proc.success_pgrase and proc.success_pgrase in line: + already_succeeded = True + + if already_succeeded: + return True + + for output in proc.subproc.stderr: + line = output.decode().strip() + if line: + if proc.wrong_error_phrase and proc.wrong_error_phrase in line: + continue + else: + success = False + signal.emit(line) + + return success + + +class UpdateSelectedApps(AsyncAction): + + signal_finished = pyqtSignal(bool, int) signal_output = pyqtSignal(str) def __init__(self, manager: ApplicationManager, apps_to_update: List[ApplicationView] = None): @@ -25,31 +63,20 @@ class UpdateSelectedApps(QThread): def run(self): - error = False + success = False for app in self.apps_to_update: - subproc = self.manager.update_and_stream(app.model) + process = self.manager.update_and_stream(app.model) + success = self.notify_subproc_outputs(process, self.signal_output) - self.signal_output.emit(' '.join(subproc.args) + '\n') - - for output in subproc.stdout: - line = output.decode().strip() - if line: - self.signal_output.emit(line) - - for output in subproc.stderr: - line = output.decode().strip() - if line: - error = True - self.signal_output.emit(line) - - self.signal_output.emit('\n') - - if error: + if not success: break + else: + self.signal_output.emit('\n') - self.signal_finished.emit(not error) + self.signal_finished.emit(success, len(self.apps_to_update)) + self.apps_to_update = None class RefreshApps(QThread): @@ -64,8 +91,8 @@ class RefreshApps(QThread): self.signal.emit(self.manager.read_installed()) -class UninstallApp(QThread): - signal_finished = pyqtSignal() +class UninstallApp(AsyncAction): + signal_finished = pyqtSignal(bool) signal_output = pyqtSignal(str) def __init__(self, manager: ApplicationManager, icon_cache: Cache, app: ApplicationView = None): @@ -73,33 +100,23 @@ class UninstallApp(QThread): self.app = app self.manager = manager self.icon_cache = icon_cache + self.root_password = None def run(self): if self.app: - subproc = self.manager.uninstall_and_stream(self.app.model) - self.signal_output.emit(' '.join(subproc.args) + '\n') + process = self.manager.uninstall_and_stream(self.app.model, self.root_password) + success = self.notify_subproc_outputs(process, self.signal_output) - for output in subproc.stdout: - line = output.decode().strip() - if line: - self.signal_output.emit(line) - - error = False - - for output in subproc.stderr: - line = output.decode().strip() - if line: - error = True - self.signal_output.emit(line) - - if not error: + if success: self.icon_cache.delete(self.app.model.base_data.icon_url) self.manager.clean_cache_for(self.app.model) - self.signal_finished.emit() + self.signal_finished.emit(success) + self.app = None + self.root_password = None -class DowngradeApp(QThread): +class DowngradeApp(AsyncAction): signal_finished = pyqtSignal(bool) signal_output = pyqtSignal(str) @@ -113,18 +130,15 @@ class DowngradeApp(QThread): def run(self): if self.app: - success = True + success = False try: - stream = self.manager.downgrade_app(self.app.model, self.root_password) + process = self.manager.downgrade_app(self.app.model, self.root_password) - if stream is None: + if process is None: dialog.show_error(title=self.locale_keys['popup.downgrade.impossible.title'], body=self.locale_keys['popup.downgrade.impossible.body']) else: - for output in stream: - line = output.decode().strip() - if line: - self.signal_output.emit(line) + success = self.notify_subproc_outputs(process, self.signal_output) except (requests.exceptions.ConnectionError, NoInternetException): success = False self.signal_output.emit(self.locale_keys['internet.required']) @@ -180,54 +194,51 @@ class SearchApps(QThread): apps_found = [] if self.word: - apps_found = self.manager.search(self.word) + res = self.manager.search(self.word) + apps_found.extend(res['installed']) + apps_found.extend(res['new']) self.signal_finished.emit(apps_found) self.word = None -class InstallApp(QThread): +class InstallApp(AsyncAction): signal_finished = pyqtSignal(bool) signal_output = pyqtSignal(str) - def __init__(self, manager: ApplicationManager, disk_cache: bool, icon_cache: Cache, app: ApplicationView = None): + def __init__(self, manager: ApplicationManager, disk_cache: bool, icon_cache: Cache, locale_keys: dict, app: ApplicationView = None): super(InstallApp, self).__init__() self.app = app self.manager = manager self.icon_cache = icon_cache self.disk_cache = disk_cache + self.locale_keys = locale_keys + self.root_password = None def run(self): if self.app: - subproc = self.manager.install_and_stream(self.app.model) - self.signal_output.emit(' '.join(subproc.args) + '\n') + success = False - for output in subproc.stdout: - line = output.decode().strip() - if line: - self.signal_output.emit(line) + try: + process = self.manager.install_and_stream(self.app.model, self.root_password) + success = self.notify_subproc_outputs(process, self.signal_output) - error = False + if success and self.disk_cache: + self.app.model.installed = True - for output in subproc.stderr: - line = output.decode().strip() - if line: - error = True - self.signal_output.emit(line) - - if not error and self.disk_cache: - self.app.model.installed = True - - if self.app.model.supports_disk_cache(): - icon_data = self.icon_cache.get(self.app.model.base_data.icon_url) - self.manager.cache_to_disk(app=self.app.model, - icon_bytes=icon_data.get('bytes') if icon_data else None, - only_icon=False) - - self.app = None - self.signal_finished.emit(not error) + if self.app.model.supports_disk_cache(): + icon_data = self.icon_cache.get(self.app.model.base_data.icon_url) + self.manager.cache_to_disk(app=self.app.model, + icon_bytes=icon_data.get('bytes') if icon_data else None, + only_icon=False) + except (requests.exceptions.ConnectionError, NoInternetException): + success = False + self.signal_output.emit(self.locale_keys['internet.required']) + finally: + self.app = None + self.signal_finished.emit(success) class AnimateProgress(QThread): @@ -292,3 +303,30 @@ class VerifyModels(QThread): break self.apps = None + + +class RefreshApp(AsyncAction): + + signal_finished = pyqtSignal(bool) + signal_output = pyqtSignal(str) + + def __init__(self, manager: ApplicationManager, app: ApplicationView = None): + super(RefreshApp, self).__init__() + self.app = app + self.manager = manager + self.root_password = None + + def run(self): + + if self.app: + success = False + + try: + process = self.manager.refresh(self.app.model, self.root_password) + success = self.notify_subproc_outputs(process, self.signal_output) + except (requests.exceptions.ConnectionError, NoInternetException): + success = False + self.signal_output.emit(self.locale_keys['internet.required']) + finally: + self.app = None + self.signal_finished.emit(success) diff --git a/fpakman/view/qt/view_model.py b/fpakman/view/qt/view_model.py index 6a090bf7..be305b88 100644 --- a/fpakman/view/qt/view_model.py +++ b/fpakman/view/qt/view_model.py @@ -1,7 +1,7 @@ from enum import Enum +from fpakman.core.model import Application, ApplicationStatus from fpakman.util import util -from fpakman.core.model import Application, ApplicationStatus, FlatpakApplication class ApplicationViewStatus(Enum): @@ -19,7 +19,7 @@ class ApplicationView: def get_async_attr(self, attr: str, strip_html: bool = False, default: str = '...'): - if isinstance(self.model, FlatpakApplication) and self.model.runtime: + if self.model.is_library(): res = getattr(self.model.base_data, attr) else: res = getattr(self.model.base_data, attr) if self.model.status == ApplicationStatus.READY and getattr(self.model.base_data, attr) else default diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index a9926e84..dc677293 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -1,14 +1,14 @@ import operator from functools import reduce from threading import Lock -from typing import List +from typing import List, Set -from PyQt5.QtCore import QEvent +from PyQt5.QtCore import QEvent, Qt from PyQt5.QtGui import QIcon, QWindowStateChangeEvent, QPixmap from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ - QSizePolicy, QLabel, QPlainTextEdit, QLineEdit, QProgressBar + QSizePolicy, QLabel, QPlainTextEdit, QLineEdit, QProgressBar, QHBoxLayout -from fpakman.core import resource +from fpakman.core import resource, system from fpakman.core.controller import ApplicationManager from fpakman.core.model import Application from fpakman.util.cache import Cache @@ -17,7 +17,7 @@ from fpakman.view.qt.history import HistoryDialog from fpakman.view.qt.info import InfoDialog from fpakman.view.qt.root import is_root, ask_root_password from fpakman.view.qt.thread import UpdateSelectedApps, RefreshApps, UninstallApp, DowngradeApp, GetAppInfo, \ - GetAppHistory, SearchApps, InstallApp, AnimateProgress, VerifyModels + GetAppHistory, SearchApps, InstallApp, AnimateProgress, VerifyModels, RefreshApp from fpakman.view.qt.view_model import ApplicationView DARK_ORANGE = '#FF4500' @@ -26,7 +26,7 @@ DARK_ORANGE = '#FF4500' class ManageWindow(QWidget): __BASE_HEIGHT__ = 400 - def __init__(self, locale_keys: dict, icon_cache: Cache, manager: ApplicationManager, disk_cache: bool, tray_icon=None): + def __init__(self, locale_keys: dict, icon_cache: Cache, manager: ApplicationManager, disk_cache: bool, download_icons: bool, screen_size, tray_icon=None): super(ManageWindow, self).__init__() self.locale_keys = locale_keys self.manager = manager @@ -37,6 +37,8 @@ class ManageWindow(QWidget): self.label_flatpak = None self.icon_cache = icon_cache self.disk_cache = disk_cache + self.download_icons = download_icons + self.screen_size = screen_size self.icon_flathub = QIcon(resource.get_path('img/logo.svg')) self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) @@ -46,14 +48,20 @@ class ManageWindow(QWidget): self.layout = QVBoxLayout() self.setLayout(self.layout) + self.toolbar_top = QToolBar() + self.toolbar_top.addWidget(self._new_spacer()) + + self.label_status = QLabel() + self.label_status.setText('') + self.label_status.setStyleSheet("font-weight: bold") + self.toolbar_top.addWidget(self.label_status) + self.toolbar_search = QToolBar() self.toolbar_search.setStyleSheet("spacing: 0px;") self.toolbar_search.setContentsMargins(0, 0, 0, 0) - self.toolbar_search.addWidget(self._new_spacer()) label_pre_search = QLabel() - label_pre_search.setStyleSheet( - "background: white; border-top-left-radius: 5px; border-bottom-left-radius: 5px;") + label_pre_search.setStyleSheet("background: white; border-top-left-radius: 5px; border-bottom-left-radius: 5px;") self.toolbar_search.addWidget(label_pre_search) self.input_search = QLineEdit() @@ -69,30 +77,33 @@ class ManageWindow(QWidget): label_pos_search.setStyleSheet("background: white; padding-right: 10px; border-top-right-radius: 5px; border-bottom-right-radius: 5px;") self.toolbar_search.addWidget(label_pos_search) - self.toolbar_search.addWidget(self._new_spacer()) - self.layout.addWidget(self.toolbar_search) + self.ref_toolbar_search = self.toolbar_top.addWidget(self.toolbar_search) + self.toolbar_top.addWidget(self._new_spacer()) + self.layout.addWidget(self.toolbar_top) toolbar = QToolBar() self.checkbox_only_apps = QCheckBox() self.checkbox_only_apps.setText(self.locale_keys['manage_window.checkbox.only_apps']) self.checkbox_only_apps.setChecked(True) - self.checkbox_only_apps.stateChanged.connect(self.filter_only_apps) - toolbar.addWidget(self.checkbox_only_apps) + self.checkbox_only_apps.stateChanged.connect(self._handle_filter_only_apps) + self.ref_checkbox_only_apps = toolbar.addWidget(self.checkbox_only_apps) - toolbar.addWidget(self._new_spacer()) + self.checkbox_updates = QCheckBox() + self.checkbox_updates.setText(self.locale_keys['updates'].capitalize()) + self.checkbox_updates.stateChanged.connect(self._handle_updates_filter) + self.ref_checkbox_updates = toolbar.addWidget(self.checkbox_updates) - self.label_status = QLabel() - self.label_status.setText('') - self.label_status.setStyleSheet("font-weight: bold") - toolbar.addWidget(self.label_status) + self.extra_filters = QWidget() + self.extra_filters.setLayout(QHBoxLayout()) + toolbar.addWidget(self.extra_filters) toolbar.addWidget(self._new_spacer()) self.bt_refresh = QToolButton() self.bt_refresh.setToolTip(locale_keys['manage_window.bt.refresh.tooltip']) self.bt_refresh.setIcon(QIcon(resource.get_path('img/refresh.svg'))) - self.bt_refresh.clicked.connect(lambda: self.refresh(keep_console=False)) + self.bt_refresh.clicked.connect(lambda: self.refresh_apps(keep_console=False)) toolbar.addWidget(self.bt_refresh) self.bt_upgrade = QToolButton() @@ -104,7 +115,7 @@ class ManageWindow(QWidget): self.layout.addWidget(toolbar) - self.table_apps = AppsTable(self, self.icon_cache, disk_cache=self.disk_cache) + self.table_apps = AppsTable(self, self.icon_cache, disk_cache=self.disk_cache, download_icons=self.download_icons) self.table_apps.change_headers_policy() self.layout.addWidget(self.table_apps) @@ -132,7 +143,7 @@ class ManageWindow(QWidget): self.thread_update.signal_finished.connect(self._finish_update_selected) self.thread_refresh = RefreshApps(self.manager) - self.thread_refresh.signal.connect(self._finish_refresh) + self.thread_refresh.signal.connect(self._finish_refresh_apps) self.thread_uninstall = UninstallApp(self.manager, self.icon_cache) self.thread_uninstall.signal_output.connect(self._update_action_output) @@ -151,7 +162,7 @@ class ManageWindow(QWidget): self.thread_search = SearchApps(self.manager) self.thread_search.signal_finished.connect(self._finish_search) - self.thread_install = InstallApp(manager=self.manager, disk_cache=self.disk_cache, icon_cache=self.icon_cache) + self.thread_install = InstallApp(manager=self.manager, disk_cache=self.disk_cache, icon_cache=self.icon_cache, locale_keys=self.locale_keys) self.thread_install.signal_output.connect(self._update_action_output) self.thread_install.signal_finished.connect(self._finish_install) @@ -161,10 +172,11 @@ class ManageWindow(QWidget): self.thread_verify_models = VerifyModels() self.thread_verify_models.signal_updates.connect(self._notify_model_data_change) + self.thread_refresh_app = RefreshApp(manager=self.manager) + self.thread_refresh_app.signal_finished.connect(self._finish_refresh) + self.thread_refresh_app.signal_output.connect(self._update_action_output) + self.toolbar_bottom = QToolBar() - self.label_updates = QLabel('') - self.label_updates.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE)) - self.ref_label_updates = self.toolbar_bottom.addWidget(self.label_updates) self.toolbar_bottom.addWidget(self._new_spacer()) @@ -174,10 +186,34 @@ class ManageWindow(QWidget): self.toolbar_bottom.addWidget(self._new_spacer()) + self.label_updates = QLabel() + self.ref_label_updates = self.toolbar_bottom.addWidget(self.label_updates) + self.layout.addWidget(self.toolbar_bottom) self.centralize() + self.filter_only_apps = True + self.filter_types = set() + self.filter_updates = False + + def _handle_updates_filter(self, status: int): + self.filter_updates = status == 2 + self.apply_filters() + + def _handle_filter_only_apps(self, status: int): + self.filter_only_apps = status == 2 + self.apply_filters() + + def _handle_type_filter(self, status: int, app_type: str): + + if status == 2: + self.filter_types.add(app_type) + elif app_type in self.filter_types: + self.filter_types.remove(app_type) + + self.apply_filters() + def _notify_model_data_change(self): self.table_apps.fill_async_data() @@ -234,61 +270,136 @@ class ManageWindow(QWidget): self.checkbox_console.setChecked(False) self.textarea_output.hide() - def refresh(self, keep_console: bool = True): + def refresh_apps(self, keep_console: bool = True): if self._acquire_lock(): + self.filter_types.clear() self.input_search.clear() if not keep_console: self._handle_console_option(False) - self._begin_action(self.locale_keys['manage_window.status.refreshing']) + self.ref_checkbox_updates.setVisible(False) + self.ref_checkbox_only_apps.setVisible(False) + self._begin_action(self.locale_keys['manage_window.status.refreshing'], clear_filters=True) self.thread_refresh.start() - def _finish_refresh(self, apps: List[Application]): + def _finish_refresh_apps(self, apps: List[Application]): self.update_apps(apps) self.finish_action() + self.ref_checkbox_only_apps.setVisible(True) self._release_lock() def uninstall_app(self, app: ApplicationView): if self._acquire_lock(): + + pwd = None + requires_root = self.manager.requires_root('uninstall', self.table_apps.get_selected_app().model) + + if not is_root() and requires_root: + pwd, ok = ask_root_password(self.locale_keys) + + if not ok: + self._release_lock() + return + self._handle_console_option(True) self._begin_action('{} {}'.format(self.locale_keys['manage_window.status.uninstalling'], app.model.base_data.name)) self.thread_uninstall.app = app + self.thread_uninstall.root_password = pwd self.thread_uninstall.start() - def _finish_uninstall(self): + def refresh(self, app: ApplicationView): + if self._acquire_lock(): + + pwd = None + requires_root = self.manager.requires_root('refresh', self.table_apps.get_selected_app().model) + + if not is_root() and requires_root: + pwd, ok = ask_root_password(self.locale_keys) + + if not ok: + self._release_lock() + return + + self._handle_console_option(True) + self._begin_action('{} {}'.format(self.locale_keys['manage_window.status.refreshing'], app.model.base_data.name)) + + self.thread_refresh_app.app = app + self.thread_refresh_app.root_password = pwd + self.thread_refresh_app.start() + + def _finish_uninstall(self, success: bool): self.finish_action() self._release_lock() - self.refresh() + + if success: + if self._can_notify_user(): + app = self.table_apps.get_selected_app() + system.notify_user('{} ({}) {}'.format(app.model.base_data.name, app.model.get_type(), self.locale_keys['uninstalled'])) + + self.refresh_apps() + else: + if self._can_notify_user(): + app = self.table_apps.get_selected_app() + system.notify_user('{}: {}'.format(app.model.base_data.name, self.locale_keys['notification.uninstall.failed'])) + + self.checkbox_console.setChecked(True) + + def _can_notify_user(self): + return self.isHidden() or self.isMinimized() def _finish_downgrade(self, success: bool): self.finish_action() self._release_lock() if success: - self.refresh() + if self._can_notify_user(): + app = self.table_apps.get_selected_app() + system.notify_user('{} ({}) {}'.format(app.model.base_data.name, app.model.get_type(), self.locale_keys['downgraded'])) + + self.refresh_apps() else: + if self._can_notify_user(): + system.notify_user(self.locale_keys['notification.downgrade.failed']) + + self.change_update_state(notify_tray=False) self.checkbox_console.setChecked(True) - def filter_only_apps(self, only_apps: int): + def _finish_refresh(self, success: bool): + self.finish_action() + self._release_lock() + if success: + self.refresh_apps() + else: + self.change_update_state(notify_tray=False) + self.checkbox_console.setChecked(True) + + def apply_filters(self): if self.apps: - show_only_apps = True if only_apps == 2 else False - + visible_apps = len(self.apps) for idx, app_v in enumerate(self.apps): - hidden = show_only_apps and app_v.model.is_library() + hidden = self.filter_only_apps and app_v.model.is_library() + + if not hidden and self.filter_types is not None: + hidden = app_v.model.get_type() not in self.filter_types + + if not hidden and self.filter_updates: + hidden = not app_v.model.update + self.table_apps.setRowHidden(idx, hidden) app_v.visible = not hidden + visible_apps -= 1 if hidden else 0 - self.change_update_state() + self.change_update_state(change_filters=False) self.table_apps.change_headers_policy(QHeaderView.Stretch) self.table_apps.change_headers_policy() - self.resize_and_center() + self.resize_and_center(accept_lower_width=visible_apps > 0) - def change_update_state(self): + def change_update_state(self, notify_tray: bool = True, change_filters: bool = True): enable_bt_update = False @@ -301,23 +412,38 @@ class ManageWindow(QWidget): else: app_updates += 1 - total_updates = app_updates + library_updates - if total_updates > 0: - self.label_updates.setText('{}: {}'.format(self.locale_keys['manage_window.label.updates'], total_updates)) - self.label_updates.setToolTip('{} {} | {} {}'.format(app_updates, - self.locale_keys['manage_window.checkbox.only_apps'].lower(), - library_updates, - self.locale_keys['others'].lower())) - else: - self.label_updates.setText('') - for app_v in self.apps: if app_v.visible and app_v.update_checked: enable_bt_update = True break self.bt_upgrade.setEnabled(enable_bt_update) - self.tray_icon.notify_updates([app.model for app in self.apps if app.model.update]) + + total_updates = app_updates + library_updates + if total_updates > 0: + self.label_updates.setPixmap(QPixmap(resource.get_path('img/exclamation.svg')).scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + self.label_updates.setToolTip('{}: {} ( {} {} | {} {} )'.format(self.locale_keys['manage_window.label.updates'], + total_updates, + app_updates, + self.locale_keys['manage_window.checkbox.only_apps'].lower(), + library_updates, + self.locale_keys['others'].lower())) + + if not self.ref_checkbox_updates.isVisible(): + self.ref_checkbox_updates.setVisible(True) + + if change_filters and not self.checkbox_updates.isChecked(): + self.checkbox_updates.setChecked(True) + + if change_filters and library_updates > 0 and self.checkbox_only_apps.isChecked(): + self.checkbox_only_apps.setChecked(False) + else: + self.checkbox_updates.setChecked(False) + self.ref_checkbox_updates.setVisible(False) + self.label_updates.setPixmap(QPixmap()) + + if notify_tray: + self.tray_icon.notify_updates([app.model for app in self.apps if app.model.update]) def centralize(self): geo = self.frameGeometry() @@ -330,12 +456,12 @@ class ManageWindow(QWidget): self.apps = [] napps = 0 # number of apps (not libraries) + available_types = set() if apps: for app in apps: - app_model = ApplicationView(model=app, - visible=(not app.is_library()) or not self.checkbox_only_apps.isChecked()) - + app_model = ApplicationView(model=app, visible=(not app.is_library()) or not self.checkbox_only_apps.isChecked()) + available_types.add(app.get_type()) napps += 1 if not app.is_library() else 0 self.apps.append(app_model) @@ -346,17 +472,41 @@ class ManageWindow(QWidget): self.checkbox_only_apps.setCheckable(True) self.checkbox_only_apps.setChecked(True) + self._update_type_filters(available_types) self.table_apps.update_apps(self.apps) + self.apply_filters() self.change_update_state() - self.filter_only_apps(2 if self.checkbox_only_apps.isChecked() else 0) self.resize_and_center() self.thread_verify_models.apps = self.apps self.thread_verify_models.start() - def resize_and_center(self): + def _update_type_filters(self, available_types: Set[str]): + + self.filter_types = available_types + + filters_layout = self.extra_filters.layout() + for i in reversed(range(filters_layout.count())): + filters_layout.itemAt(i).widget().setParent(None) + + if available_types: + for app_type in sorted(list(available_types)): + checkbox_app_type = QCheckBox() + checkbox_app_type.setChecked(True) + checkbox_app_type.setText(app_type) + + def handle_click(status: int, filter_type: str = app_type): + self._handle_type_filter(status, filter_type) + + checkbox_app_type.stateChanged.connect(handle_click) + filters_layout.addWidget(checkbox_app_type) + + def resize_and_center(self, accept_lower_width: bool = True): new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(self.table_apps.column_names))]) * 1.05 - self.resize(new_width, self.height()) + + if accept_lower_width or new_width > self.width(): + self.resize(new_width, self.height()) + self.centralize() def update_selected(self): @@ -373,20 +523,26 @@ class ManageWindow(QWidget): self.thread_update.apps_to_update = to_update self.thread_update.start() - def _finish_update_selected(self, success: bool): + def _finish_update_selected(self, success: bool, updated: int): self.finish_action() self._release_lock() if success: - self.refresh() + if self._can_notify_user(): + system.notify_user('{} {}'.format(updated, self.locale_keys['notification.update_selected.success'])) + + self.refresh_apps() else: + if self._can_notify_user(): + system.notify_user(self.locale_keys['notification.update_selected.failed']) + self.bt_upgrade.setEnabled(True) self.checkbox_console.setChecked(True) def _update_action_output(self, output: str): self.textarea_output.appendPlainText(output) - def _begin_action(self, action_label: str, keep_search: bool = False): + def _begin_action(self, action_label: str, keep_search: bool = False, clear_filters: bool = False): self.ref_label_updates.setVisible(False) self.thread_animate_progress.stop = False self.thread_animate_progress.start() @@ -397,11 +553,17 @@ class ManageWindow(QWidget): self.bt_refresh.setEnabled(False) self.checkbox_only_apps.setEnabled(False) self.table_apps.setEnabled(False) + self.checkbox_updates.setEnabled(False) if keep_search: - self.toolbar_search.setEnabled(False) + self.ref_toolbar_search.setVisible(True) else: - self.toolbar_search.setVisible(False) + self.ref_toolbar_search.setVisible(False) + + if clear_filters: + self._update_type_filters(set()) + else: + self.extra_filters.setEnabled(False) def finish_action(self): self.ref_progress_bar.setVisible(False) @@ -413,15 +575,18 @@ class ManageWindow(QWidget): self.table_apps.setEnabled(True) self.input_search.setEnabled(True) self.label_status.setText('') - self.toolbar_search.setVisible(True) - self.toolbar_search.setEnabled(True) + self.ref_toolbar_search.setVisible(True) + self.ref_toolbar_search.setEnabled(True) + self.extra_filters.setEnabled(True) + self.checkbox_updates.setEnabled(True) def downgrade_app(self, app: ApplicationView): if self._acquire_lock(): pwd = None + requires_root = self.manager.requires_root('downgrade', self.table_apps.get_selected_app().model) - if not is_root(): + if not is_root() and requires_root: pwd, ok = ask_root_password(self.locale_keys) if not ok: @@ -456,7 +621,7 @@ class ManageWindow(QWidget): self._release_lock() self.finish_action() self.change_update_state() - dialog_info = InfoDialog(app_info, self.table_apps.get_selected_app_icon(), self.locale_keys) + dialog_info = InfoDialog(app_info, self.table_apps.get_selected_app_icon(), self.locale_keys, self.table_apps.get_selected_app().model.get_type(), self.screen_size) dialog_info.exec_() def _finish_get_history(self, app: dict): @@ -478,7 +643,10 @@ class ManageWindow(QWidget): if word and self._acquire_lock(): self._handle_console_option(False) - self._begin_action(self.locale_keys['manage_window.status.searching'], keep_search=True) + self.ref_checkbox_only_apps.setVisible(False) + self.ref_checkbox_updates.setVisible(False) + self.filter_updates = False + self._begin_action('{} "{}"'.format(self.locale_keys['manage_window.status.searching'], word), clear_filters=True) self.thread_search.word = word self.thread_search.start() @@ -489,10 +657,22 @@ class ManageWindow(QWidget): def install_app(self, app: ApplicationView): if self._acquire_lock(): + + pwd = None + requires_root = self.manager.requires_root('install', self.table_apps.get_selected_app().model) + + if not is_root() and requires_root: + pwd, ok = ask_root_password(self.locale_keys) + + if not ok: + self._release_lock() + return + self._handle_console_option(True) self._begin_action('{} {}'.format(self.locale_keys['manage_window.status.installing'], app.model.base_data.name)) self.thread_install.app = app + self.thread_install.root_password = pwd self.thread_install.start() def _finish_install(self, success: bool): @@ -501,8 +681,16 @@ class ManageWindow(QWidget): self._release_lock() if success: - self.refresh() + if self._can_notify_user(): + app = self.table_apps.get_selected_app() + system.notify_user('{} ({}) {}'.format(app.model.base_data.name, app.model.get_type(), self.locale_keys['installed'])) + + self.refresh_apps() else: + if self._can_notify_user(): + app = self.table_apps.get_selected_app() + system.notify_user('{}: {}'.format(app.model.base_data.name, self.locale_keys['notification.install.failed'])) + self.checkbox_console.setChecked(True) def _update_progress(self, value: int): diff --git a/requirements.txt b/requirements.txt index 3415a156..256d8361 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pyqt5>=5.12 requests>=2.22 -colorama>=0.4.1 \ No newline at end of file +colorama>=0.4.1 +beautifulsoup4>=4.7 \ No newline at end of file diff --git a/setup.py b/setup.py index 2de123dc..dfb8ce45 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import os from setuptools import setup, find_packages DESCRIPTION = ( - "Graphical user interface to manage Flatpak applications." + "Graphical user interface to manage Flatpak / Snap applications." ) AUTHOR = "Vinicius Moreira"