improving initialization time and reducing async overload

This commit is contained in:
Vinícius Moreira
2019-07-08 12:15:15 -03:00
committed by GitHub
parent 735e590756
commit 74fa85a2bb
18 changed files with 577 additions and 240 deletions

View File

@@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.3.1]
### Improvements
- Console output now is optional and now shown by default.
- Full Flatpak database is not loaded during initialization (management panel is quickly available for the user)
- Applications data that must be retrieved from Flathub API are now retrieved on demand and cached
- Cache cleaners (for icons and API data) to improve memory usage.
- New environment variable and argument: FPAKMAN_ICON_EXPIRATION ('--icon-exp')
- Code was refactored to support other types of packaging
## [0.3.0] - 2019-07-02
### Features

View File

@@ -45,7 +45,8 @@ You can change some application settings via environment variables or arguments
- **FPAKMAN_UPDATE_NOTIFICATION**: enable or disable system updates notifications. Use **0** (disable) or **1** (enable, default).
- **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60.
- **FPAKMAN_LOCALE**: define a custom app translation for a given locale key (e.g: 'pt', 'en', 'es', ...). Default: system locale.
- **FPAKMAN_CACHE_EXPIRATION**: define a custom expiration time in SECONDS for application data retrieved from the origin API. Default: 3600 (1 hour).
- **FPAKMAN_CACHE_EXPIRATION**: define a custom expiration time in SECONDS for cached API data. Default: 3600 (1 hour).
- **FPAKMAN_ICON_EXPIRATION**: define a custom expiration time in SECONDS for cached icons. Default: 300 (5 minutes).
### Roadmap

View File

@@ -8,8 +8,10 @@ from colorama import Fore
from fpakman import __version__
from fpakman.core import resource
from fpakman.core import util
from fpakman.util import util
from fpakman.core.controller import FlatpakManager
from fpakman.util.cache import Cache
from fpakman.util.memory import CacheCleaner
from fpakman.view.qt.systray import TrayIcon
app_name = 'fpakman'
@@ -25,15 +27,19 @@ def log_msg(msg: str, color: int = None):
parser = argparse.ArgumentParser(prog=app_name, description="GUI for Flatpak applications management")
parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__))
parser.add_argument('-e', '--cache-exp', action="store", default=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60)), type=int, help='cached application expiration time in SECONDS. Default: %(default)s')
parser.add_argument('-e', '--cache-exp', action="store", default=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60)), type=int, help='cached API data expiration time in SECONDS. Default: %(default)s')
parser.add_argument('-ie', '--icon-exp', action="store", default=int(os.getenv('FPAKMAN_ICON_EXPIRATION', 60 * 5)), type=int, help='cached icons expiration time in SECONDS. Default: %(default)s')
parser.add_argument('-l', '--locale', action="store", default=os.getenv('FPAKMAN_LOCALE', 'en'), help='Translation key. Default: %(default)s')
parser.add_argument('-i', '--check-interval', action="store", default=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60)), type=int, help='Updates check interval in SECONDS. Default: %(default)s')
parser.add_argument('-n', '--update-notification', action="store", default=os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1), type=int, help='Enable/disable system notifications for new updates. Default: %(default)s')
args = parser.parse_args()
if args.cache_exp <= 0:
if args.cache_exp < 0:
log_msg("'cache-exp' set to '{}': cache will not expire.".format(args.cache_exp), Fore.YELLOW)
if args.icon_exp < 0:
log_msg("'icon-exp' set to '{}': cache will not expire.".format(args.cache_exp), Fore.YELLOW)
if not args.locale.strip():
log_msg("'locale' set as '{}'. You must provide a valid one. Aborting...".format(args.locale), Fore.RED)
exit(1)
@@ -45,18 +51,27 @@ if args.check_interval <= 0:
if args.update_notification == 0:
log_msg('updates notifications are disabled', Fore.YELLOW)
caches = []
flatpak_api_cache = Cache(expiration_time=args.cache_exp)
caches.append(flatpak_api_cache)
icon_cache = Cache(expiration_time=args.icon_exp)
caches.append(icon_cache)
locale_keys = util.get_locale_keys(args.locale)
app = QApplication(sys.argv)
app.setWindowIcon(QIcon(resource.get_path('img/logo.svg')))
manager = FlatpakManager(cache_expire=args.cache_exp)
manager = FlatpakManager(flatpak_api_cache)
trayIcon = TrayIcon(locale_keys=locale_keys,
manager=manager,
check_interval=args.check_interval,
icon_cache=icon_cache,
update_notification=bool(args.update_notification))
trayIcon.load_database()
trayIcon.show()
CacheCleaner(caches).start()
sys.exit(app.exec_())

View File

@@ -0,0 +1,2 @@
FLATHUB_URL = 'https://flathub.org'
FLATHUB_API_URL = FLATHUB_URL + '/api/v1'

View File

