From 2d837804e3a88b252e22c5e0cf68702e0fd3c4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Tue, 2 Jul 2019 10:53:04 -0300 Subject: [PATCH] 0.3.0 ### Features - Applications search - Now when you right-click a selected application you can: - retrieve its information - retrieve its commit history - downgrade - install and uninstall it - "About" window available when right-clicking the tray icon. ### Improvements - Performance and memory usage - Adding tooltips to toolbar buttons - "Update ?" column renamed to "Upgrade ?" - Management panel title renamed - Showing runtime apps when no app is available - Allowing to specify a custom app translation with the environment variable **FPAKMAN_LOCALE** - Adding expiration time for cached app data. Default to 1 hour. The environment variable **FPAKMAN_CACHE_EXPIRATION** can change this value. - Now the application accepts arguments related to environment variables as well. Check 'README.md'. - Minor GUI improvements - Notifying only new updates - New icon - Progress bar --- CHANGELOG.md | 24 + README.md | 25 +- aur/desktop_entry.py | 24 + aur/register_app.py | 24 - fpakman/__init__.py | 2 +- fpakman/app.py | 50 +- fpakman/core/controller.py | 138 ++++- fpakman/core/flatpak.py | 170 ++++++- fpakman/core/model.py | 108 ---- fpakman/core/system.py | 11 +- fpakman/core/util.py | 15 +- fpakman/resources/img/checked.svg | 62 +++ .../img/{update.svg => downgrade.svg} | 25 +- .../img/{flathub_45.svg => flathub.svg} | 24 +- fpakman/resources/img/history.svg | 220 ++++++++ fpakman/resources/img/info.svg | 155 ++++++ fpakman/resources/img/install.svg | 120 +++++ fpakman/resources/img/logo.png | Bin 0 -> 28863 bytes fpakman/resources/img/logo.svg | 477 ++++++++++++++++++ fpakman/resources/img/logo_update.png | Bin 0 -> 23931 bytes fpakman/resources/img/logo_update.svg | 473 +++++++++++++++++ fpakman/resources/img/red_cross.svg | 115 +++++ fpakman/resources/img/refresh.svg | 81 +-- fpakman/resources/img/search.svg | 120 +++++ fpakman/resources/img/toggle_off.svg | 26 +- fpakman/resources/img/toggle_on.svg | 52 +- fpakman/resources/img/uninstall.svg | 96 ++++ fpakman/resources/img/update_green.svg | 71 ++- fpakman/resources/locale/en | 73 ++- fpakman/resources/locale/es | 75 ++- fpakman/resources/locale/pt | 73 ++- fpakman/view/qt/about.py | 71 +++ fpakman/view/qt/apps_table.py | 225 +++++++++ fpakman/view/qt/dialog.py | 32 ++ fpakman/view/qt/history.py | 54 ++ fpakman/view/qt/info.py | 39 ++ fpakman/view/qt/root.py | 45 ++ fpakman/view/qt/systray.py | 119 +++-- fpakman/view/qt/thread.py | 186 +++++++ fpakman/view/qt/window.py | 447 +++++++++------- requirements.txt | 3 +- 41 files changed, 3614 insertions(+), 536 deletions(-) create mode 100644 aur/desktop_entry.py delete mode 100644 aur/register_app.py delete mode 100755 fpakman/core/model.py create mode 100755 fpakman/resources/img/checked.svg rename fpakman/resources/img/{update.svg => downgrade.svg} (83%) rename fpakman/resources/img/{flathub_45.svg => flathub.svg} (96%) create mode 100755 fpakman/resources/img/history.svg create mode 100755 fpakman/resources/img/info.svg create mode 100755 fpakman/resources/img/install.svg create mode 100644 fpakman/resources/img/logo.png create mode 100755 fpakman/resources/img/logo.svg create mode 100644 fpakman/resources/img/logo_update.png create mode 100755 fpakman/resources/img/logo_update.svg create mode 100755 fpakman/resources/img/red_cross.svg mode change 100644 => 100755 fpakman/resources/img/refresh.svg create mode 100755 fpakman/resources/img/search.svg create mode 100755 fpakman/resources/img/uninstall.svg create mode 100644 fpakman/view/qt/about.py create mode 100644 fpakman/view/qt/apps_table.py create mode 100644 fpakman/view/qt/dialog.py create mode 100644 fpakman/view/qt/history.py create mode 100644 fpakman/view/qt/info.py create mode 100644 fpakman/view/qt/root.py create mode 100644 fpakman/view/qt/thread.py diff --git a/CHANGELOG.md b/CHANGELOG.md index affcdc85..da7ba9ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ 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.0] - 2019-07-02 +### Features +- Applications search +- Now when you right-click a selected application you can: + - retrieve its information + - retrieve its commit history + - downgrade + - install and uninstall it +- "About" window available when right-clicking the tray icon. + +### Improvements +- Performance and memory usage +- Adding tooltips to toolbar buttons +- "Update ?" column renamed to "Upgrade ?" +- Management panel title renamed +- Showing runtime apps when no app is available +- Allowing to specify a custom app translation with the environment variable **FPAKMAN_LOCALE** +- Adding expiration time for cached app data. Default to 1 hour. The environment variable **FPAKMAN_CACHE_EXPIRATION** can change this value. +- Now the application accepts arguments related to environment variables as well. Check 'README.md'. +- Minor GUI improvements +- Notifying only new updates +- New icon +- Progress bar + ## [0.2.1] - 2019-06-24 ### Features - Showing the number of apps and runtime updates available diff --git a/README.md b/README.md index f9895877..d6e35040 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ ## fpakman -Graphical user interface for Flatpak application management. It is a tray icon to let the user known when new updates are available. -It has also a management window allowing the user to see all installed applications and update them. + +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 +an application management panel where you can search, update, install and uninstall applications. ### Developed with: -- Python3 and QT 5. +- Python3 and Qt5. ### Requirements -- libappindicator3 (for GTK3 desktop environments) +- libappindicator3 ( for GTK3 desktop environments ) #### Debian-based distros - python3-venv #### Arch-based distros @@ -23,7 +24,8 @@ sudo pip3 install fpakman ``` **AUR** -As **fpakman** package. There is also a staging version (**fpakman-staging**) but is intended for testing and it may not work properly. + +As **fpakman** package. There is also a staging version (**fpakman-staging**) but is intended for testing and may not work properly. ### Manual installation: @@ -35,14 +37,17 @@ env/bin/fpakman ``` ### Autostart -In order to autostart the application, use your Desktop Environment settings to register it as startup script ("fpakman"). -(P.S: the installation script currently does not do that) +In order to autostart the application, use your Desktop Environment settings to register it as a startup application / script ("fpakman"). + ### Settings -You can change some application settings via environment variables: +You can change some application settings via environment variables or arguments (type ```fpakman --help``` to get more information). - **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). + ### Roadmap -- Search and install applications -- Uninstall applications +- Support for other packaging technologies +- Memory and performance improvements diff --git a/aur/desktop_entry.py b/aur/desktop_entry.py new file mode 100644 index 00000000..080ffa72 --- /dev/null +++ b/aur/desktop_entry.py @@ -0,0 +1,24 @@ +# Generates a .desktop file based on the current python version. Used for AUR installation +import os +import sys +from pathlib import Path + +desktop_file = """ +[Desktop Entry] +Type = Application +Name = fpakman +Categories = System; +Comment = Manage your Flatpak applications +Exec = /usr/bin/fpakman +Icon = /usr/lib/python{version}/site-packages/fpakman/resources/img/logo.svg +""".format(version="{}.{}".format(sys.version_info.major, sys.version_info.minor)) + +with open('fpakman.desktop', 'w+') as f: + f.write(desktop_file) + + +# cleaning the old fpakman.desktop entry model -> the following lines will be removed for the next releases +desktop_file_path = '{}/.local/share/applications/fpakman.desktop'.format(str(Path.home())) + +if os.path.exists(desktop_file_path): + os.remove(desktop_file_path) diff --git a/aur/register_app.py b/aur/register_app.py deleted file mode 100644 index 98516e09..00000000 --- a/aur/register_app.py +++ /dev/null @@ -1,24 +0,0 @@ -# used for AUR installation -import os -import sys -from pathlib import Path - -desktop_file = """ -[Desktop Entry] -Type = Application -Name = fpakman -Categories = System; -Comment = Manage your Flatpak applications -Exec = /usr/bin/fpakman -Icon = /usr/lib/python{version}/site-packages/fpakman/resources/img/flathub_45.svg -""".format(version="{}.{}".format(sys.version_info.major, sys.version_info.minor)) - -apps_path = '{}/.local/share/applications'.format(str(Path.home())) - -if not os.path.exists(apps_path): - os.mkdir(apps_path) - -file_path = '{}/fpakman.desktop'.format(apps_path) - -with open(file_path, 'w+') as f: - f.write(desktop_file) diff --git a/fpakman/__init__.py b/fpakman/__init__.py index fc79d63d..0404d810 100644 --- a/fpakman/__init__.py +++ b/fpakman/__init__.py @@ -1 +1 @@ -__version__ = '0.2.1' +__version__ = '0.3.0' diff --git a/fpakman/app.py b/fpakman/app.py index 0ee4c73c..f23df03b 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -4,29 +4,59 @@ import sys from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication +from colorama import Fore from fpakman import __version__ from fpakman.core import resource from fpakman.core import util -from fpakman.core.controller import FlatpakController -from fpakman.core.model import FlatpakManager +from fpakman.core.controller import FlatpakManager from fpakman.view.qt.systray import TrayIcon -parser = argparse.ArgumentParser(prog='fpakman', description="GUI for Flatpak applications management") +app_name = 'fpakman' + + +def log_msg(msg: str, color: int = None): + + if color is None: + print('[{}] {}'.format(app_name, msg)) + else: + print('{}[{}] {}{}'.format(color, app_name, msg, Fore.RESET)) + + +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('-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') args = parser.parse_args() -locale_keys = util.get_locale_keys() +if args.cache_exp <= 0: + log_msg("'cache-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) + +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 args.update_notification == 0: + log_msg('updates notifications are disabled', Fore.YELLOW) + +locale_keys = util.get_locale_keys(args.locale) app = QApplication(sys.argv) -app.setWindowIcon(QIcon(resource.get_path('img/flathub_45.svg'))) -manager = FlatpakManager() -manager.load_database_async() -controller = FlatpakController(manager) +app.setWindowIcon(QIcon(resource.get_path('img/logo.svg'))) + +manager = FlatpakManager(cache_expire=args.cache_exp) trayIcon = TrayIcon(locale_keys=locale_keys, - controller=controller, - check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60))) + manager=manager, + check_interval=args.check_interval, + update_notification=bool(args.update_notification)) +trayIcon.load_database() trayIcon.show() sys.exit(app.exec_()) diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 84ed8233..5e02af2d 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -1,22 +1,134 @@ +from datetime import datetime, timedelta +from threading import Lock from typing import List -from fpakman.core.model import FlatpakManager +import requests + +from fpakman.core import flatpak + +__FLATHUB_URL__ = 'https://flathub.org' +__FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1' -class FlatpakController: +class FlatpakManager: - def __init__(self, model: FlatpakManager): - self.model = model + def __init__(self, cache_expire: int = 60 * 60): + self.cache_apps = {} + self.cache_expire = cache_expire + self.http_session = requests.Session() + self.lock_db_read = Lock() + self.lock_read = Lock() - def refresh(self) -> List[dict]: - return self.model.read_installed() + def load_full_database(self): - def update(self, app_ref: str): - return self.model.update_app(app_ref) + self.lock_db_read.acquire() - def check_installed(self) -> bool: - version = self.model.get_version() - return False if version is None else version + try: + res = self.http_session.get(__FLATHUB_API_URL__ + '/apps', timeout=30) - def get_version(self) -> str: - return self.model.get_version() + if res.status_code == 200: + for app in res.json(): + self.cache_apps[app['flatpakAppId']] = app + finally: + self.lock_db_read.release() + + def _request_app_data(self, app_id: str): + + try: + res = self.http_session.get('{}/apps/{}'.format(__FLATHUB_API_URL__, app_id), timeout=30) + + 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') + + for attr in ('name', 'description'): + if not app.get(attr): + app[attr] = api_data.get(attr) + + if app['icon'].startswith('/'): + app['icon'] = __FLATHUB_URL__ + app['icon'] + + def search(self, word: str) -> List[dict]: + + res = [] + apps_found = flatpak.search(word) + + if apps_found: + + already_read = set() + installed_apps = self.read_installed() + + if installed_apps: + for app in apps_found: + for installed_app in installed_apps: + if app['id'] == installed_app['id']: + res.append(installed_app) + already_read.add(app['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) + + return res + + def read_installed(self) -> List[dict]: + + self.lock_read.acquire() + + try: + installed = flatpak.list_installed() + + if installed: + installed.sort(key=lambda p: p['name'].lower()) + + 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 + + return installed + + finally: + self.lock_read.release() + + def downgrade_app(self, app: dict, 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) diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 82ea400d..5da0358b 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -1,8 +1,11 @@ import re +import subprocess from typing import List from fpakman.core import system +BASE_CMD = 'flatpak' + def app_str_to_json(line: str, version: str) -> dict: @@ -32,33 +35,53 @@ def app_str_to_json(line: str, version: str) -> dict: else: raise Exception('Unsupported version') - info = re.findall(r'\w+:\s.+', get_app_info(app['id'], app['branch'])) - fields_to_get = ['origin', 'arch', 'ref'] - - for field in info: - field_val = field.split(':') - field_name = field_val[0].lower() - - if field_name in fields_to_get: - app[field_name] = field_val[1].strip() - - if field_name == 'ref': - app['runtime'] = app['ref'].startswith('runtime/') + extra_fields = get_app_info_fields(app['id'], app['branch'], ['origin', 'arch', 'ref', 'commit'], check_runtime=True) + app.update(extra_fields) return app +def get_app_info_fields(app_id: str, branch: str, fields: List[str] = [], check_runtime: bool = False): + info = re.findall(r'\w+:\s.+', get_app_info(app_id, branch)) + data = {} + fields_to_retrieve = len(fields) + (1 if check_runtime and 'ref' not in fields else 0) + + for field in info: + + if fields and fields_to_retrieve == 0: + break + + field_val = field.split(':') + field_name = field_val[0].lower() + + if not fields or field_name in fields or (check_runtime and field_name == 'ref'): + data[field_name] = field_val[1].strip() + + if fields: + fields_to_retrieve -= 1 + + if check_runtime and field_name == 'ref': + data['runtime'] = data['ref'].startswith('runtime/') + + return data + + +def is_installed(): + version = get_version() + return False if version is None else True + + def get_version(): - res = system.run_cmd('flatpak --version') + res = system.run_cmd('{} --version'.format(BASE_CMD)) return res.split(' ')[1].strip() if res else None def get_app_info(app_id: str, branch: str): - return system.run_cmd('flatpak info {} {}'.format(app_id, branch)) + return system.run_cmd('{} info {} {}'.format(BASE_CMD, app_id, branch)) def list_installed() -> List[dict]: - apps_str = system.run_cmd('flatpak list') + apps_str = system.run_cmd('{} list'.format(BASE_CMD)) if apps_str: version = get_version() @@ -68,18 +91,123 @@ def list_installed() -> List[dict]: return [] -def update(app_ref: str) -> bool: - return bool(system.run_cmd('flatpak update -y ' + app_ref)) - - def update_and_stream(app_ref: str): """ Updates the app reference and streams Flatpak output, :param app_ref: :return: """ - return system.stream_cmd(['flatpak', 'update', '-y', app_ref]) + return system.stream_cmd([BASE_CMD, 'update', '-y', app_ref]) + + +def uninstall_and_stream(app_ref: str): + """ + Removes the app by its reference + :param app_ref: + :return: + """ + return system.stream_cmd([BASE_CMD, 'uninstall', app_ref, '-y']) def list_updates_as_str(): - return system.run_cmd('flatpak update', ignore_return_code=True) + 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 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) + + +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)) + + res = re.findall(r'(Commit|Subject|Date):\s(.+)', log) + + commits = [] + + commit = {} + + for idx, data in enumerate(res): + commit[data[0].strip().lower()] = data[1].strip() + + if (idx + 1) % 3 == 0: + commits.append(commit) + commit = {} + + return commits + + +def search(word: str) -> List[dict]: + cli_version = get_version() + + res = system.run_cmd('{} search {}'.format(BASE_CMD, word)) + + found = [] + + split_res = res.split('\n') + + if split_res and split_res[0].lower() != 'no matches found': + for info in split_res: + if info: + info_list = info.split('\t') + + if cli_version >= '1.3.0': + version = info_list[3].strip() + found.append({ + 'name': info_list[0].strip(), + 'description': info_list[1].strip(), + 'id': info_list[2].strip(), + 'version': version, + 'latest_version': version, + 'branch': info_list[4].strip(), + 'origin': info_list[5].strip(), + 'runtime': False, + 'arch': None, # unknown at this moment, + 'ref': None # unknown at this moment + }) + elif cli_version >= '1.2.0': + desc = info_list[0].split('-') + version = info_list[2].strip() + found.append({ + 'name': desc[0].strip(), + 'description': desc[1].strip(), + 'id': info_list[1].strip(), + 'version': version, + 'latest_version': version, + 'branch': info_list[3].strip(), + 'origin': info_list[4].strip(), + 'runtime': False, + 'arch': None, # unknown at this moment, + 'ref': None # unknown at this moment + }) + else: + version = info_list[1].strip() + found.append({ + 'name': '', + 'description': info_list[4].strip(), + 'id': info_list[0].strip(), + 'version': version, + 'latest_version': version, + 'branch': info_list[2].strip(), + 'origin': info_list[3].strip(), + 'runtime': False, + 'arch': None, # unknown at this moment, + 'ref': None # unknown at this moment + }) + return found + + +def install_and_stream(app_id: str, origin: str): + return system.stream_cmd([BASE_CMD, 'install', origin, app_id, '-y']) diff --git a/fpakman/core/model.py b/fpakman/core/model.py deleted file mode 100755 index 8346a4ad..00000000 --- a/fpakman/core/model.py +++ /dev/null @@ -1,108 +0,0 @@ -from threading import Lock, Thread -from typing import List - -import requests -from PyQt5.QtCore import QUrl -from PyQt5.QtGui import QPixmap, QIcon -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest - -from fpakman.core import flatpak - -__FLATHUB_URL__ = 'https://flathub.org' -__FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1' - - -class FlatpakManager: - - def __init__(self): - self.apps = [] - self.apps_db = {} - self.http_session = requests.Session() - self.lock_db_read = Lock() - - def load_database_async(self): - Thread(target=self.load_database, daemon=True).start() - - def load_database(self): - - self.lock_db_read.acquire() - - try: - res = self.http_session.get(__FLATHUB_API_URL__ + '/apps') - - if res.status_code == 200: - for app in res.json(): - self.apps_db[app['flatpakAppId']] = app - finally: - self.lock_db_read.release() - - def get_version(self): - return flatpak.get_version() - - def read_installed(self) -> List[dict]: - - installed = flatpak.list_installed() - - if installed: - installed.sort(key=lambda p: p['name'].lower()) - - available_updates = flatpak.list_updates_as_str() - - for app in installed: - - if not self.apps_db: - self.load_database() - - if self.apps_db: - app_data = self.apps_db.get(app['id'], None) - else: - app_data = None - - if not app_data: - app['latest_version'] = None - app['icon'] = None - else: - app['latest_version'] = app_data['currentReleaseVersion'] - app['icon'] = app_data['iconMobileUrl'] - - if app['icon'].startswith('/'): - app['icon'] = __FLATHUB_URL__ + app['icon'] - - app['update'] = app['id'] in available_updates - - self.apps = installed - return [*self.apps] - - def update_apps(self, refs: List[str]) -> List[dict]: - - if self.apps: - - for ref in refs: - package_found = [app for app in self.apps if app['ref'] == ref] - - if package_found: - package_found = package_found[0] - updated = flatpak.update(ref) - - if updated: - package_found['update'] = not updated - - return [*self.apps] - - return [] - - def update_app(self, ref: str): - - """ - :param ref: - :return: the update command stream - """ - - if self.apps: - - package_found = [app for app in self.apps if app['ref'] == ref] - - if package_found: - return flatpak.update_and_stream(ref) - - return None diff --git a/fpakman/core/system.py b/fpakman/core/system.py index ddd5edfa..462f9a66 100644 --- a/fpakman/core/system.py +++ b/fpakman/core/system.py @@ -1,11 +1,18 @@ +import os import subprocess 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) + res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, env={'LANG': 'en'}) 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).stdout + return subprocess.Popen(cmd, stdout=subprocess.PIPE, env={'LANG': 'en'}).stdout + + +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)) diff --git a/fpakman/core/util.py b/fpakman/core/util.py index f66016ca..678c577d 100644 --- a/fpakman/core/util.py +++ b/fpakman/core/util.py @@ -2,13 +2,20 @@ import locale from fpakman.core import resource import glob +import re + +HTML_RE = re.compile(r'<[^>]+>') -def get_locale_keys(): +def get_locale_keys(key: str = None): - current_locale = locale.getdefaultlocale() locale_path = None + if key is None: + current_locale = locale.getdefaultlocale() + else: + current_locale = [key.strip().lower()] + if current_locale: current_locale = current_locale[0] @@ -34,3 +41,7 @@ def get_locale_keys(): locale_obj[keyval[0].strip()] = keyval[1].strip() return locale_obj + + +def strip_html(string: str): + return HTML_RE.sub('', string) diff --git a/fpakman/resources/img/checked.svg b/fpakman/resources/img/checked.svg new file mode 100755 index 00000000..40ad2bc5 --- /dev/null +++ b/fpakman/resources/img/checked.svg @@ -0,0 +1,62 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/fpakman/resources/img/update.svg b/fpakman/resources/img/downgrade.svg similarity index 83% rename from fpakman/resources/img/update.svg rename to fpakman/resources/img/downgrade.svg index 3dfbb01c..aea4c0e8 100755 --- a/fpakman/resources/img/update.svg +++ b/fpakman/resources/img/downgrade.svg @@ -8,13 +8,13 @@ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" - viewBox="0 0 45 45" + viewBox="0 0 24 24" enable-background="new 0 0 26 26" id="svg8" - sodipodi:docname="update.svg" + sodipodi:docname="downgrade.svg" inkscape:version="0.92.3 (2405546, 2018-03-11)" - width="45" - height="45"> + width="24" + height="24"> @@ -39,13 +39,13 @@ inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="1366" - inkscape:window-height="739" + inkscape:window-height="703" id="namedview10" showgrid="false" showborder="false" - inkscape:zoom="9.0769231" - inkscape:cx="13" - inkscape:cy="20.813559" + inkscape:zoom="4.5384616" + inkscape:cx="-20.270304" + inkscape:cy="12.13426" inkscape:window-x="0" inkscape:window-y="260" inkscape:window-maximized="1" @@ -56,14 +56,17 @@ fit-margin-bottom="0" /> + transform="matrix(0.92307692,0,0,1,0,-1)" + style="fill:#ff0000"> + inkscape:connector-curvature="0" + style="fill:#ff0000" /> + inkscape:connector-curvature="0" + style="fill:#ff0000" /> diff --git a/fpakman/resources/img/flathub_45.svg b/fpakman/resources/img/flathub.svg similarity index 96% rename from fpakman/resources/img/flathub_45.svg rename to fpakman/resources/img/flathub.svg index 2c72e02e..1b44a5d4 100755 --- a/fpakman/resources/img/flathub_45.svg +++ b/fpakman/resources/img/flathub.svg @@ -9,13 +9,13 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="43.449291" - height="41.737198" - viewBox="0 0 11.495958 11.042967" + width="47.919052" + height="48.110374" + viewBox="0 0 12.678582 12.729203" version="1.1" id="svg3871" - inkscape:version="0.92.4 5da689c313, 2019-01-14" - sodipodi:docname="flathub_logo.svg"> + inkscape:version="0.92.3 (2405546, 2018-03-11)" + sodipodi:docname="flathub.svg"> + transform="translate(-12.122116,-167.33593)"> + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/info.svg b/fpakman/resources/img/info.svg new file mode 100755 index 00000000..a276e4fb --- /dev/null +++ b/fpakman/resources/img/info.svg @@ -0,0 +1,155 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/install.svg b/fpakman/resources/img/install.svg new file mode 100755 index 00000000..b24ebfbb --- /dev/null +++ b/fpakman/resources/img/install.svg @@ -0,0 +1,120 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/logo.png b/fpakman/resources/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c557b4c3e028f7a807bf66bc322a85f6104da6d2 GIT binary patch literal 28863 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 N6@{1b)v}i1{{=4qpsWA@ literal 0 HcmV?d00001 diff --git a/fpakman/resources/img/logo.svg b/fpakman/resources/img/logo.svg new file mode 100755 index 00000000..e480a993 --- /dev/null +++ b/fpakman/resources/img/logo.svg @@ -0,0 +1,477 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Layer 1 \ No newline at end of file diff --git a/fpakman/resources/img/logo_update.png b/fpakman/resources/img/logo_update.png new file mode 100644 index 0000000000000000000000000000000000000000..6941519b32e70d144d8cdfac21062cc4c07514e3 GIT binary patch literal 23931 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 Uki9DKS#A*gyxqAv^K1A17dCODZ~y=R literal 0 HcmV?d00001 diff --git a/fpakman/resources/img/logo_update.svg b/fpakman/resources/img/logo_update.svg new file mode 100755 index 00000000..2391fa85 --- /dev/null +++ b/fpakman/resources/img/logo_update.svg @@ -0,0 +1,473 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/red_cross.svg b/fpakman/resources/img/red_cross.svg new file mode 100755 index 00000000..28054ffc --- /dev/null +++ b/fpakman/resources/img/red_cross.svg @@ -0,0 +1,115 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/refresh.svg b/fpakman/resources/img/refresh.svg old mode 100644 new mode 100755 index 0158d7d0..16952792 --- a/fpakman/resources/img/refresh.svg +++ b/fpakman/resources/img/refresh.svg @@ -7,22 +7,39 @@ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" id="Capa_1" x="0px" y="0px" - width="45" - height="44.999996" - viewBox="0 0 44.999999 44.999995" + width="48" + height="48" + viewBox="0 0 47.999999 47.999999" xml:space="preserve" - sodipodi:docname="refresh_blue.svg" - inkscape:version="0.92.4 5da689c313, 2019-01-14">image/svg+xml + transform="matrix(0.10547014,0,0,0.0985161,-1.6940777,0)" + style="fill:url(#linearGradient542);fill-opacity:1"> + style="fill:url(#linearGradient542);fill-opacity:1"> + style="fill:url(#linearGradient542);fill-opacity:1" /> + style="fill:url(#linearGradient542);fill-opacity:1" /> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> \ No newline at end of file diff --git a/fpakman/resources/img/search.svg b/fpakman/resources/img/search.svg new file mode 100755 index 00000000..1519d2f8 --- /dev/null +++ b/fpakman/resources/img/search.svg @@ -0,0 +1,120 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/toggle_off.svg b/fpakman/resources/img/toggle_off.svg index f50edee5..48f4b89a 100755 --- a/fpakman/resources/img/toggle_off.svg +++ b/fpakman/resources/img/toggle_off.svg @@ -9,13 +9,13 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="45" - height="45" - viewBox="0 0 11.90625 11.90625" + width="24" + height="24" + viewBox="0 0 6.35 6.35" version="1.1" id="svg8" inkscape:version="0.92.3 (2405546, 2018-03-11)" - sodipodi:docname="toogle_red.svg"> + sodipodi:docname="toggle_off.svg"> @@ -50,7 +50,7 @@ image/svg+xml - + @@ -58,12 +58,12 @@ inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" - transform="translate(198.05951,-115.76041)"> + transform="translate(198.05952,-121.31665)"> + cx="-194.88452" + cy="124.49165" + r="3.175" /> diff --git a/fpakman/resources/img/toggle_on.svg b/fpakman/resources/img/toggle_on.svg index 5c341548..4d89b0ca 100755 --- a/fpakman/resources/img/toggle_on.svg +++ b/fpakman/resources/img/toggle_on.svg @@ -7,17 +7,41 @@ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="45" - height="45" - viewBox="0 0 11.90625 11.90625" + width="24" + height="24" + viewBox="0 0 6.35 6.35" version="1.1" id="svg8" inkscape:version="0.92.3 (2405546, 2018-03-11)" - sodipodi:docname="pak_update_green.svg"> + sodipodi:docname="toggle_on.svg"> + id="defs2"> + + + + + + @@ -58,12 +82,12 @@ inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" - transform="translate(198.05951,-115.76041)"> + transform="translate(198.05951,-121.31666)"> + cx="-194.88451" + cy="124.49166" + r="3.175" /> diff --git a/fpakman/resources/img/uninstall.svg b/fpakman/resources/img/uninstall.svg new file mode 100755 index 00000000..b228fbcd --- /dev/null +++ b/fpakman/resources/img/uninstall.svg @@ -0,0 +1,96 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/fpakman/resources/img/update_green.svg b/fpakman/resources/img/update_green.svg index 85063166..d4fc7a00 100755 --- a/fpakman/resources/img/update_green.svg +++ b/fpakman/resources/img/update_green.svg @@ -5,16 +5,17 @@ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" - viewBox="0 0 45 45" + viewBox="0 0 48 48" enable-background="new 0 0 26 26" id="svg8" - sodipodi:docname="update_green.svg" + sodipodi:docname="update_green_2.svg" inkscape:version="0.92.3 (2405546, 2018-03-11)" - width="45" - height="45"> + width="48" + height="48"> @@ -28,7 +29,47 @@ + id="defs12"> + + + + + + + + + transform="matrix(1.8461538,0,0,2,0,-2)" + style="fill:url(#linearGradient311);fill-opacity:1"> + style="fill:url(#linearGradient313);fill-opacity:1" /> + style="fill:url(#linearGradient315);fill-opacity:1" /> + diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 6a3a38ff..1cb4c76f 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -1,17 +1,72 @@ -manage_window.columns.name=Name -manage_window.columns.version=Version +manage_window.title=Flatpak Application Manager manage_window.columns.latest_version=Latest Version -manage_window.columns.branch=Branch -manage_window.columns.arch=Arch -manage_window.columns.ref=Ref -manage_window.columns.origin=Origin -manage_window.columns.update=Update ? +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 +manage_window.apps_table.row.actions.uninstall=Uninstall +manage_window.apps_table.row.actions.uninstall.popup.title=Uninstall +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.checkbox.only_apps=Apps +window_manage.input_search.placeholder=Search +window_manage.input_search.tooltip=Type and press ENTER to search for applications manage_window.label.updates=Updates manage_window.status.refreshing=Refreshing -manage_window.status.updating=Updating +manage_window.status.upgrading=Upgrading +manage_window.status.uninstalling=Uninstalling +manage_window.status.downgrading=Downgrading +manage_window.status.info=Retrieving information +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 popup.flatpak_not_installed.title=Error popup.flatpak_not_installed.msg=flatpak seems not to be installed. Exiting +popup.root.title=Requires root privileges +popup.root.password=Password +popup.root.bad_password.title=Error +popup.root.bad_password.body=Wrong password +popup.downgrade.impossible.title=Error +popup.downgrade.impossible.body=Impossible to downgrade: the app is in its first version +popup.history.title=History +popup.history.selected.tooltip=Current version +popup.button.yes=Yes +popup.button.no=No +popup.button.cancel=Cancel tray.action.manage=Manage applications tray.action.exit=Exit -notification.new_updates=Updates found \ No newline at end of file +tray.action.about=About +tray.action.refreshing=Refreshing +notification.new_updates={} updates +flatpak.info.arch=arch +flatpak.info.branch=branch +flatpak.info.collection=collection +flatpak.info.commit=commit +flatpak.info.date=date +flatpak.info.description=description +flatpak.info.id=id +flatpak.info.installation=installation +flatpak.info.installed=installed +flatpak.info.license=license +flatpak.info.name=name +flatpak.info.origin=origin +flatpak.info.parent=parent +flatpak.info.ref=ref +flatpak.info.runtime=runtime +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 +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 +yes=yes +no=no +version.updated=updated +version.outdated=outdated \ No newline at end of file diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 801c5b26..19617178 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -1,17 +1,72 @@ -manage_window.columns.name=Nombre -manage_window.columns.version=Versión +manage_window.title=Administrador de Aplicativos Flatpak manage_window.columns.latest_version=Ultima Versión -manage_window.columns.branch=Branch -manage_window.columns.arch=Arch -manage_window.columns.ref=Ref -manage_window.columns.origin=Origen -manage_window.columns.update=¿Actualizar? -manage_window.checkbox.only_apps=Apps +manage_window.columns.update=Actualizar ? +manage_window.columns.installed=Instalado +manage_window.apps_table.row.actions.info=Información +manage_window.apps_table.row.actions.history=Historia +manage_window.apps_table.row.actions.uninstall=Desinstalar +manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalación +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.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 +window_manage.input_search.tooltip=Escriba y oprima ENTER para buscar aplicativos manage_window.label.updates=Actualizaciones manage_window.status.refreshing=Recargando -manage_window.status.updating=Actualizando +manage_window.status.upgrading=Actualizando +manage_window.status.uninstalling=Desinstalando +manage_window.status.downgrading=Revirtiendo +manage_window.status.info=Obteniendo información +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 popup.flatpak_not_installed.title=Error popup.flatpak_not_installed.msg=flatpak no parece estar instalado. Saliendo +popup.root.title=Requiere privilegios de root +popup.root.password=Contraseña +popup.root.bad_password.title=Error +popup.root.bad_password.body=Contraseña incorrecta +popup.downgrade.impossible.title=Error +popup.downgrade.impossible.body=Imposible revertir la versión: el aplicativo está en su primera versión +popup.history.title=Historia +popup.history.selected.tooltip=Versión actual +popup.button.yes=Sí +popup.button.no=No +popup.button.cancel=Cancelar tray.action.manage=Administrar aplicativos tray.action.exit=Salir -notification.new_updates=Actualizaciones encontradas \ No newline at end of file +tray.action.about=Sobre +tray.action.refreshing=Recargando +notification.new_updates=Actualizaciones {} +flatpak.info.arch=arquitectura +flatpak.info.branch=rama +flatpak.info.collection=colección +flatpak.info.commit=commit +flatpak.info.date=fecha +flatpak.info.description=descripción +flatpak.info.id=id +flatpak.info.installation=instalación +flatpak.info.installed=instalado +flatpak.info.license=licencia +flatpak.info.name=nombre +flatpak.info.origin=origen +flatpak.info.parent=padre +flatpak.info.ref=ref +flatpak.info.runtime=runtime +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 +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 +yes=sí +no=no +version.updated=actualizada +version.outdated=desactualizada \ No newline at end of file diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 61db2c38..7cddb50c 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -1,17 +1,72 @@ -manage_window.columns.name=Nome -manage_window.columns.version=Versão +manage_window.title=Gerenciador de Aplicativos Flatpak manage_window.columns.latest_version=Última Versão -manage_window.columns.branch=Branch -manage_window.columns.arch=Arch -manage_window.columns.ref=Ref -manage_window.columns.origin=Origem manage_window.columns.update=Atualizar ? -manage_window.checkbox.only_apps=Apps +manage_window.columns.installed=Instalado +manage_window.apps_table.row.actions.info=Informação +manage_window.apps_table.row.actions.history=Histórico +manage_window.apps_table.row.actions.uninstall=Desinstalar +manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalação +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.checkbox.only_apps=Aplicativos +window_manage.input_search.placeholder=Buscar +window_manage.input_search.tooltip=Digite e pressione ENTER para buscar aplicativos manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando -manage_window.status.updating=Atualizando +manage_window.status.upgrading=Atualizando +manage_window.status.uninstalling=Desinstalando +manage_window.status.downgrading=Revertendo +manage_window.status.info=Obtendo informação +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 popup.flatpak_not_installed.title=Erro popup.flatpak_not_installed.msg=flatpak não parece estar instalado. Saindo +popup.root.title=Requer privilégios de root +popup.root.password=Senha +popup.root.bad_password.title=Erro +popup.root.bad_password.body=Senha incorreta +popup.downgrade.impossible.title=Erro +popup.downgrade.impossible.body=Impossível reverter a versão: o aplicativo está na sua primeira versão +popup.history.title=Histórico +popup.history.selected.tooltip=Versão atual +popup.button.yes=Sim +popup.button.no=Não +popup.button.cancel=Cancelar tray.action.manage=Gerenciar aplicativos tray.action.exit=Sair -notification.new_updates=Atualizações encontradas \ No newline at end of file +tray.action.about=Sobre +tray.action.refreshing=Recarregando +notification.new_updates=Atualizações {} +flatpak.info.arch=arquitetura +flatpak.info.branch=ramo +flatpak.info.collection=coleção +flatpak.info.commit=commit +flatpak.info.date=data +flatpak.info.description=descrição +flatpak.info.id=id +flatpak.info.installation=instalação +flatpak.info.installed=instalado +flatpak.info.license=licença +flatpak.info.name=nome +flatpak.info.origin=origem +flatpak.info.parent=pai +flatpak.info.ref=ref +flatpak.info.runtime=runtime +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 +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é +yes=sim +no=não +version.updated=atualizada +version.outdated=desatualizada \ No newline at end of file diff --git a/fpakman/view/qt/about.py b/fpakman/view/qt/about.py new file mode 100644 index 00000000..b9e319db --- /dev/null +++ b/fpakman/view/qt/about.py @@ -0,0 +1,71 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPixmap +from PyQt5.QtWidgets import QVBoxLayout, QDialog, QLabel + +from fpakman import __version__ +from fpakman.core import resource + +PROJECT_URL = 'https://github.com/vinifmor/fpakman' +LICENSE_URL = 'https://raw.githubusercontent.com/vinifmor/fpakman/master/LICENSE' + + +class AboutDialog(QDialog): + + def __init__(self, locale_keys: dict): + super(AboutDialog, self).__init__() + self.setWindowTitle(locale_keys['tray.action.about']) + layout = QVBoxLayout() + self.setLayout(layout) + + label_logo = QLabel(self) + label_logo.setPixmap(QPixmap(resource.get_path('img/logo.svg'))) + label_logo.setAlignment(Qt.AlignCenter) + layout.addWidget(label_logo) + + label_name = QLabel('fpakman ( {} {} )'.format(locale_keys['flatpak.info.version'].lower(), __version__)) + label_name.setStyleSheet('font-weight: bold;') + label_name.setAlignment(Qt.AlignCenter) + layout.addWidget(label_name) + + layout.addWidget(QLabel('')) + + line_desc = QLabel(self) + line_desc.setStyleSheet('font-size: 10px; font-weight: bold;') + line_desc.setText(locale_keys['about.info.desc']) + line_desc.setAlignment(Qt.AlignCenter) + line_desc.setMinimumWidth(400) + layout.addWidget(line_desc) + + layout.addWidget(QLabel('')) + + label_more_info = QLabel() + label_more_info.setStyleSheet('font-size: 9px;') + label_more_info.setText(locale_keys['about.info.link'] + ": {url}".format(url=PROJECT_URL)) + label_more_info.setOpenExternalLinks(True) + label_more_info.setAlignment(Qt.AlignCenter) + layout.addWidget(label_more_info) + + label_license = QLabel() + label_license.setStyleSheet('font-size: 9px;') + label_license.setText("{}".format(LICENSE_URL, locale_keys['about.info.license'])) + label_license.setOpenExternalLinks(True) + label_license.setAlignment(Qt.AlignCenter) + layout.addWidget(label_license) + + layout.addWidget(QLabel('')) + + label_rate = QLabel() + label_rate.setStyleSheet('font-size: 9px; font-weight: bold;') + label_rate.setText(locale_keys['about.info.rate'] + ' :)') + label_rate.setOpenExternalLinks(True) + label_rate.setAlignment(Qt.AlignCenter) + layout.addWidget(label_rate) + + layout.addWidget(QLabel('')) + + self.adjustSize() + self.setFixedSize(self.size()) + + def closeEvent(self, event): + event.ignore() + self.hide() diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py new file mode 100644 index 00000000..e27a9f4a --- /dev/null +++ b/fpakman/view/qt/apps_table.py @@ -0,0 +1,225 @@ +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.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ + QHeaderView, QLabel + +from fpakman.core import resource, util +from fpakman.view.qt import dialog + + +class UpdateToggleButton(QToolButton): + + def __init__(self, model: dict, root: QWidget, locale_keys: dict, checked: bool = True): + super(UpdateToggleButton, self).__init__() + self.app = model + self.root = root + self.setCheckable(True) + self.clicked.connect(self.change_state) + 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.setToolTip(locale_keys['manage_window.apps_table.upgrade_toggle.tooltip']) + + if not checked: + self.click() + + def change_state(self, not_checked: bool): + self.app['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): + 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', + 'manage_window.columns.update']] + self.setColumnCount(len(self.column_names)) + self.setFocusPolicy(Qt.NoFocus) + self.setShowGrid(False) + self.verticalHeader().setVisible(False) + self.setSelectionBehavior(QTableView.SelectRows) + self.setHorizontalHeaderLabels(self.column_names) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.icon_flathub = QIcon(resource.get_path('img/flathub.svg')) + + self.network_man = QNetworkAccessManager() + self.network_man.finished.connect(self._load_icon) + + self.icon_cache = {} + + def contextMenuEvent(self, QContextMenuEvent): # selected row right click event + + app = self.get_selected_app() + + 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) + + 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) + + 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 not app['model']['runtime']: # not available for runtimes + 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) + + menu_row.adjustSize() + menu_row.popup(QCursor.pos()) + menu_row.exec_() + + def get_selected_app(self): + return self.window.apps[self.currentRow()] + + def get_selected_app_icon(self): + 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']), + locale_keys=self.window.locale_keys): + self.window.uninstall_app(selected_app['model']['ref']) + + 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']), + locale_keys=self.window.locale_keys): + self.window.downgrade_app(selected_app) + + def _get_app_info(self): + self.window.get_app_info(self.get_selected_app()) + + def _get_app_history(self): + self.window.get_app_history(self.get_selected_app()) + + def _install_app(self): + self.window.install_app(self.get_selected_app()) + + def _load_icon(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 + + 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 + + def update_apps(self, apps: List[dict]): + 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.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_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) + + if app['model']['version'] and app['model']['latest_version'] and app['model']['version'] < app['model']['latest_version']: + col_release.setForeground(QColor('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_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) + + 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_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.setAlignment(Qt.AlignCenter) + + self.setCellWidget(idx, 7, 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) + + def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): + header_horizontal = self.horizontalHeader() + for i in range(self.columnCount()): + header_horizontal.setSectionResizeMode(i, policy) diff --git a/fpakman/view/qt/dialog.py b/fpakman/view/qt/dialog.py new file mode 100644 index 00000000..6b27e6ff --- /dev/null +++ b/fpakman/view/qt/dialog.py @@ -0,0 +1,32 @@ +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QMessageBox + +from fpakman.core import resource + + +def show_error(title: str, body: str, icon: QIcon = QIcon(resource.get_path('img/logo.svg'))): + error_msg = QMessageBox() + error_msg.setIcon(QMessageBox.Critical) + error_msg.setWindowTitle(title) + error_msg.setText(body) + + if icon: + error_msg.setWindowIcon(icon) + + error_msg.exec_() + + +def ask_confirmation(title: str, body: str, locale_keys: dict, icon: QIcon = QIcon(resource.get_path('img/logo.svg'))): + dialog_confirmation = QMessageBox() + dialog_confirmation.setIcon(QMessageBox.Question) + dialog_confirmation.setWindowTitle(title) + dialog_confirmation.setText(body) + bt_yes = dialog_confirmation.addButton(locale_keys['popup.button.yes'], QMessageBox.YesRole) + dialog_confirmation.addButton(locale_keys['popup.button.no'], QMessageBox.NoRole) + + if icon: + dialog_confirmation.setWindowIcon(icon) + + dialog_confirmation.exec_() + + return dialog_confirmation.clickedButton() == bt_yes diff --git a/fpakman/view/qt/history.py b/fpakman/view/qt/history.py new file mode 100644 index 00000000..8cb5877f --- /dev/null +++ b/fpakman/view/qt/history.py @@ -0,0 +1,54 @@ +import operator +from functools import reduce + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView + + +class HistoryDialog(QDialog): + + 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.setWindowIcon(app_icon) + + layout = QVBoxLayout() + self.setLayout(layout) + + table_history = QTableWidget() + table_history.setFocusPolicy(Qt.NoFocus) + table_history.setShowGrid(False) + 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())]) + + for row, commit in enumerate(app['commits']): + + current_app_commit = app['model']['commit'] == commit['commit'] + + for col, key in enumerate(sorted(commit.keys())): + item = QTableWidgetItem() + item.setText(commit[key]) + item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + + if current_app_commit: + item.setBackground(Qt.darkYellow if row != 0 else Qt.darkGreen) + tip = '{}. {}.'.format(locale_keys['popup.history.selected.tooltip'], locale_keys['version.{}'.format('updated'if row == 0 else 'outdated')].capitalize()) + + item.setToolTip(tip) + + table_history.setItem(row, col, item) + + layout.addWidget(table_history) + + header_horizontal = table_history.horizontalHeader() + for i in range(0, table_history.columnCount()): + header_horizontal.setSectionResizeMode(i, QHeaderView.Stretch) + + new_width = reduce(operator.add, [table_history.columnWidth(i) for i in range(table_history.columnCount())]) + self.resize(new_width, table_history.height()) diff --git a/fpakman/view/qt/info.py b/fpakman/view/qt/info.py new file mode 100644 index 00000000..0ee09ecf --- /dev/null +++ b/fpakman/view/qt/info.py @@ -0,0 +1,39 @@ +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QFormLayout, QGroupBox, \ + QLineEdit, QLabel, QPlainTextEdit + + +class InfoDialog(QDialog): + + def __init__(self, app: dict, app_icon: QIcon, locale_keys: dict): + super(InfoDialog, self).__init__() + self.setWindowTitle(app['name']) + self.setWindowIcon(app_icon) + layout = QVBoxLayout() + self.setLayout(layout) + + gbox_info_layout = QFormLayout() + gbox_info = QGroupBox() + 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") + + text.setReadOnly(True) + + label = QLabel("{}: ".format(locale_keys.get('flatpak.info.' + attr, attr)).capitalize()) + label.setStyleSheet("font-weight: bold") + + gbox_info_layout.addRow(label, text) + + self.adjustSize() diff --git a/fpakman/view/qt/root.py b/fpakman/view/qt/root.py new file mode 100644 index 00000000..55bef81d --- /dev/null +++ b/fpakman/view/qt/root.py @@ -0,0 +1,45 @@ +import io +import os +import subprocess + +from PyQt5.QtWidgets import QInputDialog, QLineEdit + +from fpakman.view.qt.dialog import show_error + + +def is_root(): + return os.getuid() == 0 + + +def ask_root_password(locale_keys: dict): + + dialog_pwd = QInputDialog() + dialog_pwd.setInputMode(QInputDialog.TextInput) + dialog_pwd.setTextEchoMode(QLineEdit.Password) + dialog_pwd.setWindowTitle(locale_keys['popup.root.title']) + dialog_pwd.setLabelText(locale_keys['popup.root.password'] + ':') + dialog_pwd.setCancelButtonText(locale_keys['popup.button.cancel']) + dialog_pwd.resize(400, 200) + + ok = dialog_pwd.exec_() + + if ok: + if not validate_password(dialog_pwd.textValue()): + show_error(title=locale_keys['popup.root.bad_password.title'], + body=locale_keys['popup.root.bad_password.body']) + ok = False + + return dialog_pwd.textValue(), ok + + +def validate_password(password: str) -> bool: + proc = subprocess.Popen('sudo -k && echo {} | sudo -S whoami'.format(password), + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=-1) + stream = os._wrap_close(io.TextIOWrapper(proc.stdout), proc) + res = stream.read() + stream.close() + + return bool(res.strip()) diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index c97a4834..a462531b 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -1,82 +1,139 @@ -import os import time +from typing import List -from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication +from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication, Qt from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu -from fpakman.core import resource -from fpakman.core.controller import FlatpakController +from fpakman.core import resource, system +from fpakman.core.controller import FlatpakManager +from fpakman.view.qt.about import AboutDialog from fpakman.view.qt.window import ManageWindow class UpdateCheck(QThread): - signal = pyqtSignal(int) + signal = pyqtSignal(list) - def __init__(self, check_interval: int, controller: FlatpakController, parent=None): + def __init__(self, manager: FlatpakManager, check_interval: int, parent=None): super(UpdateCheck, self).__init__(parent) - self.controller = controller self.check_interval = check_interval + self.manager = manager def run(self): while True: - apps = self.controller.refresh() + apps = self.manager.read_installed() - updates = len([app for app in apps if app['update']]) + updates = [app for app in apps if app['update']] - self.signal.emit(updates) + if updates: + self.signal.emit(updates) 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, controller: FlatpakController, check_interval: int = 60): + def __init__(self, locale_keys: dict, manager: FlatpakManager, check_interval: int = 60, update_notification: bool = True): super(TrayIcon, self).__init__() self.locale_keys = locale_keys - self.controller = controller + self.manager = manager - self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) - self.icon_update = QIcon(resource.get_path('img/update_logo.svg')) + self.icon_default = QIcon(resource.get_path('img/logo.png')) + self.icon_update = QIcon(resource.get_path('img/logo_update.png')) self.setIcon(self.icon_default) 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) + self.action_exit = self.menu.addAction(self.locale_keys['tray.action.exit']) self.action_exit.triggered.connect(lambda: QCoreApplication.exit()) + self.setContextMenu(self.menu) - self.manage_window = ManageWindow(locale_keys=self.locale_keys, controller=controller, tray_icon=self) - self.check_thread = UpdateCheck(check_interval=check_interval, controller=self.controller) + self.manage_window = None + self.check_thread = UpdateCheck(check_interval=check_interval, manager=self.manager) self.check_thread.signal.connect(self.notify_updates) self.check_thread.start() - def notify_updates(self, updates: int): - if updates > 0: - if self.icon().cacheKey() != self.icon_update.cacheKey(): - self.setIcon(self.icon_update) + self.dialog_about = None - msg = '{}: {}'.format(self.locale_keys['notification.new_updates'], updates) + self.thread_database = LoadDatabase(manager) + self.thread_database.signal_finished.connect(self._update_menu) + self.last_updates = set() + self.update_notification = update_notification + + def load_database(self): + self.thread_database.start() + + def _update_menu(self): + self.action_refreshing.setVisible(False) + self.action_manage.setVisible(True) + + def notify_updates(self, updates: List[dict]): + + if len(updates) > 0: + + update_keys = {'{}:{}'.format(app['id'], app['latest_version']) for app in updates} + + 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'].format('Flatpak'), len(updates)) self.setToolTip(msg) - if bool(os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1)): - os.system("notify-send -i {} '{}'".format(resource.get_path('img/flathub_45.svg'), msg)) - - if self.manage_window: - self.manage_window.refresh() + if self.update_notification: + system.notify_user(msg) else: - self.setIcon(self.icon_default) + new_icon = self.icon_default self.setToolTip(None) + if self.icon().cacheKey() != new_icon.cacheKey(): # changes the icon if needed + self.setIcon(new_icon) + def show_manage_window(self): - if not self.manage_window: - self.manage_window = ManageWindow(controller=self.controller) + if self.manage_window is None: + self.manage_window = ManageWindow(locale_keys=self.locale_keys, + manager=self.manager, + tray_icon=self) - self.manage_window.refresh() - self.manage_window.show() + if self.manage_window.isMinimized(): + self.manage_window.setWindowState(Qt.WindowNoState) + else: + self.manage_window.refresh() + self.manage_window.show() + + def show_about(self): + + if self.dialog_about is None: + self.dialog_about = AboutDialog(self.locale_keys) + + if self.dialog_about.isHidden(): + self.dialog_about.show() diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py new file mode 100644 index 00000000..d289ab5c --- /dev/null +++ b/fpakman/view/qt/thread.py @@ -0,0 +1,186 @@ +import time + +from PyQt5.QtCore import QThread, pyqtSignal + +from fpakman.core import flatpak +from fpakman.core.controller import FlatpakManager +from fpakman.view.qt import dialog + + +class UpdateSelectedApps(QThread): + + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self): + super(UpdateSelectedApps, self).__init__() + self.refs_to_update = [] + + def run(self): + + for app_ref in self.refs_to_update: + for output in flatpak.update_and_stream(app_ref): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + self.signal_finished.emit() + + +class RefreshApps(QThread): + + signal = pyqtSignal(list) + + def __init__(self, manager: FlatpakManager): + super(RefreshApps, self).__init__() + self.manager = manager + + def run(self): + self.signal.emit(self.manager.read_installed()) + + +class UninstallApp(QThread): + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self): + super(UninstallApp, self).__init__() + self.app_ref = None + + def run(self): + if self.app_ref: + for output in flatpak.uninstall_and_stream(self.app_ref): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + self.signal_finished.emit() + + +class DowngradeApp(QThread): + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self, manager: FlatpakManager, locale_keys: dict): + super(DowngradeApp, self).__init__() + self.manager = manager + self.app = None + self.root_password = None + self.locale_keys = locale_keys + + def run(self): + if self.app: + + 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() + + +class GetAppInfo(QThread): + signal_finished = pyqtSignal(dict) + + def __init__(self): + super(GetAppInfo, self).__init__() + self.app = None + + 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.app = None + + +class GetAppHistory(QThread): + signal_finished = pyqtSignal(dict) + + def __init__(self): + super(GetAppHistory, self).__init__() + self.app = None + + 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 + + +class SearchApps(QThread): + signal_finished = pyqtSignal(list) + + def __init__(self, manager: FlatpakManager): + super(SearchApps, self).__init__() + self.word = None + self.manager = manager + + def run(self): + apps_found = [] + + if self.word: + apps_found = self.manager.search(self.word) + + self.signal_finished.emit(apps_found) + self.word = None + + +class InstallApp(QThread): + + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self): + super(InstallApp, self).__init__() + self.app = None + + def run(self): + + if self.app: + for output in flatpak.install_and_stream(self.app['model']['id'], self.app['model']['origin']): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + self.app = None + self.signal_finished.emit() + + +class AnimateProgress(QThread): + + signal_change = pyqtSignal(int) + + def __init__(self): + super(AnimateProgress, self).__init__() + self.progress_value = 0 + self.increment = 5 + self.stop = False + + def run(self): + + current_increment = self.increment + + while not self.stop: + self.signal_change.emit(self.progress_value) + + if self.progress_value == 100: + current_increment = -current_increment + if self.progress_value == 0: + current_increment = self.increment + + self.progress_value += current_increment + + time.sleep(0.05) + + self.progress_value = 0 diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index ab5bb616..9a28625c 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -3,81 +3,84 @@ from functools import reduce from threading import Lock from typing import List -from PyQt5.QtCore import Qt, QThread, pyqtSignal, QUrl, QEvent -from PyQt5.QtGui import QIcon, QColor, QPixmap, QWindowStateChangeEvent -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QTableWidget, \ - QTableWidgetItem, QTableView, QCheckBox, QHeaderView, QToolButton, QToolBar, \ - QSizePolicy, QLabel, QMessageBox, QPlainTextEdit +from PyQt5.QtCore import QEvent +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 import __version__ -from fpakman.core import resource -from fpakman.core.controller import FlatpakController +from fpakman.core import resource, flatpak +from fpakman.core.controller import FlatpakManager +from fpakman.view.qt import dialog +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 - -class UpdateToggleButton(QToolButton): - - def __init__(self, model: dict, root: QWidget, checked: bool = True): - super(UpdateToggleButton, self).__init__() - self.model = model - self.root = root - self.setCheckable(True) - self.clicked.connect(self.change_state) - 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;') - - if not checked: - self.click() - - def change_state(self, not_checked: bool): - self.model['update_checked'] = not not_checked - self.setIcon(self.icon_on if not not_checked else self.icon_off) - self.root.change_update_state() +DARK_ORANGE = '#FF4500' class ManageWindow(QWidget): __BASE_HEIGHT__ = 400 - def __init__(self, locale_keys: dict, controller: FlatpakController, tray_icon = None): + def __init__(self, locale_keys: dict, manager: FlatpakManager, tray_icon=None): super(ManageWindow, self).__init__() self.locale_keys = locale_keys - self.column_names = [locale_keys['manage_window.columns.name'], - locale_keys['manage_window.columns.version'], - locale_keys['manage_window.columns.latest_version'], - locale_keys['manage_window.columns.branch'], - locale_keys['manage_window.columns.arch'], - locale_keys['manage_window.columns.ref'], - locale_keys['manage_window.columns.origin'], - locale_keys['manage_window.columns.update']] - self.controller = controller - self.icon_cache = {} + self.manager = manager self.tray_icon = tray_icon self.thread_lock = Lock() self.working = False # restrict the number of threaded actions self.apps = [] self.label_flatpak = None - self.network_man = QNetworkAccessManager() - self.network_man.finished.connect(self._load_icon) - - self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) + self.icon_flathub = QIcon(resource.get_path('img/logo.svg')) self._check_flatpak_installed() self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) - self.setWindowTitle('fpakman ({})'.format(__version__)) + self.setWindowTitle(locale_keys['manage_window.title']) self.setWindowIcon(self.icon_flathub) self.layout = QVBoxLayout() self.setLayout(self.layout) + 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) + + label_pre_search = QLabel() + 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.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) + self.toolbar_search.addWidget(self.input_search) + + label_pos_search = QLabel() + label_pos_search.setPixmap(QPixmap(resource.get_path('img/search.svg'))) + 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.layout.addWidget(self.toolbar_search) + + 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 = QToolBar() toolbar.addWidget(self.checkbox_only_apps) spacer = QWidget() @@ -86,7 +89,7 @@ class ManageWindow(QWidget): self.label_status = QLabel() self.label_status.setText('') - self.label_status.setStyleSheet("color: orange") + self.label_status.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE)) toolbar.addWidget(self.label_status) spacer = QWidget() @@ -94,28 +97,22 @@ class ManageWindow(QWidget): toolbar.addWidget(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)) toolbar.addWidget(self.bt_refresh) - self.bt_update = QToolButton() - self.bt_update.setIcon(QIcon(resource.get_path('img/update_green.svg'))) - self.bt_update.setEnabled(False) - self.bt_update.clicked.connect(self.update_selected) - toolbar.addWidget(self.bt_update) + self.bt_upgrade = QToolButton() + self.bt_upgrade.setToolTip(locale_keys['manage_window.bt.upgrade.tooltip']) + self.bt_upgrade.setIcon(QIcon(resource.get_path('img/update_green.svg'))) + self.bt_upgrade.setEnabled(False) + self.bt_upgrade.clicked.connect(self.update_selected) + toolbar.addWidget(self.bt_upgrade) self.layout.addWidget(toolbar) - self.table_apps = QTableWidget() - self.table_apps.setColumnCount(len(self.column_names)) - self.table_apps.setFocusPolicy(Qt.NoFocus) - self.table_apps.setShowGrid(False) - self.table_apps.verticalHeader().setVisible(False) - self.table_apps.setSelectionBehavior(QTableView.SelectRows) - self.table_apps.setHorizontalHeaderLabels(self.column_names) - self.table_apps.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - self._change_table_headers_policy() + self.table_apps = AppsTable(self) + self.table_apps.change_headers_policy() self.layout.addWidget(self.table_apps) @@ -126,35 +123,66 @@ class ManageWindow(QWidget): self.textarea_output.setVisible(False) self.textarea_output.setReadOnly(True) - self.thread_update = UpdateSelectedApps(self.controller) + self.thread_update = UpdateSelectedApps() 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.controller) + self.thread_refresh = RefreshApps(self.manager) self.thread_refresh.signal.connect(self._finish_refresh) + self.thread_uninstall = UninstallApp() + self.thread_uninstall.signal_output.connect(self._update_action_output) + self.thread_uninstall.signal_finished.connect(self._finish_uninstall) + + self.thread_downgrade = DowngradeApp(self.manager, self.locale_keys) + 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.signal_finished.connect(self._finish_get_info) + + self.thread_get_history = GetAppHistory() + 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.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.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) + + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.toolbar_bottom.addWidget(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.layout.addWidget(self.toolbar_bottom) self.centralize() - def _change_table_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): - header_horizontal = self.table_apps.horizontalHeader() - for i in range(0, len(self.column_names)): - header_horizontal.setSectionResizeMode(i, policy) - def changeEvent(self, e: QEvent): if isinstance(e, QWindowStateChangeEvent): - self._change_table_headers_policy(QHeaderView.Stretch if self.isMaximized() else QHeaderView.ResizeToContents) + policy = QHeaderView.Stretch if self.isMaximized() else QHeaderView.ResizeToContents + self.table_apps.change_headers_policy(policy) def closeEvent(self, event): @@ -162,35 +190,19 @@ class ManageWindow(QWidget): event.ignore() self.hide() - def _load_icon(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 - - for idx, app in enumerate(self.apps): - if app['model']['icon'] == icon_url: - self.table_apps.item(idx, 0).setIcon(icon) - self.resize_and_center() - break - def _check_flatpak_installed(self): - if not self.controller.check_installed(): - error_msg = QMessageBox() - error_msg.setIcon(QMessageBox.Critical) - error_msg.setWindowTitle(self.locale_keys['popup.flatpak_not_installed.title']) - error_msg.setText(self.locale_keys['popup.flatpak_not_installed.msg'] + '...') - error_msg.setWindowIcon(self.icon_flathub) - error_msg.exec_() + 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: ' + self.controller.get_version() + return 'flatpak: ' + flatpak.get_version() def _acquire_lock(self): @@ -211,24 +223,52 @@ class ManageWindow(QWidget): self.thread_lock.release() + def _hide_output(self): + self.textarea_output.clear() + self.textarea_output.hide() + + def _show_output(self): + self.textarea_output.clear() + self.textarea_output.show() + def refresh(self, clear_output: bool = True): if self._acquire_lock(): self._check_flatpak_installed() - self._begin_action(self.locale_keys['manage_window.status.refreshing'] + '...') + self._begin_action(self.locale_keys['manage_window.status.refreshing']) if clear_output: - self.textarea_output.clear() - self.textarea_output.hide() + self._hide_output() self.thread_refresh.start() - def _finish_refresh(self): + def _finish_refresh(self, apps: List[dict]): - self.update_apps(self.thread_refresh.apps) + self.update_apps(apps) self.finish_action() self._release_lock() + def uninstall_app(self, app_ref: str): + self._check_flatpak_installed() + + if self._acquire_lock(): + self.textarea_output.clear() + self.textarea_output.setVisible(True) + self._begin_action(self.locale_keys['manage_window.status.uninstalling']) + + self.thread_uninstall.app_ref = app_ref + self.thread_uninstall.start() + + def _finish_uninstall(self): + self.finish_action() + self._release_lock() + self.refresh(clear_output=False) + + def _finish_downgrade(self): + self.finish_action() + self._release_lock() + self.refresh(clear_output=False) + def filter_only_apps(self, only_apps: int): if self.apps: @@ -240,8 +280,8 @@ class ManageWindow(QWidget): app['visible'] = not hidden self.change_update_state() - self._change_table_headers_policy(QHeaderView.Stretch) - self._change_table_headers_policy() + self.table_apps.change_headers_policy(QHeaderView.Stretch) + self.table_apps.change_headers_policy() self.resize_and_center() def change_update_state(self): @@ -259,7 +299,10 @@ class ManageWindow(QWidget): total_updates = app_updates + runtime_updates if total_updates > 0: - self.label_updates.setText('{}: {} ( {} apps | {} runtimes )'.format(self.locale_keys['manage_window.label.updates'], total_updates, app_updates, runtime_updates)) + 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)) else: self.label_updates.setText('') @@ -268,9 +311,8 @@ class ManageWindow(QWidget): enable_bt_update = True break - self.bt_update.setEnabled(enable_bt_update) - - self.tray_icon.notify_updates(total_updates) + self.bt_upgrade.setEnabled(enable_bt_update) + self.tray_icon.notify_updates([app['model'] for app in self.apps if app['model']['update']]) def centralize(self): geo = self.frameGeometry() @@ -282,79 +324,33 @@ class ManageWindow(QWidget): def update_apps(self, apps: List[dict]): self._check_flatpak_installed() - self.table_apps.setEnabled(True) self.apps = [] - self.table_apps.setRowCount(len(apps) if apps else 0) + napps = 0 # number of apps (not runtimes) if apps: - for idx, app in enumerate(apps): - - col_name = QTableWidgetItem() - col_name.setText(app['name']) - col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - - if not app['icon']: - col_name.setIcon(self.icon_flathub) - else: - cached_icon = self.icon_cache.get(app['icon']) - - if cached_icon: - col_name.setIcon(cached_icon) - else: - col_name.setIcon(self.icon_flathub) - self.network_man.get(QNetworkRequest(QUrl(app['icon']))) - - self.table_apps.setItem(idx, 0, col_name) - - col_version = QTableWidgetItem() - col_version.setText(app['version']) - col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 1, col_version) - - col_release = QTableWidgetItem() - col_release.setText(app['latest_version']) - col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 2, col_release) - - if app['update']: - col_release.setForeground(QColor('orange')) - - col_branch = QTableWidgetItem() - col_branch.setText(app['branch']) - col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 3, col_branch) - - col_arch = QTableWidgetItem() - col_arch.setText(app['arch']) - col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 4, col_arch) - - col_package = QTableWidgetItem() - col_package.setText(app['ref']) - col_package.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 5, col_package) - - col_origin = QTableWidgetItem() - col_origin.setText(app['origin']) - col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 6, col_origin) - + for app in apps: app_model = {'model': app, 'update_checked': app['update'], 'visible': not app['runtime'] or not self.checkbox_only_apps.isChecked()} - col_update = UpdateToggleButton(app_model, self, app['update']) if app['update'] else None - self.table_apps.setCellWidget(idx, 7, col_update) - + napps += 1 if not app['runtime'] else 0 self.apps.append(app_model) + if napps == 0: + self.checkbox_only_apps.setChecked(False) + self.checkbox_only_apps.setCheckable(False) + else: + self.checkbox_only_apps.setCheckable(True) + self.checkbox_only_apps.setChecked(True) + + self.table_apps.update_apps(self.apps) self.change_update_state() self.filter_only_apps(2 if self.checkbox_only_apps.isChecked() else 0) self.resize_and_center() def resize_and_center(self): - new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(self.column_names))]) * 1.05 + 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()) self.centralize() @@ -369,7 +365,7 @@ class ManageWindow(QWidget): self.textarea_output.clear() self.textarea_output.setVisible(True) - self._begin_action(self.locale_keys['manage_window.status.updating'] + '...') + self._begin_action(self.locale_keys['manage_window.status.upgrading']) self.thread_update.refs_to_update = to_update self.thread_update.start() @@ -382,50 +378,121 @@ class ManageWindow(QWidget): self.textarea_output.appendPlainText(output) def _begin_action(self, action_label: str): - self.label_status.setText(action_label) - self.bt_update.setEnabled(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): + def finish_action(self, clear_search: bool = True): + self.thread_animate_progress.stop = True + self.ref_progress_bar.setVisible(False) + 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('') + if clear_search: + self.input_search.setText('') -# Threaded actions -class UpdateSelectedApps(QThread): + def downgrade_app(self, app: dict): - signal_finished = pyqtSignal() - signal_output = pyqtSignal(str) + self._check_flatpak_installed() - def __init__(self, controller: FlatpakController): - super(UpdateSelectedApps, self).__init__() - self.controller = controller - self.refs_to_update = [] + if self._acquire_lock(): - def run(self): + pwd = None - for app_ref in self.refs_to_update: - for output in self.controller.update(app_ref): - line = output.decode().strip() - if line: - self.signal_output.emit(line) + if not is_root(): + pwd, ok = ask_root_password(self.locale_keys) - self.signal_finished.emit() + if not ok: + self._release_lock() + return + self.textarea_output.clear() + self.textarea_output.setVisible(True) + self._begin_action(self.locale_keys['manage_window.status.downgrading']) -class RefreshApps(QThread): + self.thread_downgrade.app = app + self.thread_downgrade.root_password = pwd + self.thread_downgrade.start() - signal = pyqtSignal() + def get_app_info(self, app: dict): - def __init__(self, controller: FlatpakController): - super(RefreshApps, self).__init__() - self.controller = controller - self.apps = None + if self._acquire_lock(): + self.textarea_output.clear() + self.textarea_output.setVisible(False) + self._begin_action(self.locale_keys['manage_window.status.info']) - def run(self): - self.apps = self.controller.refresh() - self.signal.emit() + self.thread_get_info.app = app + self.thread_get_info.start() + + def get_app_history(self, app: dict): + if self._acquire_lock(): + self.textarea_output.clear() + self.textarea_output.setVisible(False) + self._begin_action(self.locale_keys['manage_window.status.history']) + + self.thread_get_history.app = app + self.thread_get_history.start() + + def _finish_get_info(self, app_info: dict): + 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.exec_() + + def _finish_get_history(self, app: dict): + 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_() + + 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.thread_search.word = word + self.thread_search.start() + + def _finish_search(self, apps_found: List[dict]): + + self._release_lock() + self.finish_action(clear_search=False) + self.update_apps(apps_found) + + def install_app(self, app: dict): + + self._check_flatpak_installed() + + if self._acquire_lock(): + self._begin_action(self.locale_keys['manage_window.status.installing']) + self._show_output() + + self.thread_install.app = app + self.thread_install.start() + + def _finish_install(self): + self.input_search.setText('') + self.finish_action() + self._release_lock() + self.refresh(clear_output=False) + + def _update_progress(self, value: int): + self.progress_bar.setValue(value) diff --git a/requirements.txt b/requirements.txt index 9ef4c21f..3415a156 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pyqt5>=5.12 -requests>=2.22 \ No newline at end of file +requests>=2.22 +colorama>=0.4.1 \ No newline at end of file