diff --git a/CHANGELOG.md b/CHANGELOG.md index da7ba9ef..3452a69a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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.3.1] - 2019-07-13 +### Improvements +- Console output now is optional and not shown by default +- Search bar is cleaned when 'Refresh' is clicked +- Full Flatpak database is not loaded during initialization: speeds up the process and reduces memory usage +- Applications data not available offline are now retrieved from Flathub API on demand and cached in memory and disk (only installed) +- In-memory cached data have an expiration time and are cleaned overtime to reduce memory usage +- Code was refactored to support other types of packaging in the future (e.g: snap) +- flatpak is not a requirement anymore +- the amount of columns of the applications table was reduced to improve the user experience +- new environment variables and arguments: FPAKMAN_ICON_EXPIRATION (--icon-exp), FPAKMAN_DISK_CACHE (--disk-cache) +- minor GUI improvements + +### Fixes: +- flatpak 1.0.X: search is now retrieving the application name +- app crashes when there is no internet connection while initializing, downgrading or retrieving app history + ## [0.3.0] - 2019-07-02 ### Features - Applications search diff --git a/README.md b/README.md index d6e35040..565a7342 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ You can change some application settings via environment variables or arguments - **FPAKMAN_UPDATE_NOTIFICATION**: enable or disable system updates notifications. Use **0** (disable) or **1** (enable, default). - **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. - **FPAKMAN_LOCALE**: define a custom app translation for a given locale key (e.g: 'pt', 'en', 'es', ...). Default: system locale. -- **FPAKMAN_CACHE_EXPIRATION**: define a custom expiration time in SECONDS for application data retrieved from the origin API. Default: 3600 (1 hour). +- **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). ### Roadmap diff --git a/fpakman/__init__.py b/fpakman/__init__.py index 0404d810..0dcd6ff2 100644 --- a/fpakman/__init__.py +++ b/fpakman/__init__.py @@ -1 +1,2 @@ -__version__ = '0.3.0' +__version__ = '0.3.1' +__app_name__ = 'fpakman' diff --git a/fpakman/app.py b/fpakman/app.py index f23df03b..2ec1d15c 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -6,34 +6,41 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication from colorama import Fore -from fpakman import __version__ +from fpakman import __version__, __app_name__ from fpakman.core import resource -from fpakman.core import util -from fpakman.core.controller import FlatpakManager +from fpakman.core.disk import DiskCacheLoaderFactory +from fpakman.core.structure import prepare_folder_structure +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 -app_name = 'fpakman' - def log_msg(msg: str, color: int = None): if color is None: - print('[{}] {}'.format(app_name, msg)) + print('[{}] {}'.format(__app_name__, msg)) else: - print('{}[{}] {}{}'.format(color, app_name, msg, Fore.RESET)) + print('{}[{}] {}{}'.format(color, __app_name__, msg, Fore.RESET)) -parser = argparse.ArgumentParser(prog=app_name, description="GUI for Flatpak applications management") +parser = argparse.ArgumentParser(prog=__app_name__, description="GUI for Flatpak applications management") parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) -parser.add_argument('-e', '--cache-exp', action="store", default=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60)), type=int, help='cached application expiration time in SECONDS. Default: %(default)s') +parser.add_argument('-e', '--cache-exp', action="store", default=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60)), type=int, help='cached API data expiration time in SECONDS. Default: %(default)s') +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='Enable/disable system notifications for new updates. 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') args = parser.parse_args() -if args.cache_exp <= 0: +if args.cache_exp < 0: log_msg("'cache-exp' set to '{}': cache will not expire.".format(args.cache_exp), Fore.YELLOW) +if args.icon_exp < 0: + log_msg("'icon-exp' set to '{}': cache will not expire.".format(args.cache_exp), Fore.YELLOW) + if not args.locale.strip(): log_msg("'locale' set as '{}'. You must provide a valid one. Aborting...".format(args.locale), Fore.RED) exit(1) @@ -47,16 +54,30 @@ if args.update_notification == 0: locale_keys = util.get_locale_keys(args.locale) +prepare_folder_structure(disk_cache=args.disk_cache) + +caches = [] +flatpak_api_cache = Cache(expiration_time=args.cache_exp) +caches.append(flatpak_api_cache) + +icon_cache = Cache(expiration_time=args.icon_exp) +caches.append(icon_cache) + app = QApplication(sys.argv) app.setWindowIcon(QIcon(resource.get_path('img/logo.svg'))) -manager = FlatpakManager(cache_expire=args.cache_exp) +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)) -trayIcon.load_database() trayIcon.show() +CacheCleaner(caches).start() + sys.exit(app.exec_()) diff --git a/fpakman/core/constants.py b/fpakman/core/constants.py new file mode 100644 index 00000000..687356af --- /dev/null +++ b/fpakman/core/constants.py @@ -0,0 +1,2 @@ +FLATHUB_URL = 'https://flathub.org' +FLATHUB_API_URL = FLATHUB_URL + '/api/v1' diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 5e02af2d..1b1c4029 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -1,106 +1,145 @@ -from datetime import datetime, timedelta +import os +import shutil +from abc import ABC, abstractmethod +from datetime import datetime from threading import Lock from typing import List import requests -from fpakman.core import flatpak - -__FLATHUB_URL__ = 'https://flathub.org' -__FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1' +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 -class FlatpakManager: +class ApplicationManager(ABC): - def __init__(self, cache_expire: int = 60 * 60): - self.cache_apps = {} - self.cache_expire = cache_expire + @abstractmethod + def search(self, word: str, disk_loader: DiskCacheLoader) -> List[Application]: + pass + + @abstractmethod + def read_installed(self, disk_loader: DiskCacheLoader, keep_workers: bool) -> List[Application]: + pass + + @abstractmethod + def downgrade_app(self, app: Application, root_password: str): + pass + + @abstractmethod + def clean_cache_for(self, app: Application): + pass + + @abstractmethod + def can_downgrade(self): + pass + + @abstractmethod + def update_and_stream(self, app: Application): + pass + + @abstractmethod + def uninstall_and_stream(self, app: Application): + pass + + @abstractmethod + def get_app_type(self) -> str: + pass + + @abstractmethod + def get_info(self, app: Application) -> dict: + pass + + @abstractmethod + def get_history(self, app: Application) -> List[dict]: + pass + + @abstractmethod + def install_and_stream(self, app: Application): + pass + + @abstractmethod + def is_enabled(self) -> bool: + pass + + @abstractmethod + def cache_to_disk(self, app: Application, icon_bytes: bytes, only_icon: bool): + pass + + +from fpakman.core.worker import FlatpakAsyncDataLoaderManager + + +class FlatpakManager(ApplicationManager): + + def __init__(self, api_cache: Cache, disk_cache: bool): + self.api_cache = api_cache self.http_session = requests.Session() - self.lock_db_read = Lock() 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() - def load_full_database(self): + def get_app_type(self): + return FlatpakApplication - self.lock_db_read.acquire() + def _map_to_model(self, app: dict, installed: bool, disk_loader: DiskCacheLoader) -> FlatpakApplication: - try: - res = self.http_session.get(__FLATHUB_API_URL__ + '/apps', timeout=30) + 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 - if res.status_code == 200: - for app in res.json(): - self.cache_apps[app['flatpakAppId']] = app - finally: - self.lock_db_read.release() + api_data = self.api_cache.get(app['id']) - def _request_app_data(self, app_id: str): + expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow() - try: - res = self.http_session.get('{}/apps/{}'.format(__FLATHUB_API_URL__, app_id), timeout=30) + 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) - if res.status_code == 200: - return res.json() - else: - print("Could not retrieve app data for id '{}'. Server response: {}".format(app_id, res.status_code)) - except: - print("Could not retrieve app data for id '{}'. Timeout".format(app_id)) - return None - - def _fill_api_data(self, app: dict): - - api_data = self.cache_apps.get(app['id']) - - if (not app['runtime'] and not api_data) or (api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow()): # if api data is not cached or expired, tries to retrieve it - api_data = self._request_app_data(app['id']) - - if api_data: - - if self.cache_expire > 0: - api_data['expires_at'] = datetime.utcnow() + timedelta(seconds=self.cache_expire) - - self.cache_apps[app['id']] = api_data - - if not api_data: - for attr in ('latest_version', 'icon', 'description'): - if attr not in app: - app[attr] = None else: - app['latest_version'] = api_data.get('currentReleaseVersion') - app['icon'] = api_data.get('iconMobileUrl') + model.fill_cached_data(api_data) - for attr in ('name', 'description'): - if not app.get(attr): - app[attr] = api_data.get(attr) + return model - if app['icon'].startswith('/'): - app['icon'] = __FLATHUB_URL__ + app['icon'] - - def search(self, word: str) -> List[dict]: + def search(self, word: str, disk_loader: DiskCacheLoader) -> List[FlatpakApplication]: res = [] apps_found = flatpak.search(word) if apps_found: - already_read = set() - installed_apps = self.read_installed() + installed_apps = self.read_installed(disk_loader=disk_loader, keep_workers=True) if installed_apps: - for app in apps_found: + for app_found in apps_found: for installed_app in installed_apps: - if app['id'] == installed_app['id']: + if app_found['id'] == installed_app.base_data.id: res.append(installed_app) - already_read.add(app['id']) + already_read.add(app_found['id']) - for app in apps_found: - if app['id'] not in already_read: - app['update'] = False - app['installed'] = False - self._fill_api_data(app) - res.append(app) + 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() return res - def read_installed(self) -> List[dict]: + def read_installed(self, disk_loader: DiskCacheLoader, keep_workers: bool = False) -> List[FlatpakApplication]: self.lock_read.acquire() @@ -112,23 +151,166 @@ class FlatpakManager: available_updates = flatpak.list_updates_as_str() - for app in installed: - self._fill_api_data(app) - app['update'] = app['id'] in available_updates - app['installed'] = True + models = [] - return installed + for app in installed: + model = self._map_to_model(app, True, disk_loader) + model.update = app['id'] in available_updates + models.append(model) + + if not keep_workers: + self.async_data_loader.stop_current_workers() + + return models + + return [] finally: self.lock_read.release() - def downgrade_app(self, app: dict, root_password: str): + def can_downgrade(self): + return True - commits = flatpak.get_app_commits(app['ref'], app['origin']) - commit_idx = commits.index(app['commit']) + 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) + 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): + man = self._get_manager_for(app) + + if man and man.can_downgrade(): + return man.downgrade_app(app, root_password) + else: + raise Exception("downgrade is not possible for {}".format(app.__class__.__name__)) + + def clean_cache_for(self, app: Application): + man = self._get_manager_for(app) + + if man: + return man.clean_cache_for(app) + + def update_and_stream(self, app: Application): + man = self._get_manager_for(app) + + if man: + return man.update_and_stream(app) + + def uninstall_and_stream(self, app: Application): + man = self._get_manager_for(app) + + if man: + return man.uninstall_and_stream(app) + + def install_and_stream(self, app: Application): + man = self._get_manager_for(app) + + if man: + return man.install_and_stream(app) + + def get_info(self, app: Application): + man = self._get_manager_for(app) + + if man: + return man.get_info(app) + + def get_history(self, app: Application): + man = self._get_manager_for(app) + + if man: + return man.get_history(app) + + def get_app_type(self): + return None + + def is_enabled(self): + return True + + def _get_manager_for(self, app: Application) -> ApplicationManager: + man = self.map[app.__class__] + return man if man and man.is_enabled() 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(): + man = self._get_manager_for(app) + + if man: + return man.cache_to_disk(app, icon_bytes=icon_bytes, only_icon=only_icon) diff --git a/fpakman/core/disk.py b/fpakman/core/disk.py new file mode 100644 index 00000000..3097a686 --- /dev/null +++ b/fpakman/core/disk.py @@ -0,0 +1,79 @@ +import json +import os +from pathlib import Path +from threading import Thread, Lock +from typing import List + +from fpakman.core.model import Application, FlatpakApplication +from fpakman.util.cache import Cache + + +class DiskCacheLoader(Thread): + + def __init__(self, enabled: bool, flatpak_api_cache: 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.enabled = enabled + + def run(self): + + if self.enabled: + while True: + if self.apps: + self.lock.acquire() + app = self.apps[0] + del self.apps[0] + self.lock.release() + self.fill_cached_data(app) + elif self.stop: + break + + def add(self, app: Application): + if self.enabled: + if app and app.supports_disk_cache(): + self.lock.acquire() + self.apps.append(app) + self.lock.release() + + def fill_cached_data(self, app: Application): + if self.enabled: + if os.path.exists(app.get_disk_data_path()): + 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) + + +class DiskCacheLoaderFactory: + + def __init__(self, disk_cache: bool, flatpak_api_cache: Cache): + self.disk_cache = disk_cache + self.flatpak_api_cache = flatpak_api_cache + + def new(self): + return DiskCacheLoader(enabled=self.disk_cache, flatpak_api_cache=self.flatpak_api_cache) + + +def save(app: Application, icon_bytes: bytes = None, only_icon: bool = False): + + if app.supports_disk_cache(): + + if not only_icon: + Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True) + + 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)) + + if icon_bytes: + Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True) + + with open(app.get_disk_icon_path(), 'wb+') as f: + f.write(icon_bytes) diff --git a/fpakman/core/exception.py b/fpakman/core/exception.py new file mode 100644 index 00000000..8d00ac1a --- /dev/null +++ b/fpakman/core/exception.py @@ -0,0 +1,3 @@ + +class NoInternetException(Exception): + pass diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 5da0358b..5c27bb2e 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -3,6 +3,8 @@ import subprocess from typing import List from fpakman.core import system +from fpakman.core.exception import NoInternetException +from fpakman.core.model import Application BASE_CMD = 'flatpak' @@ -72,7 +74,7 @@ def is_installed(): def get_version(): - res = system.run_cmd('{} --version'.format(BASE_CMD)) + res = system.run_cmd('{} --version'.format(BASE_CMD), print_error=False) return res.split(' ')[1].strip() if res else None @@ -97,7 +99,7 @@ def update_and_stream(app_ref: str): :param app_ref: :return: """ - return system.stream_cmd([BASE_CMD, 'update', '-y', app_ref]) + return system.cmd_to_subprocess([BASE_CMD, 'update', '-y', app_ref]) def uninstall_and_stream(app_ref: str): @@ -106,7 +108,7 @@ def uninstall_and_stream(app_ref: str): :param app_ref: :return: """ - return system.stream_cmd([BASE_CMD, 'uninstall', app_ref, '-y']) + return system.cmd_to_subprocess([BASE_CMD, 'uninstall', app_ref, '-y']) def list_updates_as_str(): @@ -127,12 +129,19 @@ def downgrade_and_stream(app_ref: str, commit: str, root_password: str): def get_app_commits(app_ref: str, origin: str) -> List[str]: log = system.run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref)) - return re.findall(r'Commit+:\s(.+)', log) + + if log: + return re.findall(r'Commit+:\s(.+)', log) + else: + raise NoInternetException() def get_app_commits_data(app_ref: str, origin: str) -> List[dict]: log = system.run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref)) + if not log: + raise NoInternetException() + res = re.findall(r'(Commit|Subject|Date):\s(.+)', log) commits = [] @@ -210,4 +219,9 @@ def search(word: str) -> List[dict]: def install_and_stream(app_id: str, origin: str): - return system.stream_cmd([BASE_CMD, 'install', origin, app_id, '-y']) + return system.cmd_to_subprocess([BASE_CMD, 'install', origin, app_id, '-y']) + + +def set_default_remotes(): + system.run_cmd('flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo') + diff --git a/fpakman/core/model.py b/fpakman/core/model.py new file mode 100644 index 00000000..c0a31113 --- /dev/null +++ b/fpakman/core/model.py @@ -0,0 +1,150 @@ +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): + READY = 1 + LOADING_DATA = 2 + + +class ApplicationData: + + def __init__(self, id: str, version: str, name: str = None, description: str = None, latest_version: str = None, icon_url: str = None): + self.id = id + self.name = name + self.version = version + self.description = description + self.latest_version = latest_version + self.icon_url = icon_url + + +class Application(ABC): + + def __init__(self, base_data: ApplicationData, status: ApplicationStatus = ApplicationStatus.READY, installed: bool = False, update: bool = False): + self.base_data = base_data + self.status = status + self.installed = installed + self.update = update + + @abstractmethod + def has_history(self): + pass + + @abstractmethod + def has_info(self): + pass + + @abstractmethod + def can_be_downgraded(self): + pass + + @abstractmethod + def can_be_uninstalled(self): + pass + + @abstractmethod + def can_be_installed(self): + pass + + @abstractmethod + def get_type(self): + pass + + def get_default_icon_path(self): + return resource.get_path('img/logo.svg') + + @abstractmethod + 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() + + def get_disk_cache_path(self): + return '{}/{}'.format(flatpak_cache_path, self.base_data.id) + + def get_disk_icon_path(self): + return '{}/icon.png'.format(self.get_disk_cache_path()) + + def get_disk_data_path(self): + return '{}/data.json'.format(self.get_disk_cache_path()) + + 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/structure.py b/fpakman/core/structure.py new file mode 100644 index 00000000..fd90cf53 --- /dev/null +++ b/fpakman/core/structure.py @@ -0,0 +1,12 @@ +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 462f9a66..38edbaba 100644 --- a/fpakman/core/system.py +++ b/fpakman/core/system.py @@ -5,13 +5,26 @@ from typing import List from fpakman.core import resource -def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False) -> str: - res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, env={'LANG': 'en'}) +def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False, print_error: bool = True) -> str: + args = { + "shell": True, + "stdout": subprocess.PIPE, + "env": {'LANG': 'en'} + } + + if not print_error: + args["stderr"] = subprocess.DEVNULL + + res = subprocess.run(cmd, **args) return res.stdout.decode() if ignore_return_code or res.returncode == expected_code else None def stream_cmd(cmd: List[str]): - return subprocess.Popen(cmd, stdout=subprocess.PIPE, env={'LANG': 'en'}).stdout + return cmd_to_subprocess(cmd).stdout + + +def cmd_to_subprocess(cmd: List[str]): + return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env={'LANG': 'en'}) def notify_user(msg: str, icon_path: str = resource.get_path('img/logo.svg')): diff --git a/fpakman/core/worker.py b/fpakman/core/worker.py new file mode 100644 index 00000000..f9648a37 --- /dev/null +++ b/fpakman/core/worker.py @@ -0,0 +1,140 @@ +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 + + +class FlatpakAsyncDataLoader(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 + self.id_ = '{}#{}'.format(self.__class__.__name__, id(self)) + self.stop = False + self.to_persist = {} # stores all data loaded by the instance + self.manager = manager + + def log_msg(self, msg: str, color: int = None): + final_msg = StringIO() + + if color: + final_msg.write(str(color)) + + final_msg.write('[{}] '.format(self.id_)) + + final_msg.write(msg) + + if color: + final_msg.write(Fore.RESET) + + 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/locale/en b/fpakman/resources/locale/en index 1cb4c76f..60f3c90f 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -1,6 +1,5 @@ -manage_window.title=Flatpak Application Manager +manage_window.title=Applications Manager manage_window.columns.latest_version=Latest Version -manage_window.columns.installed=Installed manage_window.columns.update=Upgrade ? manage_window.apps_table.row.actions.info=Information manage_window.apps_table.row.actions.history=History @@ -25,8 +24,7 @@ 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 -popup.flatpak_not_installed.title=Error -popup.flatpak_not_installed.msg=flatpak seems not to be installed. Exiting +manage_window.checkbox.show_details=Show details popup.root.title=Requires root privileges popup.root.password=Password popup.root.bad_password.title=Error @@ -42,7 +40,7 @@ tray.action.manage=Manage applications tray.action.exit=Exit tray.action.about=About tray.action.refreshing=Refreshing -notification.new_updates={} updates +notification.new_updates=Updates flatpak.info.arch=arch flatpak.info.branch=branch flatpak.info.collection=collection @@ -69,4 +67,12 @@ about.info.rate=If this tool is useful for you, give it a star on Github to keep yes=yes no=no version.updated=updated -version.outdated=outdated \ No newline at end of file +version.outdated=outdated +name=name +version=version +latest_version=latest version +description=description +type=type +installed=installed +others=others +internet.required=Internet connection is required diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 19617178..97e7e9b9 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -1,4 +1,4 @@ -manage_window.title=Administrador de Aplicativos Flatpak +manage_window.title=Administrador de Aplicativos manage_window.columns.latest_version=Ultima Versión manage_window.columns.update=Actualizar ? manage_window.columns.installed=Instalado @@ -25,8 +25,7 @@ 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 -popup.flatpak_not_installed.title=Error -popup.flatpak_not_installed.msg=flatpak no parece estar instalado. Saliendo +manage_window.checkbox.show_details=Mostrar detalles popup.root.title=Requiere privilegios de root popup.root.password=Contraseña popup.root.bad_password.title=Error @@ -42,7 +41,7 @@ tray.action.manage=Administrar aplicativos tray.action.exit=Salir tray.action.about=Sobre tray.action.refreshing=Recargando -notification.new_updates=Actualizaciones {} +notification.new_updates=Actualizaciones flatpak.info.arch=arquitectura flatpak.info.branch=rama flatpak.info.collection=colección @@ -69,4 +68,12 @@ about.info.rate=Si esta herramienta es útil para ti, dale una estrella en Githu yes=sí no=no version.updated=actualizada -version.outdated=desactualizada \ No newline at end of file +version.outdated=desactualizada +name=nombre +version=versión +latest_version=ultima versión +description=descripción +type=tipo +installed=instalado +others=otros +internet.required=Se requiere conexión a internet diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 7cddb50c..cd4212ce 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -1,4 +1,4 @@ -manage_window.title=Gerenciador de Aplicativos Flatpak +manage_window.title=Gerenciador de Aplicativos manage_window.columns.latest_version=Última Versão manage_window.columns.update=Atualizar ? manage_window.columns.installed=Instalado @@ -25,8 +25,7 @@ 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 -popup.flatpak_not_installed.title=Erro -popup.flatpak_not_installed.msg=flatpak não parece estar instalado. Saindo +manage_window.checkbox.show_details=Mostrar detalhes popup.root.title=Requer privilégios de root popup.root.password=Senha popup.root.bad_password.title=Erro @@ -42,7 +41,7 @@ tray.action.manage=Gerenciar aplicativos tray.action.exit=Sair tray.action.about=Sobre tray.action.refreshing=Recarregando -notification.new_updates=Atualizações {} +notification.new_updates=Atualizações flatpak.info.arch=arquitetura flatpak.info.branch=ramo flatpak.info.collection=coleção @@ -69,4 +68,12 @@ about.info.rate=Se essa ferramenta é útil para você, dê uma estrela no Githu yes=sim no=não version.updated=atualizada -version.outdated=desatualizada \ No newline at end of file +version.outdated=desatualizada +name=nome +version=versão +latest_version=última versão +description=descrição +type=tipo +installed=instalado +others=outros +internet.required=É necessário estar conectado a Internet diff --git a/fpakman/util/__init__.py b/fpakman/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fpakman/util/cache.py b/fpakman/util/cache.py new file mode 100644 index 00000000..971909bb --- /dev/null +++ b/fpakman/util/cache.py @@ -0,0 +1,63 @@ +from datetime import datetime, timedelta +from threading import Lock + + +class Cache: + + def __init__(self, expiration_time: int): + self.expiration_time = expiration_time + self._cache = {} + self.lock = Lock() + + def is_enabled(self): + return self.expiration_time < 0 or self.expiration_time > 0 + + def add(self, key: str, val: object): + if key and self.is_enabled(): + self.lock.acquire() + self._add(key, val) + 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} + + def add_non_existing(self, key: str, val: object): + + if key and self. is_enabled(): + self.lock.acquire() + cur_val = self.get(key) + + if cur_val is None: + self._add(key, val) + + self.lock.release() + + def get(self, key: str): + if key and self.is_enabled(): + val = self._cache.get(key) + + if val: + expiration = val.get('expires_at') + + if expiration and expiration <= datetime.utcnow(): + self.lock.acquire() + del self._cache[key] + self.lock.release() + return None + + return val['val'] + + def delete(self, key): + if key and self.is_enabled(): + if key in self._cache: + self.lock.acquire() + del self._cache[key] + self.lock.release() + + def keys(self): + return set(self._cache.keys()) if self.is_enabled() else set() + + def clean_expired(self): + if self.is_enabled(): + for key in self.keys(): + self.get(key) diff --git a/fpakman/util/memory.py b/fpakman/util/memory.py new file mode 100644 index 00000000..35ec2659 --- /dev/null +++ b/fpakman/util/memory.py @@ -0,0 +1,25 @@ +import time +from threading import Thread +from typing import List + +from fpakman.util.cache import Cache +import gc + + +class CacheCleaner(Thread): + + def __init__(self, caches: List[Cache], check_interval: int = 15): + super(CacheCleaner, self).__init__(daemon=True) + self.caches = [c for c in caches if c.is_enabled()] + self.check_interval = check_interval + + def run(self): + + if self.caches: + while True: + for cache in self.caches: + cache.clean_expired() + + gc.collect() + time.sleep(self.check_interval) + diff --git a/fpakman/core/util.py b/fpakman/util/util.py similarity index 100% rename from fpakman/core/util.py rename to fpakman/util/util.py diff --git a/fpakman/view/qt/about.py b/fpakman/view/qt/about.py index b9e319db..87810957 100644 --- a/fpakman/view/qt/about.py +++ b/fpakman/view/qt/about.py @@ -2,7 +2,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QPixmap from PyQt5.QtWidgets import QVBoxLayout, QDialog, QLabel -from fpakman import __version__ +from fpakman import __version__, __app_name__ from fpakman.core import resource PROJECT_URL = 'https://github.com/vinifmor/fpakman' @@ -22,7 +22,7 @@ class AboutDialog(QDialog): label_logo.setAlignment(Qt.AlignCenter) layout.addWidget(label_logo) - label_name = QLabel('fpakman ( {} {} )'.format(locale_keys['flatpak.info.version'].lower(), __version__)) + label_name = QLabel('{} ( {} {} )'.format(__app_name__, locale_keys['flatpak.info.version'].lower(), __version__)) label_name.setStyleSheet('font-weight: bold;') label_name.setAlignment(Qt.AlignCenter) layout.addWidget(label_name) diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index e27a9f4a..6e5f4f7b 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -1,20 +1,25 @@ +import os +from threading import Lock from typing import List from PyQt5.QtCore import Qt, QUrl from PyQt5.QtGui import QPixmap, QIcon, QColor, QCursor -from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ QHeaderView, QLabel -from fpakman.core import resource, util +from fpakman.core import resource +from fpakman.core.model import ApplicationStatus +from fpakman.util.cache import Cache from fpakman.view.qt import dialog +from fpakman.view.qt.view_model import ApplicationView, ApplicationViewStatus class UpdateToggleButton(QToolButton): - def __init__(self, model: dict, root: QWidget, locale_keys: dict, checked: bool = True): + def __init__(self, app_view: ApplicationView, root: QWidget, locale_keys: dict, checked: bool = True): super(UpdateToggleButton, self).__init__() - self.app = model + self.app_view = app_view self.root = root self.setCheckable(True) self.clicked.connect(self.change_state) @@ -28,25 +33,24 @@ class UpdateToggleButton(QToolButton): self.click() def change_state(self, not_checked: bool): - self.app['update_checked'] = not not_checked + self.app_view.update_checked = not not_checked self.setIcon(self.icon_on if not not_checked else self.icon_off) self.root.change_update_state() class AppsTable(QTableWidget): - def __init__(self, parent: QWidget): + def __init__(self, parent: QWidget, icon_cache: Cache, disk_cache: bool): super(AppsTable, self).__init__() self.setParent(parent) self.window = parent - self.column_names = [parent.locale_keys[key].capitalize() for key in ['flatpak.info.name', - 'flatpak.info.version', - 'manage_window.columns.latest_version', - 'flatpak.info.branch', - 'flatpak.info.arch', - 'flatpak.info.id', - 'flatpak.info.origin', - 'manage_window.columns.installed', + self.disk_cache = disk_cache + self.column_names = [parent.locale_keys[key].capitalize() for key in ['name', + 'version', + 'latest_version', + 'flatpak.info.description', + 'type', + 'installed', 'manage_window.columns.update']] self.setColumnCount(len(self.column_names)) self.setFocusPolicy(Qt.NoFocus) @@ -56,11 +60,13 @@ class AppsTable(QTableWidget): self.setHorizontalHeaderLabels(self.column_names) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.icon_flathub = QIcon(resource.get_path('img/flathub.svg')) + self.icon_logo = QIcon(resource.get_path('img/logo.svg')) self.network_man = QNetworkAccessManager() - self.network_man.finished.connect(self._load_icon) + self.network_man.finished.connect(self._load_icon_and_cache) - self.icon_cache = {} + self.icon_cache = icon_cache + self.lock_async_data = Lock() def contextMenuEvent(self, QContextMenuEvent): # selected row right click event @@ -68,56 +74,85 @@ class AppsTable(QTableWidget): menu_row = QMenu() - if app['model']['installed']: - 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: - action_history = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.history"]) - action_history.setIcon(QIcon(resource.get_path('img/history.svg'))) - action_history.triggered.connect(self._get_app_history) - menu_row.addAction(action_history) + 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) - action_uninstall = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.uninstall"]) - action_uninstall.setIcon(QIcon(resource.get_path('img/uninstall.svg'))) - action_uninstall.triggered.connect(self._uninstall_app) - menu_row.addAction(action_uninstall) + if app.model.has_history(): + action_history = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.history"]) + action_history.setIcon(QIcon(resource.get_path('img/history.svg'))) + action_history.triggered.connect(self._get_app_history) + menu_row.addAction(action_history) - if not app['model']['runtime']: # not available for runtimes + if app.model.can_be_uninstalled(): + action_uninstall = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.uninstall"]) + action_uninstall.setIcon(QIcon(resource.get_path('img/uninstall.svg'))) + action_uninstall.triggered.connect(self._uninstall_app) + menu_row.addAction(action_uninstall) + + if app.model.can_be_downgraded(): action_downgrade = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.downgrade"]) action_downgrade.triggered.connect(self._downgrade_app) action_downgrade.setIcon(QIcon(resource.get_path('img/downgrade.svg'))) menu_row.addAction(action_downgrade) else: - action_install = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.install"]) - action_install.setIcon(QIcon(resource.get_path('img/install.svg'))) - action_install.triggered.connect(self._install_app) - menu_row.addAction(action_install) + if app.model.can_be_installed(): + action_install = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.install"]) + action_install.setIcon(QIcon(resource.get_path('img/install.svg'))) + action_install.triggered.connect(self._install_app) + menu_row.addAction(action_install) menu_row.adjustSize() menu_row.popup(QCursor.pos()) menu_row.exec_() - def get_selected_app(self): + def fill_async_data(self): + + self.lock_async_data.acquire() + + if self.window.apps: + + 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))) + + 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) + app_v.status = ApplicationViewStatus.READY + + self.window.resize_and_center() + + self.lock_async_data.release() + + def get_selected_app(self) -> ApplicationView: return self.window.apps[self.currentRow()] - def get_selected_app_icon(self): + def get_selected_app_icon(self) -> QIcon: return self.item(self.currentRow(), 0).icon() def _uninstall_app(self): selected_app = self.get_selected_app() if dialog.ask_confirmation(title=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.title'], - body=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app['model']['name']), + body=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app.model.base_data.name), locale_keys=self.window.locale_keys): - self.window.uninstall_app(selected_app['model']['ref']) + self.window.uninstall_app(selected_app) def _downgrade_app(self): selected_app = self.get_selected_app() if dialog.ask_confirmation(title=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade'], - body=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app['model']['name']), + body=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app.model.base_data.name), locale_keys=self.window.locale_keys): self.window.downgrade_app(selected_app) @@ -130,96 +165,100 @@ class AppsTable(QTableWidget): def _install_app(self): self.window.install_app(self.get_selected_app()) - def _load_icon(self, http_response): + def _load_icon_and_cache(self, http_response): icon_url = http_response.url().toString() - pixmap = QPixmap() - pixmap.loadFromData(http_response.readAll()) - icon = QIcon(pixmap) - self.icon_cache[icon_url] = icon + + icon_data = self.icon_cache.get(icon_url) + icon_was_cached = True + + if not icon_data: + icon_was_cached = False + pixmap = QPixmap() + icon_bytes = http_response.readAll() + pixmap.loadFromData(icon_bytes) + icon = QIcon(pixmap) + icon_data = {'icon': icon, 'bytes': icon_bytes} + self.icon_cache.add(icon_url, icon_data) for idx, app in enumerate(self.window.apps): - if app['model']['icon'] == icon_url: - self.item(idx, 0).setIcon(icon) - self.window.resize_and_center() - break + if app.model.base_data.icon_url == icon_url: + col_name = self.item(idx, 0) + col_name.setIcon(icon_data['icon']) - def update_apps(self, apps: List[dict]): + if self.disk_cache and app.model.supports_disk_cache(): + if not icon_was_cached or not os.path.exists(app.model.get_disk_icon_path()): + self.window.manager.cache_to_disk(app=app.model, icon_bytes=icon_data['bytes'], only_icon=True) + + def update_apps(self, app_views: List[ApplicationView]): self.setEnabled(True) - self.setRowCount(len(apps) if apps else 0) - - if apps: - for idx, app in enumerate(apps): - - tooltip = util.strip_html(app['model']['description']) if app['model']['description'] else None - - col_name = QTableWidgetItem() - col_name.setText(app['model']['name']) - col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_name.setToolTip(tooltip) - - if not app['model']['icon']: - col_name.setIcon(self.icon_flathub) - else: - cached_icon = self.icon_cache.get(app['model']['icon']) - - if cached_icon: - col_name.setIcon(cached_icon) - else: - col_name.setIcon(self.icon_flathub) - self.network_man.get(QNetworkRequest(QUrl(app['model']['icon']))) + self.setRowCount(len(app_views) if app_views else 0) + 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 = QTableWidgetItem() - col_version.setText(app['model']['version']) - col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_version.setToolTip(tooltip) - self.setItem(idx, 1, col_version) + col_version = QLabel(app_v.model.base_data.version) + col_version.setAlignment(Qt.AlignCenter) + self.setCellWidget(idx, 1, col_version) - col_release = QTableWidgetItem() - col_release.setText(app['model']['latest_version']) - col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_release.setToolTip(tooltip) - self.setItem(idx, 2, col_release) + col_release = QLabel(app_v.get_async_attr('latest_version')) + col_release.setAlignment(Qt.AlignCenter) + self.setCellWidget(idx, 2, col_release) - if app['model']['version'] and app['model']['latest_version'] and app['model']['version'] < app['model']['latest_version']: - col_release.setForeground(QColor('orange')) + 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_branch = QTableWidgetItem() - col_branch.setText(app['model']['branch']) - col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_branch.setToolTip(tooltip) - self.setItem(idx, 3, col_branch) + col_description = QTableWidgetItem() + col_description.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self._set_col_description(col_description, app_v) - col_arch = QTableWidgetItem() - col_arch.setText(app['model']['arch']) - col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_arch.setToolTip(tooltip) - self.setItem(idx, 4, col_arch) + self.setItem(idx, 3, col_description) - col_id = QTableWidgetItem() - col_id.setText(app['model']['id']) - col_id.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_id.setToolTip(tooltip) - self.setItem(idx, 5, col_id) - - col_origin = QTableWidgetItem() - col_origin.setText(app['model']['origin']) - col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_origin.setToolTip(tooltip) - self.setItem(idx, 6, col_origin) + 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['model']['installed'] else 'red_cross'))))) - col_installed.setToolTip(tooltip) + 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, 7, col_installed) + self.setCellWidget(idx, 5, col_installed) - col_update = UpdateToggleButton(app, self.window, self.window.locale_keys, app['model']['update']) if app['model']['update'] else None - self.setCellWidget(idx, 8, col_update) + 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) + + def _gen_col_name(self, 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) + + 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: + icon_bytes = f.read() + pixmap = QPixmap() + 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: + icon_data = self.icon_cache.get(app_v.model.base_data.icon_url) + icon = icon_data['icon'] if icon_data else QIcon(app_v.model.get_default_icon_path()) + + col.setIcon(icon) + return col + + def _set_col_description(self, col: QTableWidgetItem, app_v: ApplicationView): + desc = app_v.get_async_attr('description', strip_html=True) + + if desc: + col.setText(desc[0:25] + '...') + + if app_v.model.status == ApplicationStatus.READY: + col.setToolTip(desc) def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): header_horizontal = self.horizontalHeader() for i in range(self.columnCount()): - header_horizontal.setSectionResizeMode(i, policy) + header_horizontal.setSectionResizeMode(i, policy) diff --git a/fpakman/view/qt/history.py b/fpakman/view/qt/history.py index 8cb5877f..ae77232e 100644 --- a/fpakman/view/qt/history.py +++ b/fpakman/view/qt/history.py @@ -8,10 +8,10 @@ from PyQt5.QtWidgets import QDialog, QVBoxLayout, QTableWidget, QTableWidgetItem class HistoryDialog(QDialog): - def __init__(self, app: dict, app_icon: QIcon, locale_keys: dict): + def __init__(self, app: dict, app_icon: QIcon, locale_keys: dict): super(HistoryDialog, self).__init__() - self.setWindowTitle('{} - {} '.format(locale_keys['popup.history.title'], app['model']['name'])) + self.setWindowTitle('{} - {} '.format(locale_keys['popup.history.title'], app['model'].base_data.name)) self.setWindowIcon(app_icon) layout = QVBoxLayout() @@ -23,13 +23,13 @@ class HistoryDialog(QDialog): table_history.verticalHeader().setVisible(False) table_history.setAlternatingRowColors(True) - table_history.setColumnCount(len(app['commits'][0])) - table_history.setRowCount(len(app['commits'])) - table_history.setHorizontalHeaderLabels([locale_keys['flatpak.info.' + key].capitalize() for key in sorted(app['commits'][0].keys())]) + table_history.setColumnCount(len(app['history'][0])) + table_history.setRowCount(len(app['history'])) + table_history.setHorizontalHeaderLabels([locale_keys['flatpak.info.' + key].capitalize() for key in sorted(app['history'][0].keys())]) - for row, commit in enumerate(app['commits']): + for row, commit in enumerate(app['history']): - current_app_commit = app['model']['commit'] == commit['commit'] + current_app_commit = app['model'].commit == commit['commit'] for col, key in enumerate(sorted(commit.keys())): item = QTableWidgetItem() diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index a462531b..0923fcfe 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -1,4 +1,5 @@ import time +from threading import Lock from typing import List from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication, Qt @@ -6,7 +7,9 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu from fpakman.core import resource, system -from fpakman.core.controller import FlatpakManager +from fpakman.core.controller import FlatpakManager, ApplicationManager +from fpakman.core.model import Application +from fpakman.util.cache import Cache from fpakman.view.qt.about import AboutDialog from fpakman.view.qt.window import ManageWindow @@ -15,7 +18,7 @@ class UpdateCheck(QThread): signal = pyqtSignal(list) - def __init__(self, manager: FlatpakManager, check_interval: int, parent=None): + def __init__(self, manager: ApplicationManager, check_interval: int, parent=None): super(UpdateCheck, self).__init__(parent) self.check_interval = check_interval self.manager = manager @@ -26,7 +29,7 @@ class UpdateCheck(QThread): apps = self.manager.read_installed() - updates = [app for app in apps if app['update']] + updates = [app for app in apps if app.update] if updates: self.signal.emit(updates) @@ -34,25 +37,14 @@ class UpdateCheck(QThread): time.sleep(self.check_interval) -class LoadDatabase(QThread): - - signal_finished = pyqtSignal() - - def __init__(self, manager: FlatpakManager, parent=None): - super(LoadDatabase, self).__init__(parent) - self.manager = manager - - def run(self): - self.manager.load_full_database() - self.signal_finished.emit() - - class TrayIcon(QSystemTrayIcon): - def __init__(self, locale_keys: dict, manager: FlatpakManager, check_interval: int = 60, update_notification: bool = True): + def __init__(self, locale_keys: dict, manager: ApplicationManager, icon_cache: Cache, disk_cache: bool, 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.icon_default = QIcon(resource.get_path('img/logo.png')) self.icon_update = QIcon(resource.get_path('img/logo_update.png')) @@ -60,12 +52,8 @@ class TrayIcon(QSystemTrayIcon): self.menu = QMenu() - self.action_refreshing = self.menu.addAction(self.locale_keys['tray.action.refreshing'] + '...') - self.action_refreshing.setEnabled(False) - self.action_manage = self.menu.addAction(self.locale_keys['tray.action.manage']) self.action_manage.triggered.connect(self.show_manage_window) - self.action_manage.setVisible(False) self.action_about = self.menu.addAction(self.locale_keys['tray.action.about']) self.action_about.triggered.connect(self.show_about) @@ -82,47 +70,47 @@ class TrayIcon(QSystemTrayIcon): self.dialog_about = None - self.thread_database = LoadDatabase(manager) - self.thread_database.signal_finished.connect(self._update_menu) self.last_updates = set() self.update_notification = update_notification + self.lock_notify = Lock() - def load_database(self): - self.thread_database.start() + def notify_updates(self, updates: List[Application]): - def _update_menu(self): - self.action_refreshing.setVisible(False) - self.action_manage.setVisible(True) + self.lock_notify.acquire() - def notify_updates(self, updates: List[dict]): + try: + if len(updates) > 0: - if len(updates) > 0: + update_keys = {'{}:{}'.format(app.base_data.id, app.base_data.version) for app in updates} - update_keys = {'{}:{}'.format(app['id'], app['latest_version']) for app in updates} + new_icon = self.icon_update - new_icon = self.icon_update + if update_keys.difference(self.last_updates): + self.last_updates = update_keys + msg = '{}: {}'.format(self.locale_keys['notification.new_updates'], len(updates)) + self.setToolTip(msg) - if update_keys.difference(self.last_updates): - self.last_updates = update_keys - msg = '{}: {}'.format(self.locale_keys['notification.new_updates'].format('Flatpak'), len(updates)) - self.setToolTip(msg) + if self.update_notification: + system.notify_user(msg) - if self.update_notification: - system.notify_user(msg) + else: + new_icon = self.icon_default + self.setToolTip(None) - else: - new_icon = self.icon_default - self.setToolTip(None) + if self.icon().cacheKey() != new_icon.cacheKey(): # changes the icon if needed + self.setIcon(new_icon) - if self.icon().cacheKey() != new_icon.cacheKey(): # changes the icon if needed - self.setIcon(new_icon) + finally: + self.lock_notify.release() def show_manage_window(self): if self.manage_window is None: self.manage_window = ManageWindow(locale_keys=self.locale_keys, manager=self.manager, - tray_icon=self) + icon_cache=self.icon_cache, + tray_icon=self, + disk_cache=self.disk_cache) if self.manage_window.isMinimized(): self.manage_window.setWindowState(Qt.WindowNoState) diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index d289ab5c..16335302 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -1,37 +1,62 @@ import time +from datetime import datetime, timedelta +from typing import List +import requests from PyQt5.QtCore import QThread, pyqtSignal -from fpakman.core import flatpak -from fpakman.core.controller import FlatpakManager +from fpakman.core.controller import ApplicationManager +from fpakman.core.exception import NoInternetException +from fpakman.core.model import ApplicationStatus +from fpakman.util.cache import Cache from fpakman.view.qt import dialog +from fpakman.view.qt.view_model import ApplicationView class UpdateSelectedApps(QThread): - signal_finished = pyqtSignal() + signal_finished = pyqtSignal(bool) signal_output = pyqtSignal(str) - def __init__(self): + def __init__(self, manager: ApplicationManager, apps_to_update: List[ApplicationView] = None): super(UpdateSelectedApps, self).__init__() - self.refs_to_update = [] + self.apps_to_update = apps_to_update + self.manager = manager def run(self): - for app_ref in self.refs_to_update: - for output in flatpak.update_and_stream(app_ref): + error = False + + for app in self.apps_to_update: + + subproc = self.manager.update_and_stream(app.model) + + self.signal_output.emit(' '.join(subproc.args) + '\n') + + for output in subproc.stdout: line = output.decode().strip() if line: self.signal_output.emit(line) - self.signal_finished.emit() + 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: + break + + self.signal_finished.emit(not error) class RefreshApps(QThread): signal = pyqtSignal(list) - def __init__(self, manager: FlatpakManager): + def __init__(self, manager: ApplicationManager): super(RefreshApps, self).__init__() self.manager = manager @@ -43,85 +68,110 @@ class UninstallApp(QThread): signal_finished = pyqtSignal() signal_output = pyqtSignal(str) - def __init__(self): + def __init__(self, manager: ApplicationManager, icon_cache: Cache, app: ApplicationView = None): super(UninstallApp, self).__init__() - self.app_ref = None + self.app = app + self.manager = manager + self.icon_cache = icon_cache def run(self): - if self.app_ref: - for output in flatpak.uninstall_and_stream(self.app_ref): + if self.app: + subproc = self.manager.uninstall_and_stream(self.app.model) + self.signal_output.emit(' '.join(subproc.args) + '\n') + + 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: + self.icon_cache.delete(self.app.model.base_data.icon_url) + self.manager.clean_cache_for(self.app.model) + self.signal_finished.emit() class DowngradeApp(QThread): - signal_finished = pyqtSignal() + signal_finished = pyqtSignal(bool) signal_output = pyqtSignal(str) - def __init__(self, manager: FlatpakManager, locale_keys: dict): + def __init__(self, manager: ApplicationManager, locale_keys: dict, app: ApplicationView = None): super(DowngradeApp, self).__init__() self.manager = manager - self.app = None - self.root_password = None + self.app = app self.locale_keys = locale_keys + self.root_password = None def run(self): if self.app: - stream = self.manager.downgrade_app(self.app['model'], self.root_password) + success = True + try: + stream = self.manager.downgrade_app(self.app.model, self.root_password) - if stream 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 self.manager.downgrade_app(self.app['model'], self.root_password): - line = output.decode().strip() - if line: - self.signal_output.emit(line) - - self.app = None - self.root_password = None - self.signal_finished.emit() + if stream 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) + except (requests.exceptions.ConnectionError, NoInternetException): + success = False + self.signal_output.emit(self.locale_keys['internet.required']) + finally: + self.app = None + self.root_password = None + self.signal_finished.emit(success) class GetAppInfo(QThread): signal_finished = pyqtSignal(dict) - def __init__(self): + def __init__(self, manager: ApplicationManager, app: ApplicationView = None): super(GetAppInfo, self).__init__() - self.app = None + self.app = app + self.manager = manager def run(self): if self.app: - app_info = flatpak.get_app_info_fields(self.app['model']['id'], self.app['model']['branch']) - app_info['name'] = self.app['model']['name'] - app_info['type'] = 'runtime' if self.app['model']['runtime'] else 'app' - app_info['description'] = self.app['model']['description'] - self.signal_finished.emit(app_info) + self.signal_finished.emit(self.manager.get_info(self.app.model)) self.app = None class GetAppHistory(QThread): signal_finished = pyqtSignal(dict) - def __init__(self): + def __init__(self, manager: ApplicationManager, locale_keys: dict, app: ApplicationView = None): super(GetAppHistory, self).__init__() - self.app = None + self.app = app + self.manager = manager + self.locale_keys = locale_keys def run(self): if self.app: - commits = flatpak.get_app_commits_data(self.app['model']['ref'], self.app['model']['origin']) - self.signal_finished.emit({'model': self.app['model'], 'commits': commits}) - self.app = None + try: + res = {'model': self.app.model, 'history': self.manager.get_history(self.app.model)} + self.signal_finished.emit(res) + except (requests.exceptions.ConnectionError, NoInternetException): + self.signal_finished.emit({'error': self.locale_keys['internet.required']}) + finally: + self.app = None class SearchApps(QThread): signal_finished = pyqtSignal(list) - def __init__(self, manager: FlatpakManager): + def __init__(self, manager: ApplicationManager): super(SearchApps, self).__init__() self.word = None self.manager = manager @@ -138,23 +188,46 @@ class SearchApps(QThread): class InstallApp(QThread): - signal_finished = pyqtSignal() + signal_finished = pyqtSignal(bool) signal_output = pyqtSignal(str) - def __init__(self): + def __init__(self, manager: ApplicationManager, disk_cache: bool, icon_cache: Cache, app: ApplicationView = None): super(InstallApp, self).__init__() - self.app = None + self.app = app + self.manager = manager + self.icon_cache = icon_cache + self.disk_cache = disk_cache def run(self): if self.app: - for output in flatpak.install_and_stream(self.app['model']['id'], self.app['model']['origin']): + subproc = self.manager.install_and_stream(self.app.model) + self.signal_output.emit(' '.join(subproc.args) + '\n') + + for output in subproc.stdout: line = output.decode().strip() if line: self.signal_output.emit(line) - self.app = None - self.signal_finished.emit() + error = False + + 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) class AnimateProgress(QThread): @@ -184,3 +257,38 @@ class AnimateProgress(QThread): time.sleep(0.05) self.progress_value = 0 + + +class VerifyModels(QThread): + + signal_updates = pyqtSignal() + + def __init__(self, apps: List[ApplicationView] = None): + super(VerifyModels, self).__init__() + self.apps = apps + + def run(self): + + if self.apps: + + stop_at = datetime.utcnow() + timedelta(seconds=30) + last_ready = 0 + + while True: + current_ready = 0 + + for app in self.apps: + current_ready += 1 if app.model.status == ApplicationStatus.READY else 0 + + if current_ready > last_ready: + last_ready = current_ready + self.signal_updates.emit() + + if current_ready == len(self.apps): + self.signal_updates.emit() + break + + if stop_at <= datetime.utcnow(): + break + + self.apps = None diff --git a/fpakman/view/qt/view_model.py b/fpakman/view/qt/view_model.py new file mode 100644 index 00000000..6a090bf7 --- /dev/null +++ b/fpakman/view/qt/view_model.py @@ -0,0 +1,27 @@ +from enum import Enum + +from fpakman.util import util +from fpakman.core.model import Application, ApplicationStatus, FlatpakApplication + + +class ApplicationViewStatus(Enum): + LOADING = 0 + READY = 1 + + +class ApplicationView: + + def __init__(self, model: Application, visible: bool = True): + self.model = model + self.update_checked = model.update + self.visible = visible + self.status = ApplicationViewStatus.LOADING + + def get_async_attr(self, attr: str, strip_html: bool = False, default: str = '...'): + + if isinstance(self.model, FlatpakApplication) and self.model.runtime: + 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 + + return util.strip_html(res) if res and strip_html else res diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 9a28625c..a9926e84 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -8,24 +8,25 @@ from PyQt5.QtGui import QIcon, QWindowStateChangeEvent, QPixmap from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ QSizePolicy, QLabel, QPlainTextEdit, QLineEdit, QProgressBar -from fpakman.core import resource, flatpak -from fpakman.core.controller import FlatpakManager -from fpakman.view.qt import dialog +from fpakman.core import resource +from fpakman.core.controller import ApplicationManager +from fpakman.core.model import Application +from fpakman.util.cache import Cache from fpakman.view.qt.apps_table import AppsTable 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 + GetAppHistory, SearchApps, InstallApp, AnimateProgress, VerifyModels +from fpakman.view.qt.view_model import ApplicationView DARK_ORANGE = '#FF4500' class ManageWindow(QWidget): - __BASE_HEIGHT__ = 400 - def __init__(self, locale_keys: dict, manager: FlatpakManager, tray_icon=None): + def __init__(self, locale_keys: dict, icon_cache: Cache, manager: ApplicationManager, disk_cache: bool, tray_icon=None): super(ManageWindow, self).__init__() self.locale_keys = locale_keys self.manager = manager @@ -34,9 +35,10 @@ class ManageWindow(QWidget): self.working = False # restrict the number of threaded actions self.apps = [] self.label_flatpak = None + self.icon_cache = icon_cache + self.disk_cache = disk_cache self.icon_flathub = QIcon(resource.get_path('img/logo.svg')) - self._check_flatpak_installed() self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) self.setWindowTitle(locale_keys['manage_window.title']) self.setWindowIcon(self.icon_flathub) @@ -47,19 +49,16 @@ class ManageWindow(QWidget): self.toolbar_search = QToolBar() self.toolbar_search.setStyleSheet("spacing: 0px;") self.toolbar_search.setContentsMargins(0, 0, 0, 0) - - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - - self.toolbar_search.addWidget(spacer) + 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() self.input_search.setFrame(False) - self.input_search.setPlaceholderText(self.locale_keys['window_manage.input_search.placeholder']+"...") + self.input_search.setPlaceholderText(self.locale_keys['window_manage.input_search.placeholder'] + "...") self.input_search.setToolTip(self.locale_keys['window_manage.input_search.tooltip']) self.input_search.setStyleSheet("QLineEdit { background-color: white; color: grey; spacing: 0;}") self.input_search.returnPressed.connect(self.search) @@ -70,9 +69,7 @@ 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) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.toolbar_search.addWidget(spacer) + self.toolbar_search.addWidget(self._new_spacer()) self.layout.addWidget(self.toolbar_search) toolbar = QToolBar() @@ -83,23 +80,19 @@ class ManageWindow(QWidget): self.checkbox_only_apps.stateChanged.connect(self.filter_only_apps) toolbar.addWidget(self.checkbox_only_apps) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - toolbar.addWidget(spacer) + toolbar.addWidget(self._new_spacer()) self.label_status = QLabel() self.label_status.setText('') - self.label_status.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE)) + self.label_status.setStyleSheet("font-weight: bold") toolbar.addWidget(self.label_status) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - toolbar.addWidget(spacer) + 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(clear_output=True)) + self.bt_refresh.clicked.connect(lambda: self.refresh(keep_console=False)) toolbar.addWidget(self.bt_refresh) self.bt_upgrade = QToolButton() @@ -111,11 +104,22 @@ class ManageWindow(QWidget): self.layout.addWidget(toolbar) - self.table_apps = AppsTable(self) + self.table_apps = AppsTable(self, self.icon_cache, disk_cache=self.disk_cache) self.table_apps.change_headers_policy() self.layout.addWidget(self.table_apps) + toolbar_console = QToolBar() + + self.checkbox_console = QCheckBox() + self.checkbox_console.setText(self.locale_keys['manage_window.checkbox.show_details']) + self.checkbox_console.stateChanged.connect(self._handle_console) + self.checkbox_console.setVisible(False) + self.ref_checkbox_console = toolbar_console.addWidget(self.checkbox_console) + + toolbar_console.addWidget(self._new_spacer()) + self.layout.addWidget(toolbar_console) + self.textarea_output = QPlainTextEdit(self) self.textarea_output.resize(self.table_apps.size()) self.textarea_output.setStyleSheet("background: black; color: white;") @@ -123,14 +127,14 @@ class ManageWindow(QWidget): self.textarea_output.setVisible(False) self.textarea_output.setReadOnly(True) - self.thread_update = UpdateSelectedApps() + self.thread_update = UpdateSelectedApps(self.manager) self.thread_update.signal_output.connect(self._update_action_output) 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_uninstall = UninstallApp() + self.thread_uninstall = UninstallApp(self.manager, self.icon_cache) self.thread_uninstall.signal_output.connect(self._update_action_output) self.thread_uninstall.signal_finished.connect(self._finish_uninstall) @@ -138,46 +142,50 @@ class ManageWindow(QWidget): self.thread_downgrade.signal_output.connect(self._update_action_output) self.thread_downgrade.signal_finished.connect(self._finish_downgrade) - self.thread_get_info = GetAppInfo() + self.thread_get_info = GetAppInfo(self.manager) self.thread_get_info.signal_finished.connect(self._finish_get_info) - self.thread_get_history = GetAppHistory() + self.thread_get_history = GetAppHistory(self.manager, self.locale_keys) self.thread_get_history.signal_finished.connect(self._finish_get_history) self.thread_search = SearchApps(self.manager) self.thread_search.signal_finished.connect(self._finish_search) - self.thread_install = InstallApp() + self.thread_install = InstallApp(manager=self.manager, disk_cache=self.disk_cache, icon_cache=self.icon_cache) self.thread_install.signal_output.connect(self._update_action_output) self.thread_install.signal_finished.connect(self._finish_install) self.thread_animate_progress = AnimateProgress() self.thread_animate_progress.signal_change.connect(self._update_progress) + self.thread_verify_models = VerifyModels() + self.thread_verify_models.signal_updates.connect(self._notify_model_data_change) + self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') self.label_updates.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE)) - self.toolbar_bottom.addWidget(self.label_updates) + self.ref_label_updates = self.toolbar_bottom.addWidget(self.label_updates) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.toolbar_bottom.addWidget(spacer) + self.toolbar_bottom.addWidget(self._new_spacer()) self.progress_bar = QProgressBar() self.progress_bar.setTextVisible(False) self.ref_progress_bar = self.toolbar_bottom.addWidget(self.progress_bar) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.toolbar_bottom.addWidget(spacer) - - self.label_flatpak = QLabel(self._get_flatpak_label()) - self.toolbar_bottom.addWidget(self.label_flatpak) + self.toolbar_bottom.addWidget(self._new_spacer()) self.layout.addWidget(self.toolbar_bottom) self.centralize() + def _notify_model_data_change(self): + self.table_apps.fill_async_data() + + def _new_spacer(self): + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + return spacer + def changeEvent(self, e: QEvent): if isinstance(e, QWindowStateChangeEvent): @@ -189,20 +197,7 @@ class ManageWindow(QWidget): if self.tray_icon: event.ignore() self.hide() - - def _check_flatpak_installed(self): - - if not flatpak.is_installed(): - dialog.show_error(title=self.locale_keys['popup.flatpak_not_installed.title'], - body=self.locale_keys['popup.flatpak_not_installed.msg'] + '...', - icon=self.icon_flathub) - exit(1) - - if self.label_flatpak: - self.label_flatpak.setText(self._get_flatpak_label()) - - def _get_flatpak_label(self): - return 'flatpak: ' + flatpak.get_version() + self._handle_console_option(False) def _acquire_lock(self): @@ -223,61 +218,70 @@ class ManageWindow(QWidget): self.thread_lock.release() - def _hide_output(self): - self.textarea_output.clear() + def _handle_console(self, checked: bool): + + if checked: + self.textarea_output.show() + else: + self.textarea_output.hide() + + def _handle_console_option(self, enable: bool): + + if enable: + self.textarea_output.clear() + + self.ref_checkbox_console.setVisible(enable) + self.checkbox_console.setChecked(False) self.textarea_output.hide() - def _show_output(self): - self.textarea_output.clear() - self.textarea_output.show() - - def refresh(self, clear_output: bool = True): + def refresh(self, keep_console: bool = True): if self._acquire_lock(): - self._check_flatpak_installed() - self._begin_action(self.locale_keys['manage_window.status.refreshing']) + self.input_search.clear() - if clear_output: - self._hide_output() + if not keep_console: + self._handle_console_option(False) + + self._begin_action(self.locale_keys['manage_window.status.refreshing']) self.thread_refresh.start() - def _finish_refresh(self, apps: List[dict]): - + def _finish_refresh(self, apps: List[Application]): self.update_apps(apps) self.finish_action() self._release_lock() - def uninstall_app(self, app_ref: str): - self._check_flatpak_installed() - + def uninstall_app(self, app: ApplicationView): if self._acquire_lock(): - self.textarea_output.clear() - self.textarea_output.setVisible(True) - self._begin_action(self.locale_keys['manage_window.status.uninstalling']) + 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_ref = app_ref + self.thread_uninstall.app = app self.thread_uninstall.start() def _finish_uninstall(self): self.finish_action() self._release_lock() - self.refresh(clear_output=False) + self.refresh() - def _finish_downgrade(self): + def _finish_downgrade(self, success: bool): self.finish_action() self._release_lock() - self.refresh(clear_output=False) + + if success: + self.refresh() + else: + self.checkbox_console.setChecked(True) def filter_only_apps(self, only_apps: int): if self.apps: show_only_apps = True if only_apps == 2 else False - for idx, app in enumerate(self.apps): - hidden = show_only_apps and app['model']['runtime'] + for idx, app_v in enumerate(self.apps): + hidden = show_only_apps and app_v.model.is_library() self.table_apps.setRowHidden(idx, hidden) - app['visible'] = not hidden + app_v.visible = not hidden self.change_update_state() self.table_apps.change_headers_policy(QHeaderView.Stretch) @@ -288,31 +292,32 @@ class ManageWindow(QWidget): enable_bt_update = False - app_updates, runtime_updates = 0, 0 + app_updates, library_updates = 0, 0 - for app in self.apps: - if app['model']['update']: - if app['model']['runtime']: - runtime_updates += 1 + for app_v in self.apps: + if app_v.model.update: + if app_v.model.runtime: + library_updates += 1 else: app_updates += 1 - total_updates = app_updates + runtime_updates + 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('{} {} | {} runtimes'.format(app_updates, - self.locale_keys['manage_window.checkbox.only_apps'].lower(), - runtime_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 in self.apps: - if app['visible'] and app['update_checked']: + 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']]) + self.tray_icon.notify_updates([app.model for app in self.apps if app.model.update]) def centralize(self): geo = self.frameGeometry() @@ -321,20 +326,17 @@ class ManageWindow(QWidget): geo.moveCenter(center_point) self.move(geo.topLeft()) - def update_apps(self, apps: List[dict]): - self._check_flatpak_installed() - + def update_apps(self, apps: List[Application]): self.apps = [] - napps = 0 # number of apps (not runtimes) + napps = 0 # number of apps (not libraries) if apps: for app in apps: - app_model = {'model': app, - 'update_checked': app['update'], - 'visible': not app['runtime'] or not self.checkbox_only_apps.isChecked()} + app_model = ApplicationView(model=app, + visible=(not app.is_library()) or not self.checkbox_only_apps.isChecked()) - napps += 1 if not app['runtime'] else 0 + napps += 1 if not app.is_library() else 0 self.apps.append(app_model) if napps == 0: @@ -349,6 +351,9 @@ class ManageWindow(QWidget): 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): 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()) @@ -359,55 +364,59 @@ class ManageWindow(QWidget): if self._acquire_lock(): if self.apps: - to_update = [pak['model']['ref'] for pak in self.apps if pak['visible'] and pak['update_checked']] + to_update = [app_v for app_v in self.apps if app_v.visible and app_v.update_checked] if to_update: - self.textarea_output.clear() - self.textarea_output.setVisible(True) + self._handle_console_option(True) self._begin_action(self.locale_keys['manage_window.status.upgrading']) - self.thread_update.refs_to_update = to_update + self.thread_update.apps_to_update = to_update self.thread_update.start() - def _finish_update_selected(self): + def _finish_update_selected(self, success: bool): self.finish_action() self._release_lock() - self.refresh(clear_output=False) + + if success: + self.refresh() + else: + 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): + def _begin_action(self, action_label: str, keep_search: bool = False): + self.ref_label_updates.setVisible(False) self.thread_animate_progress.stop = False self.thread_animate_progress.start() self.ref_progress_bar.setVisible(True) - self.progress_bar.setValue(50) + self.label_status.setText(action_label + "...") - self.toolbar_search.setVisible(False) self.bt_upgrade.setEnabled(False) self.bt_refresh.setEnabled(False) self.checkbox_only_apps.setEnabled(False) self.table_apps.setEnabled(False) - def finish_action(self, clear_search: bool = True): - self.thread_animate_progress.stop = True + if keep_search: + self.toolbar_search.setEnabled(False) + else: + self.toolbar_search.setVisible(False) + + def finish_action(self): self.ref_progress_bar.setVisible(False) + self.ref_label_updates.setVisible(True) + self.thread_animate_progress.stop = True self.progress_bar.setValue(0) - self.progress_bar.setVisible(False) self.bt_refresh.setEnabled(True) - self.toolbar_search.setVisible(True) self.checkbox_only_apps.setEnabled(True) self.table_apps.setEnabled(True) self.input_search.setEnabled(True) self.label_status.setText('') + self.toolbar_search.setVisible(True) + self.toolbar_search.setEnabled(True) - if clear_search: - self.input_search.setText('') - - def downgrade_app(self, app: dict): - - self._check_flatpak_installed() - + def downgrade_app(self, app: ApplicationView): if self._acquire_lock(): pwd = None @@ -419,9 +428,8 @@ class ManageWindow(QWidget): self._release_lock() return - self.textarea_output.clear() - self.textarea_output.setVisible(True) - self._begin_action(self.locale_keys['manage_window.status.downgrading']) + self._handle_console_option(True) + self._begin_action('{} {}'.format(self.locale_keys['manage_window.status.downgrading'], app.model.base_data.name)) self.thread_downgrade.app = app self.thread_downgrade.root_password = pwd @@ -430,8 +438,7 @@ class ManageWindow(QWidget): def get_app_info(self, app: dict): if self._acquire_lock(): - self.textarea_output.clear() - self.textarea_output.setVisible(False) + self._handle_console_option(False) self._begin_action(self.locale_keys['manage_window.status.info']) self.thread_get_info.app = app @@ -439,8 +446,7 @@ class ManageWindow(QWidget): def get_app_history(self, app: dict): if self._acquire_lock(): - self.textarea_output.clear() - self.textarea_output.setVisible(False) + self._handle_console_option(False) self._begin_action(self.locale_keys['manage_window.status.history']) self.thread_get_history.app = app @@ -457,42 +463,47 @@ class ManageWindow(QWidget): self._release_lock() self.finish_action() self.change_update_state() - dialog_history = HistoryDialog(app, self.table_apps.get_selected_app_icon(), self.locale_keys) - dialog_history.exec_() + + if app.get('error'): + self._handle_console_option(True) + self.textarea_output.appendPlainText(app['error']) + self.checkbox_console.setChecked(True) + else: + dialog_history = HistoryDialog(app, self.table_apps.get_selected_app_icon(), self.locale_keys) + dialog_history.exec_() def search(self): word = self.input_search.text().strip() if word and self._acquire_lock(): - self.textarea_output.clear() - self.textarea_output.setVisible(False) - self._begin_action(self.locale_keys['manage_window.status.searching']) + self._handle_console_option(False) + self._begin_action(self.locale_keys['manage_window.status.searching'], keep_search=True) self.thread_search.word = word self.thread_search.start() - def _finish_search(self, apps_found: List[dict]): - + def _finish_search(self, apps_found: List[Application]): self._release_lock() - self.finish_action(clear_search=False) + self.finish_action() self.update_apps(apps_found) - def install_app(self, app: dict): - - self._check_flatpak_installed() - + def install_app(self, app: ApplicationView): if self._acquire_lock(): - self._begin_action(self.locale_keys['manage_window.status.installing']) - self._show_output() + 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.start() - def _finish_install(self): + def _finish_install(self, success: bool): self.input_search.setText('') self.finish_action() self._release_lock() - self.refresh(clear_output=False) + + if success: + self.refresh() + else: + self.checkbox_console.setChecked(True) def _update_progress(self, value: int): self.progress_bar.setValue(value)