@@ -1,80 +1,53 @@
from datetime import datetime, timedelta
from datetime import datetime
from threading import Lock
from typing import List
import requests
from fpakman.core import flatpak
__FLATHUB_URL__ = 'https://flathub.org'
__FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1'
from fpakman.core.model import FlatpakApplication, ApplicationData, ApplicationStatus
from fpakman.core.worker import FlatpakAsyncDataLoaderManager
from fpakman.util.cache import Cache
class FlatpakManager:
def __init__(self, cache_expire: int = 60 * 60):
self.cache_apps = {}
self.cache_expire = cache_expire
def __init__(self, api_cache: Cache):
self.api_cache = api_cache
self.http_session = requests.Session()
self.lock_db_read = Lock()
self.lock_read = Lock()
self.async_data_loader = FlatpakAsyncDataLoaderManager(api_cache=self.api_cache)
def load_full_database(self):
def _map_to_model(self, app: dict) -> FlatpakApplication:
self.lock_db_read.acquire()
model = FlatpakApplication(arch=app.get('arch'),
branch=app.get('branch'),
origin=app.get('origin'),
runtime=app.get('runtime'),
ref=app.get('ref'),
commit=app.get('commit'),
base_data=ApplicationData(id=app.get('id'),
name=app.get('name'),
version=app.get('version'),
latest_version=app.get('latest_version')))
try:
res = self.http_session.get(__FLATHUB_API_URL__ + '/apps', timeout=30)
api_data = self.api_cache.get(app['id'])
if res.status_code == 200:
for app in res.json():
self.cache_apps[app['flatpakAppId']] = app
finally:
self.lock_db_read.release()
expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow()
def _request_app_data(self, app_id: str):
if not api_data or expired_data:
if not app['runtime']:
model.status = ApplicationStatus.LOADING_DATA
self.async_data_loader.load(model)
try:
res = self.http_session.get('{}/apps/{}'.format(__FLATHUB_API_URL__, app_id), timeout=30)
else: # filling cached data
for attr, val in api_data.items():
if attr != 'expires_at' and val:
setattr(model.base_data, attr, val)
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
return model
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]:
def search(self, word: str) -> List[FlatpakApplication]:
res = []
apps_found = flatpak.search(word)
@@ -82,25 +55,24 @@ class FlatpakManager:
if apps_found:
already_read = set()
installed_apps = self.read_installed()
installed_apps = self.read_installed(keep_workers=True)
if installed_apps:
for app in apps_found:
for app_found in apps_found:
for installed_app in installed_apps:
if app['id'] == installed_app['id']:
if app_found['id'] == installed_app.base_data.id:
res.append(installed_app)
already_read.add(app['id'])
already_read.add(app_found['id'])
for app in apps_found:
if app['id'] not in already_read:
app['update'] = False
app['installed'] = False
self._fill_api_data(app)
res.append(app)
for app_found in apps_found:
if app_found['id'] not in already_read:
res.append(self._map_to_model(app_found))
self.async_data_loader.stop_current_workers()
return res
def read_installed(self) -> List[dict]:
def read_installed(self, keep_workers: bool = False) -> List[FlatpakApplication]:
self.lock_read.acquire()
@@ -112,23 +84,34 @@ class FlatpakManager:
available_updates = flatpak.list_updates_as_str()
for app in installed:
self._fill_api_data(app)
app['update'] = app['id'] in available_updates
app['installed'] = True
models = []
return installed
for app in installed:
model = self._map_to_model(app)
model.installed = True
model.update = app['id'] in available_updates
models.append(model)
if not keep_workers:
self.async_data_loader.stop_current_workers()
return models
return []
finally:
self.lock_read.release()
def downgrade_app(self, app: dict, root_password: str):
def downgrade_app(self, app: FlatpakApplication, root_password: str):
commits = flatpak.get_app_commits(app['ref'], app['origin'])
commit_idx = commits.index(app['commit'])
commits = flatpak.get_app_commits(app.ref, app.origin)
commit_idx = commits.index(app.commit)
# downgrade is not possible if the app current commit in the first one:
if commit_idx == len(commits) - 1:
return None
return flatpak.downgrade_and_stream(app['ref'], commits[commit_idx + 1], root_password)
return flatpak.downgrade_and_stream(app.ref, commits[commit_idx + 1], root_password)
def clean_cache_for(self, app_id: str):
self.api_cache.delete(app_id)

View File

@@ -3,6 +3,7 @@ import subprocess
from typing import List
from fpakman.core import system
from fpakman.core.model import Application
BASE_CMD = 'flatpak'

42
fpakman/core/model.py Normal file
View File

@@ -0,0 +1,42 @@
from abc import ABC
from enum import Enum
class ApplicationStatus(Enum):
READY = 1
LOADING_DATA = 2
class ApplicationData:
def __init__(self, id: str, version: str, name: str = None, description: str = None, latest_version: str = None, icon_url: str = None):
self.id = id
self.name = name
self.version = version
self.description = description
self.latest_version = latest_version
self.icon_url = icon_url
class Application(ABC):
def __init__(self, base_data: ApplicationData, status: ApplicationStatus = ApplicationStatus.READY, installed: bool = False, update: bool = False):
self.base_data = base_data
self.status = status
self.installed = installed
self.update = update
class FlatpakApplication(Application):
def __init__(self, base_data: ApplicationData, branch: str, arch: str, origin: str, runtime: bool, ref: str, commit: str):
super(FlatpakApplication, self).__init__(base_data=base_data)
self.ref = ref
self.branch = branch
self.arch = arch
self.origin = origin
self.runtime = runtime
self.commit = commit
def is_incomplete(self):
return self.base_data.description is None and self.base_data.icon_url

119
fpakman/core/worker.py Normal file
View File

@@ -0,0 +1,119 @@
import traceback
from io import StringIO
from threading import Thread
from typing import List
import requests
from colorama import Fore
from fpakman.core.constants import FLATHUB_API_URL, FLATHUB_URL
from fpakman.core.model import FlatpakApplication, ApplicationStatus
from fpakman.util.cache import Cache
class FlatpakAsyncDataLoader(Thread):
def __init__(self, http_session, api_cache: Cache, attempts: int = 3):
super(FlatpakAsyncDataLoader, self).__init__(daemon=True)
self.apps = []
self.http_session = http_session
self.attempts = attempts
self.api_cache = api_cache
self.id_ = '{}#{}'.format(self.__class__.__name__, id(self))
self.stop = False
def log_msg(self, msg: str, color: int = None):
final_msg = StringIO()
if color:
final_msg.write(str(color))
final_msg.write('[{}] '.format(self.id_))
final_msg.write(msg)
if color:
final_msg.write(Fore.RESET)
final_msg.seek(0)
print(final_msg.read())
def run(self):
while True:
if not self.apps and self.stop:
break # stop working
else:
if self.apps:
app = self.apps[0]
app.status = ApplicationStatus.LOADING_DATA
for _ in range(0, self.attempts):
try:
res = self.http_session.get('{}/apps/{}'.format(FLATHUB_API_URL, app.base_data.id), timeout=30)
if res.status_code == 200 and res.text:
data = res.json()
if not app.base_data.version:
app.base_data.version = data.get('version')
app.base_data.description = data.get('description', data.get('summary', None))
app.base_data.icon_url = data.get('iconMobileUrl', None)
app.base_data.latest_version = data.get('currentReleaseVersion', app.base_data.version)
if app.base_data.icon_url and app.base_data.icon_url.startswith('/'):
app.base_data.icon_url = FLATHUB_URL + app.base_data.icon_url
self.api_cache.add(app.base_data.id, {
'description': app.base_data.description,
'icon_url': app.base_data.icon_url,
'latest_version': app.base_data.latest_version,
'version': app.base_data.version
})
app.status = ApplicationStatus.READY
break
else:
self.log_msg("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(app.base_data.id, res.status_code, res.content.decode()), Fore.RED)
except:
self.log_msg("Could not retrieve app data for id '{}'".format(app.base_data.id), Fore.YELLOW)
traceback.print_exc()
del self.apps[0]
def add(self, app: FlatpakApplication):
self.apps.append(app)
def current_load(self):
return len(self.apps)
class FlatpakAsyncDataLoaderManager:
def __init__(self, api_cache: Cache, worker_load: int = 3, workers: List[FlatpakAsyncDataLoader] = []):
self.worker_load = worker_load
self.current_workers = workers
self.http_session = requests.Session()
self.api_cache = api_cache
def load(self, app: FlatpakApplication):
available_workers = [w for w in self.current_workers if w.current_load() < self.worker_load]
if available_workers:
worker = available_workers[0]
else: # new worker
worker = FlatpakAsyncDataLoader(http_session=self.http_session,
api_cache=self.api_cache)
worker.start()
self.current_workers.append(worker)
worker.add(app)
def stop_current_workers(self):
for w in self.current_workers:
w.stop = True
self.current_workers = []

0
fpakman/util/__init__.py Normal file
View File

50
fpakman/util/cache.py Normal file
View File

@@ -0,0 +1,50 @@
from datetime import datetime, timedelta
from threading import Lock
class Cache:
def __init__(self, expiration_time: int):
self.expiration_time = expiration_time
self._cache = {}
self.lock = Lock()
def is_enabled(self):
return self.expiration_time < 0 or self.expiration_time > 0
def add(self, key: str, val: object):
if self.is_enabled():
self.lock.acquire()
self._cache[key] = {'val': val, 'expires_at': datetime.utcnow() + timedelta(seconds=self.expiration_time) if self.expiration_time > 0 else None}
self.lock.release()
def get(self, key: str):
if self.is_enabled():
val = self._cache.get(key)
if val:
expiration = val.get('expires_at')
if expiration and expiration <= datetime.utcnow():
self.lock.acquire()
del self._cache[key]
self.lock.release()
return None
return val['val']
def delete(self, key):
if self.is_enabled():
if key in self._cache:
self.lock.acquire()
del self._cache[key]
self.lock.release()
def keys(self):
return set(self._cache.keys()) if self.is_enabled() else set()
def clean_expired(self):
if self.is_enabled():
for key in self.keys():
self.get(key)

25
fpakman/util/memory.py Normal file
View File

@@ -0,0 +1,25 @@
import time
from threading import Thread
from typing import List
from fpakman.util.cache import Cache
import gc
class CacheCleaner(Thread):
def __init__(self, caches: List[Cache], check_interval: int = 15):
super(CacheCleaner, self).__init__(daemon=True)
self.caches = [c for c in caches if c.is_enabled()]
self.check_interval = check_interval
def run(self):
if self.caches:
while True:
for cache in self.caches:
cache.clean_expired()
gc.collect()
time.sleep(self.check_interval)

View File

@@ -1,20 +1,24 @@
from threading import Lock
from typing import List
from PyQt5.QtCore import Qt, QUrl
from PyQt5.QtGui import QPixmap, QIcon, QColor, QCursor
from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \
QHeaderView, QLabel
from fpakman.core import resource, util
from fpakman.core import resource
from fpakman.core.model import FlatpakApplication, ApplicationStatus
from fpakman.util.cache import Cache
from fpakman.view.qt import dialog
from fpakman.view.qt.view_model import ApplicationView, ApplicationViewStatus
class UpdateToggleButton(QToolButton):
def __init__(self, model: dict, root: QWidget, locale_keys: dict, checked: bool = True):
def __init__(self, app_view: ApplicationView, root: QWidget, locale_keys: dict, checked: bool = True):
super(UpdateToggleButton, self).__init__()
self.app = model
self.app_view = app_view
self.root = root
self.setCheckable(True)
self.clicked.connect(self.change_state)
@@ -28,14 +32,14 @@ class UpdateToggleButton(QToolButton):
self.click()
def change_state(self, not_checked: bool):
self.app['update_checked'] = not not_checked
self.app_view.updated_checked = not not_checked
self.setIcon(self.icon_on if not not_checked else self.icon_off)
self.root.change_update_state()
class AppsTable(QTableWidget):
def __init__(self, parent: QWidget):
def __init__(self, parent: QWidget, icon_cache: Cache):
super(AppsTable, self).__init__()
self.setParent(parent)
self.window = parent
@@ -44,7 +48,7 @@ class AppsTable(QTableWidget):
'manage_window.columns.latest_version',
'flatpak.info.branch',
'flatpak.info.arch',
'flatpak.info.id',
'flatpak.info.description',
'flatpak.info.origin',
'manage_window.columns.installed',
'manage_window.columns.update']]
@@ -60,7 +64,8 @@ class AppsTable(QTableWidget):
self.network_man = QNetworkAccessManager()
self.network_man.finished.connect(self._load_icon)
self.icon_cache = {}
self.icon_cache = icon_cache
self.lock_async_data = Lock()
def contextMenuEvent(self, QContextMenuEvent): # selected row right click event
@@ -68,7 +73,7 @@ class AppsTable(QTableWidget):
menu_row = QMenu()
if app['model']['installed']:
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)
@@ -84,7 +89,7 @@ class AppsTable(QTableWidget):
action_uninstall.triggered.connect(self._uninstall_app)
menu_row.addAction(action_uninstall)
if not app['model']['runtime']: # not available for runtimes
if isinstance(app.model, FlatpakApplication) and 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')))
@@ -99,25 +104,54 @@ class AppsTable(QTableWidget):
menu_row.popup(QCursor.pos())
menu_row.exec_()
def get_selected_app(self):
def fill_async_data(self):
self.lock_async_data.acquire()
if self.window.apps:
for idx, app_v in enumerate(self.window.apps):
if app_v.visible and app_v.status == ApplicationViewStatus.LOADING and app_v.model.status == ApplicationStatus.READY:
self.network_man.get(QNetworkRequest(QUrl(app_v.model.base_data.icon_url)))
self.item(idx, 2).setText(app_v.model.base_data.latest_version)
desc = app_v.get_async_attr('description', strip_html=True)
self.item(idx, 5).setText(desc)
self.item(idx, 5).setToolTip(desc)
app_v.status = ApplicationViewStatus.READY
visible, ready = 0, 0
for app_v in self.window.apps:
if app_v.visible:
visible += 1
if app_v.status == ApplicationViewStatus.READY:
ready += 1
if ready == visible:
self.window.resize_and_center()
self.lock_async_data.release()
def get_selected_app(self) -> ApplicationView:
return self.window.apps[self.currentRow()]
def get_selected_app_icon(self):
def get_selected_app_icon(self) -> QIcon:
return self.item(self.currentRow(), 0).icon()
def _uninstall_app(self):
selected_app = self.get_selected_app()
if dialog.ask_confirmation(title=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.title'],
body=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app['model']['name']),
body=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app.model.base_data.name),
locale_keys=self.window.locale_keys):
self.window.uninstall_app(selected_app['model']['ref'])
self.window.uninstall_app(selected_app)
def _downgrade_app(self):
selected_app = self.get_selected_app()
if dialog.ask_confirmation(title=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade'],
body=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app['model']['name']),
body=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app.model.base_data.name),
locale_keys=self.window.locale_keys):
self.window.downgrade_app(selected_app)
@@ -132,94 +166,92 @@ class AppsTable(QTableWidget):
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
if not self.icon_cache.get(icon_url):
pixmap = QPixmap()
pixmap.loadFromData(http_response.readAll())
icon = QIcon(pixmap)
self.icon_cache.add(icon_url, icon)
def update_apps(self, apps: List[dict]):
for idx, app in enumerate(self.window.apps):
if app.model.base_data.icon_url == icon_url:
self.item(idx, 0).setIcon(icon)
break
def update_apps(self, app_views: List[ApplicationView]):
self.setEnabled(True)
self.setRowCount(len(apps) if apps else 0)
self.setRowCount(len(app_views) if app_views else 0)
if apps:
for idx, app in enumerate(apps):
tooltip = util.strip_html(app['model']['description']) if app['model']['description'] else None
if app_views:
for idx, app_v in enumerate(app_views):
col_name = QTableWidgetItem()
col_name.setText(app['model']['name'])
col_name.setText(app_v.model.base_data.name)
col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
col_name.setToolTip(tooltip)
if not app['model']['icon']:
if not app_v.model.base_data.icon_url:
col_name.setIcon(self.icon_flathub)
else:
cached_icon = self.icon_cache.get(app['model']['icon'])
cached_icon = self.icon_cache.get(app_v.model.base_data.icon_url)
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.setText(app_v.model.base_data.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.setText(app_v.get_async_attr('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']:
if app_v.model.base_data.version and app_v.model.base_data.latest_version and app_v.model.base_data.version < app_v.model.base_data.latest_version:
col_release.setForeground(QColor('orange'))
col_branch = QTableWidgetItem()
col_branch.setText(app['model']['branch'])
col_branch.setText(app_v.model.branch if isinstance(app_v.model, FlatpakApplication) else '')
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.setText(app_v.model.arch if isinstance(app_v.model, FlatpakApplication) else '')
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)
desc = app_v.get_async_attr('description', strip_html=True)
col_description = QTableWidgetItem()
col_description.setText(desc)
col_description.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
if app_v.model.status == ApplicationStatus.READY:
col_description.setToolTip(desc)
self.setItem(idx, 5, col_description)
col_origin = QTableWidgetItem()
col_origin.setText(app['model']['origin'])
col_origin.setText(app_v.model.origin if isinstance(app_v.model, FlatpakApplication) else '')
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.setPixmap((QPixmap(resource.get_path('img/{}.svg'.format('checked' if app_v.model.installed else 'red_cross')))))
col_installed.setAlignment(Qt.AlignCenter)
self.setCellWidget(idx, 7, col_installed)
col_update = UpdateToggleButton(app, self.window, self.window.locale_keys, app['model']['update']) if app['model']['update'] else None
col_update = UpdateToggleButton(app_v, self.window, self.window.locale_keys, app_v.model.update) if app_v.model.update else None
self.setCellWidget(idx, 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)
if i == 5:
header_horizontal.setSectionResizeMode(i, QHeaderView.Stretch)
else:
header_horizontal.setSectionResizeMode(i, policy)

View File

@@ -11,7 +11,7 @@ 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.setWindowTitle('{} - {} '.format(locale_keys['popup.history.title'], app['model'].base_data.name))
self.setWindowIcon(app_icon)
layout = QVBoxLayout()
@@ -29,7 +29,7 @@ class HistoryDialog(QDialog):
for row, commit in enumerate(app['commits']):
current_app_commit = app['model']['commit'] == commit['commit']
current_app_commit = app['model'].commit == commit['commit']
for col, key in enumerate(sorted(commit.keys())):
item = QTableWidgetItem()

View File

@@ -1,4 +1,5 @@
import time
from threading import Lock
from typing import List
from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication, Qt
@@ -7,6 +8,8 @@ from PyQt5.QtWidgets import QSystemTrayIcon, QMenu
from fpakman.core import resource, system
from fpakman.core.controller import FlatpakManager
from fpakman.core.model import Application
from fpakman.util.cache import Cache
from fpakman.view.qt.about import AboutDialog
from fpakman.view.qt.window import ManageWindow
@@ -26,7 +29,7 @@ class UpdateCheck(QThread):
apps = self.manager.read_installed()
updates = [app for app in apps if app['update']]
updates = [app for app in apps if app.update]
if updates:
self.signal.emit(updates)
@@ -34,25 +37,13 @@ class UpdateCheck(QThread):
time.sleep(self.check_interval)
class LoadDatabase(QThread):
signal_finished = pyqtSignal()
def __init__(self, manager: FlatpakManager, parent=None):
super(LoadDatabase, self).__init__(parent)
self.manager = manager
def run(self):
self.manager.load_full_database()
self.signal_finished.emit()
class TrayIcon(QSystemTrayIcon):
def __init__(self, locale_keys: dict, manager: FlatpakManager, check_interval: int = 60, update_notification: bool = True):
def __init__(self, locale_keys: dict, manager: FlatpakManager, icon_cache: Cache, check_interval: int = 60, update_notification: bool = True):
super(TrayIcon, self).__init__()
self.locale_keys = locale_keys
self.manager = manager
self.icon_cache = icon_cache
self.icon_default = QIcon(resource.get_path('img/logo.png'))
self.icon_update = QIcon(resource.get_path('img/logo_update.png'))
@@ -60,12 +51,8 @@ class TrayIcon(QSystemTrayIcon):
self.menu = QMenu()
self.action_refreshing = self.menu.addAction(self.locale_keys['tray.action.refreshing'] + '...')
self.action_refreshing.setEnabled(False)
self.action_manage = self.menu.addAction(self.locale_keys['tray.action.manage'])
self.action_manage.triggered.connect(self.show_manage_window)
self.action_manage.setVisible(False)
self.action_about = self.menu.addAction(self.locale_keys['tray.action.about'])
self.action_about.triggered.connect(self.show_about)
@@ -82,46 +69,45 @@ class TrayIcon(QSystemTrayIcon):
self.dialog_about = None
self.thread_database = LoadDatabase(manager)
self.thread_database.signal_finished.connect(self._update_menu)
self.last_updates = set()
self.update_notification = update_notification
self.lock_notify = Lock()
def load_database(self):
self.thread_database.start()
def notify_updates(self, updates: List[Application]):
def _update_menu(self):
self.action_refreshing.setVisible(False)
self.action_manage.setVisible(True)
self.lock_notify.acquire()
def notify_updates(self, updates: List[dict]):
try:
if len(updates) > 0:
if len(updates) > 0:
update_keys = {'{}:{}'.format(app.base_data.id, app.base_data.version) for app in updates}
update_keys = {'{}:{}'.format(app['id'], app['latest_version']) for app in updates}
new_icon = self.icon_update
new_icon = self.icon_update
if update_keys.difference(self.last_updates):
self.last_updates = update_keys
msg = '{}: {}'.format(self.locale_keys['notification.new_updates'].format('Flatpak'), len(updates))
self.setToolTip(msg)
if update_keys.difference(self.last_updates):
self.last_updates = update_keys
msg = '{}: {}'.format(self.locale_keys['notification.new_updates'].format('Flatpak'), len(updates))
self.setToolTip(msg)
if self.update_notification:
system.notify_user(msg)
if self.update_notification:
system.notify_user(msg)
else:
new_icon = self.icon_default
self.setToolTip(None)
else:
new_icon = self.icon_default
self.setToolTip(None)
if self.icon().cacheKey() != new_icon.cacheKey(): # changes the icon if needed
self.setIcon(new_icon)
if self.icon().cacheKey() != new_icon.cacheKey(): # changes the icon if needed
self.setIcon(new_icon)
finally:
self.lock_notify.release()
def show_manage_window(self):
if self.manage_window is None:
self.manage_window = ManageWindow(locale_keys=self.locale_keys,
manager=self.manager,
icon_cache=self.icon_cache,
tray_icon=self)
if self.manage_window.isMinimized():

View File

@@ -1,10 +1,15 @@
import time
from datetime import datetime, timedelta
from typing import List
from PyQt5.QtCore import QThread, pyqtSignal
from fpakman.core import flatpak
from fpakman.core.controller import FlatpakManager
from fpakman.core.model import ApplicationStatus, FlatpakApplication
from fpakman.util.cache import Cache
from fpakman.view.qt import dialog
from fpakman.view.qt.view_model import ApplicationView, ApplicationViewStatus
class UpdateSelectedApps(QThread):
@@ -43,17 +48,21 @@ class UninstallApp(QThread):
signal_finished = pyqtSignal()
signal_output = pyqtSignal(str)
def __init__(self):
def __init__(self, manager: FlatpakManager, icon_cache: Cache, app: ApplicationView = None):
super(UninstallApp, self).__init__()
self.app_ref = None
self.app = app
self.manager = manager
self.icon_cache = icon_cache
def run(self):
if self.app_ref:
for output in flatpak.uninstall_and_stream(self.app_ref):
if self.app and isinstance(self.app.model, FlatpakApplication):
for output in flatpak.uninstall_and_stream(self.app.model.ref):
line = output.decode().strip()
if line:
self.signal_output.emit(line)
self.icon_cache.delete(self.app.model.base_data.icon_url)
self.manager.clean_cache_for(self.app.model.base_data.id)
self.signal_finished.emit()
@@ -61,23 +70,23 @@ class DowngradeApp(QThread):
signal_finished = pyqtSignal()
signal_output = pyqtSignal(str)
def __init__(self, manager: FlatpakManager, locale_keys: dict):
def __init__(self, manager: FlatpakManager, locale_keys: dict, app: ApplicationView = None):
super(DowngradeApp, self).__init__()
self.manager = manager
self.app = None
self.root_password = None
self.app = app
self.locale_keys = locale_keys
self.root_password = None
def run(self):
if self.app:
if self.app and isinstance(self.app.model, FlatpakApplication):
stream = self.manager.downgrade_app(self.app['model'], self.root_password)
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):
for output in self.manager.downgrade_app(self.app.model, self.root_password):
line = output.decode().strip()
if line:
self.signal_output.emit(line)
@@ -90,16 +99,16 @@ class DowngradeApp(QThread):
class GetAppInfo(QThread):
signal_finished = pyqtSignal(dict)
def __init__(self):
def __init__(self, app: ApplicationView = None):
super(GetAppInfo, self).__init__()
self.app = None
self.app = app
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']
if self.app and isinstance(self.app.model, FlatpakApplication):
app_info = flatpak.get_app_info_fields(self.app.model.base_data.id, self.app.model.branch)
app_info['name'] = self.app.model.base_data.name
app_info['type'] = 'runtime' if self.app.model.runtime else 'app'
app_info['description'] = self.app.model.base_data.description
self.signal_finished.emit(app_info)
self.app = None
@@ -107,14 +116,14 @@ class GetAppInfo(QThread):
class GetAppHistory(QThread):
signal_finished = pyqtSignal(dict)
def __init__(self):
def __init__(self, app: ApplicationView = None):
super(GetAppHistory, self).__init__()
self.app = None
self.app = app
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})
if self.app and isinstance(self.app.model, FlatpakApplication):
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
@@ -141,14 +150,14 @@ class InstallApp(QThread):
signal_finished = pyqtSignal()
signal_output = pyqtSignal(str)
def __init__(self):
def __init__(self, app: ApplicationView = None):
super(InstallApp, self).__init__()
self.app = None
self.app = app
def run(self):
if self.app:
for output in flatpak.install_and_stream(self.app['model']['id'], self.app['model']['origin']):
if self.app and isinstance(self.app.model, FlatpakApplication):
for output in flatpak.install_and_stream(self.app.model.base_data.id, self.app.model.origin):
line = output.decode().strip()
if line:
self.signal_output.emit(line)
@@ -184,3 +193,36 @@ class AnimateProgress(QThread):
time.sleep(0.05)
self.progress_value = 0
class VerifyModels(QThread):
signal_updates = pyqtSignal()
def __init__(self, apps: List[ApplicationView] = None):
super(VerifyModels, self).__init__()
self.apps = apps
def run(self):
if self.apps:
stop_at = datetime.utcnow() + timedelta(seconds=30)
last_ready = 0
while True:
current_ready = 0
for app in self.apps:
current_ready += 1 if app.model.status == ApplicationStatus.READY else 0
if current_ready > last_ready:
last_ready = current_ready
self.signal_updates.emit()
if current_ready == len(self.apps):
self.signal_updates.emit()
break
if stop_at <= datetime.utcnow():
break

View File

@@ -0,0 +1,27 @@
from enum import Enum
from fpakman.util import util
from fpakman.core.model import Application, ApplicationStatus, FlatpakApplication
class ApplicationViewStatus(Enum):
LOADING = 0
READY = 1
class ApplicationView:
def __init__(self, model: Application, visible: bool = True):
self.model = model
self.update_checked = model.update
self.visible = visible
self.status = ApplicationViewStatus.LOADING
def get_async_attr(self, attr: str, strip_html: bool = False, default: str = '...'):
if isinstance(self.model, FlatpakApplication) and self.model.runtime:
res = getattr(self.model.base_data, attr)
else:
res = getattr(self.model.base_data, attr) if self.model.status == ApplicationStatus.READY and getattr(self.model.base_data, attr) else default
return util.strip_html(res) if res and strip_html else res

View File

@@ -10,13 +10,16 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHead
from fpakman.core import resource, flatpak
from fpakman.core.controller import FlatpakManager
from fpakman.core.model import Application, FlatpakApplication
from fpakman.util.cache import Cache
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
GetAppHistory, SearchApps, InstallApp, AnimateProgress, VerifyModels
from fpakman.view.qt.view_model import ApplicationView
DARK_ORANGE = '#FF4500'
@@ -24,7 +27,7 @@ DARK_ORANGE = '#FF4500'
class ManageWindow(QWidget):
__BASE_HEIGHT__ = 400
def __init__(self, locale_keys: dict, manager: FlatpakManager, tray_icon=None):
def __init__(self, locale_keys: dict, icon_cache: Cache, manager: FlatpakManager, tray_icon=None):
super(ManageWindow, self).__init__()
self.locale_keys = locale_keys
self.manager = manager
@@ -33,6 +36,7 @@ class ManageWindow(QWidget):
self.working = False # restrict the number of threaded actions
self.apps = []
self.label_flatpak = None
self.icon_cache = icon_cache
self.icon_flathub = QIcon(resource.get_path('img/logo.svg'))
self._check_flatpak_installed()
@@ -101,7 +105,7 @@ class ManageWindow(QWidget):
self.layout.addWidget(toolbar)
self.table_apps = AppsTable(self)
self.table_apps = AppsTable(self, self.icon_cache)
self.table_apps.change_headers_policy()
self.layout.addWidget(self.table_apps)
@@ -131,7 +135,7 @@ class ManageWindow(QWidget):
self.thread_refresh = RefreshApps(self.manager)
self.thread_refresh.signal.connect(self._finish_refresh)
self.thread_uninstall = UninstallApp()
self.thread_uninstall = UninstallApp(self.manager, self.icon_cache)
self.thread_uninstall.signal_output.connect(self._update_action_output)
self.thread_uninstall.signal_finished.connect(self._finish_uninstall)
@@ -155,6 +159,9 @@ class ManageWindow(QWidget):
self.thread_animate_progress = AnimateProgress()
self.thread_animate_progress.signal_change.connect(self._update_progress)
self.thread_verify_models = VerifyModels()
self.thread_verify_models.signal_updates.connect(self._notify_model_data_change)
self.toolbar_bottom = QToolBar()
self.label_updates = QLabel('')
self.label_updates.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE))
@@ -175,6 +182,9 @@ class ManageWindow(QWidget):
self.centralize()
def _notify_model_data_change(self):
self.table_apps.fill_async_data()
def _new_spacer(self):
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
@@ -250,20 +260,20 @@ class ManageWindow(QWidget):
self.thread_refresh.start()
def _finish_refresh(self, apps: List[dict]):
def _finish_refresh(self, apps: List[Application]):
self.update_apps(apps)
self.finish_action()
self._release_lock()
def uninstall_app(self, app_ref: str):
def uninstall_app(self, app: ApplicationView):
self._check_flatpak_installed()
if self._acquire_lock():
self._handle_console_option(True)
self._begin_action(self.locale_keys['manage_window.status.uninstalling'])
self.thread_uninstall.app_ref = app_ref
self.thread_uninstall.app = app
self.thread_uninstall.start()
def _finish_uninstall(self):
@@ -281,10 +291,10 @@ class ManageWindow(QWidget):
if self.apps:
show_only_apps = True if only_apps == 2 else False
for idx, app in enumerate(self.apps):
hidden = show_only_apps and app['model']['runtime']
for idx, app_v in enumerate(self.apps):
hidden = show_only_apps and isinstance(app_v.model, FlatpakApplication) and app_v.model.runtime
self.table_apps.setRowHidden(idx, hidden)
app['visible'] = not hidden
app_v.visible = not hidden
self.change_update_state()
self.table_apps.change_headers_policy(QHeaderView.Stretch)
@@ -297,9 +307,9 @@ class ManageWindow(QWidget):
app_updates, runtime_updates = 0, 0
for app in self.apps:
if app['model']['update']:
if app['model']['runtime']:
for app_v in self.apps:
if app_v.model.update:
if app_v.model.runtime:
runtime_updates += 1
else:
app_updates += 1
@@ -314,13 +324,13 @@ class ManageWindow(QWidget):
else:
self.label_updates.setText('')
for app in self.apps:
if app['visible'] and app['update_checked']:
for app_v in self.apps:
if app_v.visible and app_v.update_checked:
enable_bt_update = True
break
self.bt_upgrade.setEnabled(enable_bt_update)
self.tray_icon.notify_updates([app['model'] for app in self.apps if app['model']['update']])
self.tray_icon.notify_updates([app.model for app in self.apps if app.model.update])
def centralize(self):
geo = self.frameGeometry()
@@ -329,7 +339,7 @@ class ManageWindow(QWidget):
geo.moveCenter(center_point)
self.move(geo.topLeft())
def update_apps(self, apps: List[dict]):
def update_apps(self, apps: List[Application]):
self._check_flatpak_installed()
self.apps = []
@@ -338,11 +348,10 @@ class ManageWindow(QWidget):
if apps:
for app in apps:
app_model = {'model': app,
'update_checked': app['update'],
'visible': not app['runtime'] or not self.checkbox_only_apps.isChecked()}
app_model = ApplicationView(model=app,
visible=(isinstance(app, FlatpakApplication) and not app.runtime) or not self.checkbox_only_apps.isChecked())
napps += 1 if not app['runtime'] else 0
napps += 1 if isinstance(app, FlatpakApplication) and not app.runtime else 0
self.apps.append(app_model)
if napps == 0:
@@ -357,9 +366,11 @@ class ManageWindow(QWidget):
self.filter_only_apps(2 if self.checkbox_only_apps.isChecked() else 0)
self.resize_and_center()
self.thread_verify_models.apps = self.apps
self.thread_verify_models.start()
def resize_and_center(self):
new_width = reduce(operator.add,
[self.table_apps.columnWidth(i) for i in range(len(self.table_apps.column_names))]) * 1.05
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()
@@ -368,7 +379,7 @@ class ManageWindow(QWidget):
if self._acquire_lock():
if self.apps:
to_update = [pak['model']['ref'] for pak in self.apps if pak['visible'] and pak['update_checked']]
to_update = [app_v.model.ref for app_v in self.apps if app_v.visible and app_v.update_checked and isinstance(app_v.model, FlatpakApplication)]
if to_update:
self._handle_console_option(True)
@@ -390,7 +401,7 @@ class ManageWindow(QWidget):
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)
@@ -398,12 +409,11 @@ class ManageWindow(QWidget):
self.checkbox_only_apps.setEnabled(False)
self.table_apps.setEnabled(False)
def finish_action(self, clear_search: bool = True):
def finish_action(self):
self.ref_progress_bar.setVisible(False)
self.ref_label_updates.setVisible(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)
@@ -411,9 +421,6 @@ class ManageWindow(QWidget):
self.input_search.setEnabled(True)
self.label_status.setText('')
if clear_search:
self.input_search.setText('')
def downgrade_app(self, app: dict):
self._check_flatpak_installed()
@@ -477,10 +484,10 @@ class ManageWindow(QWidget):
self.thread_search.word = word
self.thread_search.start()
def _finish_search(self, apps_found: List[dict]):
def _finish_search(self, apps_found: List[Application]):
self._release_lock()
self.finish_action(clear_search=False)
self.finish_action()
self.update_apps(apps_found)
def install_app(self, app: dict):