diff --git a/bauh/api/__init__.py b/bauh/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bauh/api/abstract/__init__.py b/bauh/api/abstract/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bauh/api/abstract/cache.py b/bauh/api/abstract/cache.py
new file mode 100644
index 00000000..ac3c2534
--- /dev/null
+++ b/bauh/api/abstract/cache.py
@@ -0,0 +1,50 @@
+from abc import ABC, abstractmethod
+from typing import Set
+
+
+class MemoryCache(ABC):
+ """
+ Represents a memory cache.
+ """
+
+ @abstractmethod
+ def is_enabled(self):
+ pass
+
+ @abstractmethod
+ def add(self, key: str, val: object):
+ pass
+
+ @abstractmethod
+ def add_non_existing(self, key: str, val: object):
+ pass
+
+ @abstractmethod
+ def get(self, key: str):
+ pass
+
+ @abstractmethod
+ def delete(self, key):
+ pass
+
+ @abstractmethod
+ def keys(self) -> Set[str]:
+ pass
+
+ @abstractmethod
+ def clean_expired(self):
+ pass
+
+
+class MemoryCacheFactory(ABC):
+ """
+ Instantiate new memory cache instances.
+ """
+
+ @abstractmethod
+ def new(self, expiration: int) -> MemoryCache:
+ """
+ :param expiration: expiration time for the cache keys in seconds. Use -1 to disable this feature.
+ :return:
+ """
+ pass
diff --git a/bauh/api/abstract/context.py b/bauh/api/abstract/context.py
new file mode 100644
index 00000000..6d4b2023
--- /dev/null
+++ b/bauh/api/abstract/context.py
@@ -0,0 +1,30 @@
+import logging
+
+from bauh.api.abstract.cache import MemoryCacheFactory
+from bauh.api.abstract.disk import DiskCacheLoaderFactory
+from bauh.api.http import HttpClient
+
+
+class ApplicationContext:
+
+ def __init__(self, disk_cache: bool, download_icons: bool, http_client: HttpClient, app_root_dir: str, i18n: dict,
+ cache_factory: MemoryCacheFactory, disk_loader_factory: DiskCacheLoaderFactory,
+ logger: logging.Logger):
+ """
+ :param disk_cache: if package data should be cached to disk
+ :param download_icons: if packages icons should be downloaded
+ :param http_client: a shared instance of http client
+ :param app_root_dir: GUI root dir
+ :param i18n: the i18n dictionary keys
+ :param cache_factory:
+ :param disk_loader_factory:
+ :param logger: a logger instance
+ """
+ self.disk_cache = disk_cache
+ self.download_icons = download_icons
+ self.http_client = http_client
+ self.app_root_dir = app_root_dir
+ self.i18n = i18n
+ self.cache_factory = cache_factory
+ self.disk_loader_factory = disk_loader_factory
+ self.logger = logger
diff --git a/bauh/api/abstract/controller.py b/bauh/api/abstract/controller.py
new file mode 100644
index 00000000..949bc876
--- /dev/null
+++ b/bauh/api/abstract/controller.py
@@ -0,0 +1,214 @@
+import json
+import os
+import shutil
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import List, Set, Type
+
+from bauh.api.abstract.context import ApplicationContext
+from bauh.api.abstract.disk import DiskCacheLoader
+from bauh.api.abstract.handler import ProcessWatcher
+from bauh.api.abstract.model import SoftwarePackage, PackageUpdate, PackageHistory, PackageSuggestion, PackageAction
+
+
+class SearchResult:
+
+ def __init__(self, installed: List[SoftwarePackage], new: List[SoftwarePackage], total: int):
+ """
+ :param installed: already installed packages
+ :param new: new packages found
+ :param total: total number of applications actually found
+ """
+ self.installed = installed
+ self.new = new
+ self.total = total
+
+
+class SoftwareManager(ABC):
+
+ """
+ Base controller class that will be called by the graphical interface to execute operations.
+ """
+
+ def __init__(self, context: ApplicationContext):
+ """
+ :param context:
+ """
+ self.context = context
+
+ @abstractmethod
+ def search(self, words: str, disk_loader: DiskCacheLoader, limit: int) -> SearchResult:
+ """
+ :param words: the words typed by the user
+ :param disk_loader: a running disk loader thread that loads package data from the disk asynchronously
+ :param limit: the max number of packages to be retrieved. <= 1 should retrieve everything
+ :return:
+ """
+ pass
+
+ @abstractmethod
+ def read_installed(self, disk_loader: DiskCacheLoader, limit: int, only_apps: bool, pkg_types: Set[Type[SoftwarePackage]]) -> SearchResult:
+ """
+ :param disk_loader: a running disk loader thread that loads application data from the disk asynchronously
+ :param limit: the max number of packages to be retrieved. <= 1 should retrieve everything
+ :param only_apps: if only application packages should be retrieved
+ :param pkg_types: use 'None' to bring any or specify some
+ :return:
+ """
+ pass
+
+ @abstractmethod
+ def downgrade(self, pkg: SoftwarePackage, root_password: str, handler: ProcessWatcher) -> bool:
+ """
+ downgrades a package version
+ :param pkg:
+ :param root_password: the root user password (if required)
+ :param handler: a subprocess handler
+ :return:
+ """
+ pass
+
+ def clean_cache_for(self, pkg: SoftwarePackage):
+ """
+ Cleans cached package cached data. This default implementation only cleans the cached data from the heard disk
+ :param pkg:
+ :return:
+ """
+ if pkg.supports_disk_cache() and os.path.exists(pkg.get_disk_cache_path()):
+ shutil.rmtree(pkg.get_disk_cache_path())
+
+ @abstractmethod
+ def update(self, pkg: SoftwarePackage, root_password: str, watcher: ProcessWatcher) -> bool:
+ """
+ :param pkg:
+ :param root_password: the root user password (if required)
+ :param watcher:
+ :return:
+ """
+ pass
+
+ @abstractmethod
+ def uninstall(self, pkg: SoftwarePackage, root_password: str, watcher: ProcessWatcher) -> bool:
+ """
+ :param pkg:
+ :param root_password: the root user password (if required)
+ :param watcher:
+ :return: if the uninstall succeeded
+ """
+ pass
+
+ @abstractmethod
+ def get_managed_types(self) -> Set[Type[SoftwarePackage]]:
+ """
+ :return: the managed package class type
+ """
+ pass
+
+ @abstractmethod
+ def get_info(self, pkg: SoftwarePackage) -> dict:
+ """
+ retrieve the package information
+ :param pkg:
+ :return: a dictionary with the attributes to be shown
+ """
+ pass
+
+ @abstractmethod
+ def get_history(self, pkg: SoftwarePackage) -> PackageHistory:
+ """
+ :param pkg:
+ :return:
+ """
+ pass
+
+ @abstractmethod
+ def install(self, pkg: SoftwarePackage, root_password: str, watcher: ProcessWatcher) -> bool:
+ """
+ :param pkg:
+ :param root_password: the root user password (if required)
+ :param watcher:
+ :return: if the installation succeeded
+ """
+ pass
+
+ @abstractmethod
+ def is_enabled(self) -> bool:
+ """
+ :return: if the instance is available to perform actions
+ """
+ pass
+
+ def cache_to_disk(self, pkg: SoftwarePackage, icon_bytes: bytes, only_icon: bool):
+ """
+ Saves the package data to the hard disk.
+ :param pkg:
+ :param icon_bytes:
+ :param only_icon: if only the icon should be saved
+ :return:
+ """
+ if pkg.supports_disk_cache():
+
+ if not only_icon:
+ Path(pkg.get_disk_cache_path()).mkdir(parents=True, exist_ok=True)
+ data = pkg.get_data_to_cache()
+
+ if data:
+ with open(pkg.get_disk_data_path(), 'w+') as f:
+ f.write(json.dumps(data))
+
+ if icon_bytes:
+ Path(pkg.get_disk_cache_path()).mkdir(parents=True, exist_ok=True)
+
+ with open(pkg.get_disk_icon_path(), 'wb+') as f:
+ f.write(icon_bytes)
+
+ @abstractmethod
+ def requires_root(self, action: str, pkg: SoftwarePackage):
+ """
+ if a given action requires root privileges to be executed. Current actions are: 'install', 'uninstall', 'downgrade', 'search', 'refresh'
+ :param action:
+ :param pkg:
+ :return:
+ """
+ pass
+
+ @abstractmethod
+ def prepare(self):
+ """
+ It prepares the manager to start working. It will be called by GUI. Do not call it within.
+ :return:
+ """
+ pass
+
+ @abstractmethod
+ def list_updates(self) -> List[PackageUpdate]:
+ """
+ :return: available package updates
+ """
+ pass
+
+ @abstractmethod
+ def list_warnings(self) -> List[str]:
+ """
+ :return: a list of warnings to be shown to the user
+ """
+ pass
+
+ @abstractmethod
+ def list_suggestions(self, limit: int) -> List[PackageSuggestion]:
+ """
+ :param limit: max suggestions to be returned. If limit < 0, it should not be considered
+ :return: a list of package suggestions
+ """
+ pass
+
+ def execute_custom_action(self, action: PackageAction, pkg: SoftwarePackage, root_password: str, watcher: ProcessWatcher) -> bool:
+ """
+ At the moment the GUI implements this action. No need to implement it yourself.
+ :param action:
+ :param pkg:
+ :param root_password:
+ :param watcher:
+ :return: if the action resulted in success
+ """
+ pass
diff --git a/bauh/api/abstract/disk.py b/bauh/api/abstract/disk.py
new file mode 100644
index 00000000..02de2707
--- /dev/null
+++ b/bauh/api/abstract/disk.py
@@ -0,0 +1,42 @@
+from abc import ABC, abstractmethod
+from typing import Type
+
+from bauh.api.abstract.cache import MemoryCache
+from bauh.api.abstract.model import SoftwarePackage
+
+
+class DiskCacheLoader:
+ """
+ Reads cached data from the disk and fills package instances.
+ """
+
+ def map(self, cache: MemoryCache, pkg_type: Type[SoftwarePackage]):
+ """
+ maps a given cache instance for a given package type
+ :param cache:
+ :param pkg_type:
+ :return:
+ """
+ pass
+
+ def fill(self, pkg: SoftwarePackage):
+ """
+ fill cached data from the disk of a given package instance
+ If a cache mapping was previously done, then data retrieved will be cached to memory as well.
+ :param pkg:
+ :return:
+ """
+ pass
+
+
+class DiskCacheLoaderFactory(ABC):
+
+ @abstractmethod
+ def map(self, pkg_type: Type[SoftwarePackage], cache: MemoryCache):
+ """
+ Associated a cache instance to instances of a given SoftwarePackage class
+ :param pkg_type:
+ :param cache:
+ :return:
+ """
+ pass
diff --git a/bauh/api/abstract/handler.py b/bauh/api/abstract/handler.py
new file mode 100644
index 00000000..ffdc52c6
--- /dev/null
+++ b/bauh/api/abstract/handler.py
@@ -0,0 +1,67 @@
+from typing import List
+
+from bauh.api.abstract.view import MessageType, ViewComponent
+
+
+class ProcessWatcher:
+ """
+ Represents an view component watching background processes. It's a bridge for ApplicationManager instances notifiy the view of processes progression and also
+ request any user interaction without the need of knowing any GUI code.
+ """
+
+ def print(self, msg: str):
+ """
+ prints a given message to the user. In the current GUI implementation, the message is printed on the terminal window ("Details")
+ :param msg:
+ :return:
+ """
+ pass
+
+ def request_confirmation(self, title: str, body: str, components: List[ViewComponent] = None, confirmation_label: str = None, deny_label: str = None)-> bool:
+ """
+ request a user confirmation. In the current GUI implementation, it shows a popup to the user.
+ :param title: popup title
+ :param body: popup body
+ :param components: extra view components that will be rendered to the confirmation popup.
+ :param confirmation_label: optional confirmation button label (default to 'yes')
+ :param deny_label: optional deny button label (default to 'no')
+ :return: if the request was confirmed by the user
+ """
+ pass
+
+ def show_message(self, title: str, body: str, type_: MessageType = MessageType.INFO):
+ """
+ shows a message to the user. In the current GUI implementation, it shows a popup.
+ :param title:
+ :param body:
+ :param type_: determines the icon that will be displayed
+ :return:
+ """
+ pass
+
+ def change_status(self, msg: str):
+ """
+ Changes the process status. In the current GUI implementation, the process status is displayed above the toolbar.
+ :param msg: msg
+ :return:
+ """
+ pass
+
+ def change_substatus(self, msg: str):
+ """
+ Changes the process substatus. In the current GUI implementation, the process substatus is displayed above the progress bar.
+ :param msg:
+ :return:
+ """
+
+ def change_progress(self, val: int):
+ """
+ Changes the process progress. In the current GUI implementation, the progress is displayed as a bottom bar.
+ :param val: a val between 0 and 100
+ :return:
+ """
+
+ def should_stop(self) -> bool:
+ """
+ :return: if the use requested to stop the process.
+ """
diff --git a/bauh/api/abstract/model.py b/bauh/api/abstract/model.py
new file mode 100644
index 00000000..0843da6d
--- /dev/null
+++ b/bauh/api/abstract/model.py
@@ -0,0 +1,222 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import List
+
+from bauh.api.constants import CACHE_PATH
+
+
+class PackageAction:
+
+ def __init__(self, i18_label_key: str, i18n_status_key: str, icon_path: str, manager_method: str, requires_root: bool):
+ """
+ :param i18_label_key: the i18n key that will be used to display the action name
+ :param i18n_status_key: the i18n key that will be used to display the action name being executed
+ :param icon_path: the action icon path. Use None for no icon
+ :param manager_method: the SoftwareManager method name that should be called. The method must has the following parameters: (pkg: SoftwarePackage, root_password: str, watcher: ProcessWatcher)
+ :param requires_root:
+ """
+ self.id = id
+ self.i18_label_key = i18_label_key
+ self.i18n_status_key = i18n_status_key
+ self.icon_path = icon_path
+ self.manager_method = manager_method
+ self.requires_root = requires_root
+
+
+class PackageStatus(Enum):
+ READY = 1 # when all package data is already filled
+ LOADING_DATA = 2 # when some package data is already being retrieved asynchronously
+
+
+class SoftwarePackage(ABC):
+
+ def __init__(self, id: str = None, version: str = None, name: str = None, description: str = None, latest_version: str = None,
+ icon_url: str = None, status: PackageStatus = PackageStatus.READY, installed: bool = False, update: bool = False,
+ size: int = None, categories: List[str] = None):
+ """
+ :param id:
+ :param version:
+ :param name:
+ :param description:
+ :param latest_version:
+ :param icon_url: the path to the package icon. It can be an URL or PATH
+ :param status:
+ :param installed:
+ :param update: if there is an update for package
+ :param size: package size in BYTES
+ :param categories: package categories. i.e: video editor, web browser, etc
+ """
+ self.id = id
+ self.name = name
+ self.description = description
+ self.status = status
+ self.version = version
+ self.latest_version = latest_version
+ self.icon_url = icon_url
+ self.installed = installed
+ self.update = update
+ self.size = size
+ self.categories = categories
+
+ @abstractmethod
+ def has_history(self):
+ """
+ :return: if the application has a commit history that can be shown to the user
+ """
+ pass
+
+ @abstractmethod
+ def has_info(self):
+ """
+ :return: if the application has additional information that can be shown to the user
+ """
+ pass
+
+ @abstractmethod
+ def can_be_downgraded(self):
+ pass
+
+ def can_be_uninstalled(self):
+ return self.installed
+
+ def can_be_installed(self):
+ return not self.installed
+
+ @abstractmethod
+ def get_type(self):
+ """
+ :return: a string that represents the application type
+ """
+ pass
+
+ @abstractmethod
+ def get_default_icon_path(self):
+ """
+ :return: the path of a default icon when the application icon could not be retrieved (or will not be retrieved)
+ """
+ pass
+
+ @abstractmethod
+ def get_type_icon_path(self):
+ """
+ :return: the path of the application type icon
+ """
+
+ @abstractmethod
+ def is_application(self):
+ """
+ :return: if the package is an application
+ """
+ pass
+
+ def supports_disk_cache(self):
+ """
+ :return: if some application data and icon should be cached to the user disk
+ """
+ return self.installed and self.is_application()
+
+ def get_disk_cache_path(self):
+ """
+ :return: base cache path for the specific app type
+ """
+ return CACHE_PATH + '/' + self.get_type()
+
+ def get_disk_icon_path(self):
+ return '{}/icon.png'.format(self.get_disk_cache_path())
+
+ def get_disk_data_path(self):
+ return '{}/data.json'.format(self.get_disk_cache_path())
+
+ @abstractmethod
+ def get_data_to_cache(self) -> dict:
+ """
+ :return: the application data that should be cached in disk / memory for quick access
+ """
+ pass
+
+ @abstractmethod
+ def fill_cached_data(self, data: dict):
+ """
+ sets cached data to the current instance
+ :param data:
+ :return:
+ """
+ pass
+
+ @abstractmethod
+ def can_be_run(self) -> str:
+ """
+ :return: whether the app can be run via the GUI
+ """
+
+ @abstractmethod
+ def get_command(self) -> str:
+ """
+ :return: the command to run the application
+ """
+ pass
+
+ def is_trustable(self) -> bool:
+ """
+ :return: if the package is distributed by a trustable source
+ """
+ return False
+
+ @abstractmethod
+ def get_publisher(self) -> str:
+ """
+ :return: the package publisher / maintainer
+ """
+ pass
+
+ def get_custom_supported_actions(self) -> List[PackageAction]:
+ """
+ :return: custom supported actions
+ """
+ pass
+
+ def __str__(self):
+ return '{} (id={}, name={})'.format(self.__class__.__name__, self.id, self.name)
+
+
+class PackageUpdate:
+
+ def __init__(self, pkg_id: str, version: str, pkg_type: str):
+ """
+ :param pkg_id: an unique package identifier
+ :param version: the new version
+ :param pkg_type: the package type
+ """
+ self.id = pkg_id
+ self.version = version
+ self.type = pkg_type
+
+ def __str__(self):
+ return '{} (id={}, type={}, new_version={})'.format(self.__class__.__name__, self.id, self.type, self.type)
+
+
+class PackageHistory:
+
+ def __init__(self, pkg: SoftwarePackage, history: List[dict], pkg_status_idx: int):
+ """
+ :param pkg
+ :param history: a list with the package history.
+ :param pkg_status_idx: 'history' index in which the application is current found
+ """
+ self.pkg = pkg
+ self.history = history
+ self.pkg_status_idx = pkg_status_idx
+
+
+class SuggestionPriority(Enum):
+ LOW = 0
+ MEDIUM = 1
+ HIGH = 2
+ TOP = 3
+
+
+class PackageSuggestion:
+
+ def __init__(self, package: SoftwarePackage, priority: SuggestionPriority):
+ self.package = package
+ self.priority = priority
diff --git a/bauh/api/abstract/view.py b/bauh/api/abstract/view.py
new file mode 100644
index 00000000..e6dbbb08
--- /dev/null
+++ b/bauh/api/abstract/view.py
@@ -0,0 +1,90 @@
+from abc import ABC
+from enum import Enum
+from typing import List, Set
+
+
+class MessageType:
+ INFO = 0
+ WARNING = 1
+ ERROR = 2
+
+
+class ViewComponent(ABC):
+ """
+ Represents a GUI component
+ """
+ def __init__(self, id_: str):
+ self.id = id_
+
+
+class InputViewComponent(ViewComponent):
+ """
+ Represents an component which needs a user interaction to provide its value
+ """
+
+
+class InputOption:
+ """
+ Represents a select component option.
+ """
+
+ def __init__(self, label: str, value: str, tooltip: str = None, icon_path: str = None):
+ """
+ :param label: the string that will be shown to the user
+ :param value: the option value (not shown)
+ :param tooltip: an optional tooltip
+ :param icon_path: an optional icon path
+ """
+ if not label:
+ raise Exception("'label' must be a not blank string")
+
+ if not value:
+ raise Exception("'value' must be a not blank string")
+
+ self.label = label
+ self.value = value
+ self.tooltip = tooltip
+ self.icon_path = icon_path
+
+ def __hash__(self):
+ return hash(self.label) + hash(self.value)
+
+
+class SelectViewType(Enum):
+ RADIO = 0
+ COMBO = 1
+
+
+class SingleSelectComponent(InputViewComponent):
+
+ def __init__(self, type_: SelectViewType, label: str, options: List[InputOption], default_option: InputOption = None, max_per_line: int = 1, id_: str = None):
+ super(SingleSelectComponent, self).__init__(id_=id_)
+ if options is None or len(options) < 2:
+ raise Exception("'options' must be a list with at least 2 elements")
+
+ self.type = type_
+ self.label = label
+ self.options = options
+ self.value = default_option
+ self.max_per_line = max_per_line
+
+
+class MultipleSelectComponent(InputViewComponent):
+
+ def __init__(self, label: str, options: List[InputOption], default_options: Set[InputOption] = None, max_per_line: int = 1, id_: str = None):
+ super(MultipleSelectComponent, self).__init__(id_=id_)
+
+ if not options:
+ raise Exception("'options' cannot be None or empty")
+
+ self.options = options
+ self.label = label
+ self.values = default_options if default_options else set()
+ self.max_per_line = max_per_line
+
+
+class TextComponent(ViewComponent):
+
+ def __init__(self, html: str, id_: str = None):
+ super(TextComponent, self).__init__(id_=id_)
+ self.value = html
diff --git a/bauh/api/constants.py b/bauh/api/constants.py
new file mode 100644
index 00000000..e216d7f2
--- /dev/null
+++ b/bauh/api/constants.py
@@ -0,0 +1,4 @@
+from pathlib import Path
+
+HOME_PATH = Path.home()
+CACHE_PATH = '{}/.cache/bauh'.format(HOME_PATH)
diff --git a/bauh/api/exception.py b/bauh/api/exception.py
new file mode 100644
index 00000000..8d00ac1a
--- /dev/null
+++ b/bauh/api/exception.py
@@ -0,0 +1,3 @@
+
+class NoInternetException(Exception):
+ pass
diff --git a/bauh/api/http.py b/bauh/api/http.py
new file mode 100644
index 00000000..d01d9cc7
--- /dev/null
+++ b/bauh/api/http.py
@@ -0,0 +1,40 @@
+import logging
+import time
+import traceback
+
+import requests
+
+
+class HttpClient:
+
+ def __init__(self, logger: logging.Logger, max_attempts: int = 2, timeout: int = 30, sleep: float = 0.5):
+ self.max_attempts = max_attempts
+ self.session = requests.Session()
+ self.timeout = timeout
+ self.sleep = sleep
+ self.logger = logger
+
+ def get(self, url: str):
+ cur_attempts = 1
+
+ while cur_attempts <= self.max_attempts:
+ cur_attempts += 1
+
+ try:
+ res = self.session.get(url, timeout=self.timeout)
+
+ if res.status_code == 200:
+ return res
+
+ if self.sleep > 0:
+ time.sleep(self.sleep)
+ except:
+ self.logger.error("Could not retrieve data from '{}'".format(url))
+ traceback.print_exc()
+ continue
+
+ self.logger.warning("Could not retrieve data from '{}'".format(url))
+
+ def get_json(self, url: str):
+ res = self.get(url)
+ return res.json() if res else None
diff --git a/bauh/app.py b/bauh/app.py
index 39652824..ea910e0c 100755
--- a/bauh/app.py
+++ b/bauh/app.py
@@ -2,15 +2,16 @@ import sys
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QApplication
-from bauh_api.abstract.controller import ApplicationContext
-from bauh_api.http import HttpClient
from bauh import __version__, __app_name__, app_args, ROOT_DIR
-from bauh.core import extensions
+from bauh.api.abstract.controller import ApplicationContext
+from bauh.api.http import HttpClient
+from bauh.core import gems, config
from bauh.core.controller import GenericSoftwareManager
from bauh.util import util, logs, resource
from bauh.util.cache import DefaultMemoryCacheFactory, CacheCleaner
from bauh.util.disk import DefaultDiskCacheLoaderFactory
+from bauh.view.qt.gem_selector import GemSelectorPanel
from bauh.view.qt.systray import TrayIcon
from bauh.view.qt.window import ManageWindow
@@ -33,37 +34,48 @@ context = ApplicationContext(i18n=i18n,
disk_loader_factory=DefaultDiskCacheLoaderFactory(disk_cache_enabled=args.disk_cache),
logger=logger)
-managers = extensions.load_managers(context=context, locale=args.locale)
-
-
-manager = GenericSoftwareManager(managers, context=context, app_args=args)
-manager.prepare()
-
app = QApplication(sys.argv)
app.setApplicationName(__app_name__)
app.setApplicationVersion(__version__)
app.setWindowIcon(QIcon(resource.get_path('img/logo.svg')))
-manage_window = ManageWindow(i18n=i18n,
- manager=manager,
- icon_cache=icon_cache,
- disk_cache=args.disk_cache,
- download_icons=bool(args.download_icons),
- screen_size=app.primaryScreen().size(),
- suggestions=args.sugs,
- display_limit=args.max_displayed)
+user_config = config.read()
-if args.tray:
- trayIcon = TrayIcon(locale_keys=i18n,
- manager=manager,
- manage_window=manage_window,
- check_interval=args.check_interval,
- update_notification=bool(args.update_notification))
- manage_window.tray_icon = trayIcon
- trayIcon.show()
+if not user_config.gems:
+ managers = gems.load_managers(context=context, locale=args.locale)
else:
- manage_window.refresh_apps()
- manage_window.show()
+ managers = gems.load_managers(context=context, locale=args.locale, names=user_config.gems)
+
+enabled_managers = [m for m in managers if m.is_enabled()]
+
+if not user_config.gems and enabled_managers:
+ gem_panel = GemSelectorPanel(enabled_managers, i18n, boot=True)
+ gem_panel.show()
+else:
+ manager = GenericSoftwareManager(enabled_managers, context=context, app_args=args)
+ manager.prepare()
+
+ manage_window = ManageWindow(i18n=i18n,
+ manager=manager,
+ icon_cache=icon_cache,
+ disk_cache=args.disk_cache,
+ download_icons=bool(args.download_icons),
+ screen_size=app.primaryScreen().size(),
+ suggestions=args.sugs,
+ display_limit=args.max_displayed)
+
+ if args.tray:
+ trayIcon = TrayIcon(locale_keys=i18n,
+ manager=manager,
+ manage_window=manage_window,
+ check_interval=args.check_interval,
+ update_notification=bool(args.update_notification))
+ manage_window.tray_icon = trayIcon
+ trayIcon.show()
+ else:
+ manage_window.refresh_apps()
+ manage_window.show()
+
+ cache_cleaner.start()
-cache_cleaner.start()
sys.exit(app.exec_())
diff --git a/bauh/commons/__init__.py b/bauh/commons/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bauh/commons/html.py b/bauh/commons/html.py
new file mode 100644
index 00000000..7ca8bbbe
--- /dev/null
+++ b/bauh/commons/html.py
@@ -0,0 +1,11 @@
+import re
+
+HTML_RE = re.compile(r'<[^>]+>')
+
+
+def strip_html(string: str):
+ return HTML_RE.sub('', string)
+
+
+def bold(text: str) -> str:
+ return '{}'.format(text)
diff --git a/bauh/commons/resource.py b/bauh/commons/resource.py
new file mode 100644
index 00000000..2f48b317
--- /dev/null
+++ b/bauh/commons/resource.py
@@ -0,0 +1,3 @@
+
+def get_path(resource_path, root_dir: str):
+ return root_dir + '/resources/' + resource_path
diff --git a/bauh/commons/system.py b/bauh/commons/system.py
new file mode 100644
index 00000000..2e9f8a58
--- /dev/null
+++ b/bauh/commons/system.py
@@ -0,0 +1,135 @@
+import os
+import subprocess
+from subprocess import PIPE
+from typing import List
+
+# default environment variables for subprocesses.
+from bauh.api.abstract.handler import ProcessWatcher
+
+GLOBAL_INTERPRETER_PATH = ':'.join(os.getenv('PATH').split(':')[1:])
+USE_GLOBAL_INTERPRETER = bool(os.getenv('VIRTUAL_ENV'))
+
+
+def gen_env(global_interpreter: bool) -> dict:
+ res = {'LANG': 'en'}
+
+ if global_interpreter: # to avoid subprocess calls to the virtualenv python interpreter instead of the global one.
+ res['PATH'] = GLOBAL_INTERPRETER_PATH
+
+ return res
+
+
+class SystemProcess:
+
+ """
+ Represents a system process being executed.
+ """
+
+ def __init__(self, subproc: subprocess.Popen, success_phrase: str = None, wrong_error_phrase: str = '[sudo] password for', check_error_output: bool = True):
+ self.subproc = subproc
+ self.success_phrase = success_phrase
+ self.wrong_error_phrase = wrong_error_phrase
+ self.check_error_output = check_error_output
+
+ def wait(self):
+ self.subproc.wait()
+
+
+class ProcessHandler:
+
+ """
+ It handles a process execution and notifies a specified watcher.
+ """
+
+ def __init__(self, watcher: ProcessWatcher = None):
+ self.watcher = watcher
+
+ def _notify_watcher(self, msg: str):
+ if self.watcher:
+ self.watcher.print(msg)
+
+ def handle(self, process: SystemProcess) -> bool:
+ self._notify_watcher(' '.join(process.subproc.args) + '\n')
+
+ already_succeeded = False
+
+ for output in process.subproc.stdout:
+ line = output.decode().strip()
+ if line:
+ self._notify_watcher(line)
+
+ if process.success_phrase and process.success_phrase in line:
+ already_succeeded = True
+
+ if already_succeeded:
+ return True
+
+ for output in process.subproc.stderr:
+ line = output.decode().strip()
+ if line:
+ self._notify_watcher(line)
+
+ if process.check_error_output:
+ if process.wrong_error_phrase and process.wrong_error_phrase in line:
+ continue
+ else:
+ break
+
+ return process.subproc.returncode is None or process.subproc.returncode == 0
+
+
+def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False, print_error: bool = True,
+ cwd: str = '.', global_interpreter: bool = USE_GLOBAL_INTERPRETER) -> str:
+ """
+ runs a given command and returns its default output
+ :param cmd:
+ :param expected_code:
+ :param ignore_return_code:
+ :param print_error:
+ :param global_interpreter
+ :return:
+ """
+ args = {
+ "shell": True,
+ "stdout": PIPE,
+ "env": gen_env(global_interpreter),
+ 'cwd': cwd
+ }
+
+ if not print_error:
+ args["stderr"] = subprocess.DEVNULL
+
+ res = subprocess.run(cmd, **args)
+ return res.stdout.decode() if ignore_return_code or res.returncode == expected_code else None
+
+
+def new_subprocess(cmd: List[str], cwd: str = '.', shell: bool = False, stdin = None,
+ global_interpreter: bool = USE_GLOBAL_INTERPRETER) -> subprocess.Popen:
+ args = {
+ "stdout": PIPE,
+ "stderr": PIPE,
+ "cwd": cwd,
+ "shell": shell,
+ "env": gen_env(global_interpreter)
+ }
+
+ if input:
+ args['stdin'] = stdin
+
+ return subprocess.Popen(cmd, **args)
+
+
+def new_root_subprocess(cmd: List[str], root_password: str, cwd: str = '.',
+ global_interpreter: bool = USE_GLOBAL_INTERPRETER) -> subprocess.Popen:
+ pwdin, final_cmd = None, []
+
+ if root_password is not None:
+ final_cmd.extend(['sudo', '-S'])
+ pwdin = new_subprocess(['echo', root_password], global_interpreter=global_interpreter).stdout
+
+ final_cmd.extend(cmd)
+ return subprocess.Popen(final_cmd, stdin=pwdin, stdout=PIPE, stderr=PIPE, cwd=cwd)
+
+
+def notify_user(msg: str, app_name: str, icon_path: str):
+ os.system("notify-send -a {} {} '{}'".format(app_name, "-i {}".format(icon_path) if icon_path else '', msg))
diff --git a/bauh/core/config.py b/bauh/core/config.py
new file mode 100644
index 00000000..c101cd78
--- /dev/null
+++ b/bauh/core/config.py
@@ -0,0 +1,33 @@
+import json
+import os
+from pathlib import Path
+from typing import List
+
+from bauh import __app_name__
+from bauh.api.constants import HOME_PATH
+
+CONFIG_PATH = '{}/.config/{}'.format(HOME_PATH, __app_name__)
+FILE_PATH = '{}/config.json'.format(CONFIG_PATH)
+
+
+class Configuration:
+
+ def __init__(self, gems: List[str]):
+ self.gems = gems
+
+
+def read() -> Configuration:
+ if os.path.exists(FILE_PATH):
+ with open(FILE_PATH) as f:
+ config_file = f.read()
+
+ return Configuration(**json.loads(config_file))
+
+ return Configuration(gems=None)
+
+
+def save(config: Configuration):
+ Path(CONFIG_PATH).mkdir(parents=True, exist_ok=True)
+
+ with open(FILE_PATH, 'w+') as f:
+ f.write(json.dumps(config.__dict__, indent=2))
diff --git a/bauh/core/controller.py b/bauh/core/controller.py
index 316f8227..213f25e6 100755
--- a/bauh/core/controller.py
+++ b/bauh/core/controller.py
@@ -2,10 +2,10 @@ from argparse import Namespace
from threading import Thread
from typing import List, Set, Type
-from bauh_api.abstract.controller import SoftwareManager, SearchResult, ApplicationContext
-from bauh_api.abstract.disk import DiskCacheLoader
-from bauh_api.abstract.handler import ProcessWatcher
-from bauh_api.abstract.model import SoftwarePackage, PackageUpdate, PackageHistory, PackageSuggestion, PackageAction
+from bauh.api.abstract.controller import SoftwareManager, SearchResult, ApplicationContext
+from bauh.api.abstract.disk import DiskCacheLoader
+from bauh.api.abstract.handler import ProcessWatcher
+from bauh.api.abstract.model import SoftwarePackage, PackageUpdate, PackageHistory, PackageSuggestion, PackageAction
SUGGESTIONS_LIMIT = 5
diff --git a/bauh/core/extensions.py b/bauh/core/extensions.py
deleted file mode 100644
index 1c1ea698..00000000
--- a/bauh/core/extensions.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import inspect
-import os
-import pkgutil
-from typing import List
-
-from bauh_api.abstract.controller import SoftwareManager, ApplicationContext
-
-from bauh import __app_name__
-from bauh.util import util
-
-ignore_modules = {'{}_api'.format(__app_name__), '{}_commons'.format(__app_name__)}
-ext_pattern = '{}_'.format(__app_name__)
-
-
-def find_manager(member):
- if not isinstance(member, str):
- if inspect.isclass(member) and inspect.getmro(member)[1].__name__ == 'SoftwareManager':
- return member
- elif inspect.ismodule(member) and member.__name__ not in ignore_modules:
- for name, mod in inspect.getmembers(member):
- manager_found = find_manager(mod)
- if manager_found:
- return manager_found
-
-
-def load_managers(locale: str, context: ApplicationContext) -> List[SoftwareManager]:
- managers = []
-
- for m in pkgutil.iter_modules():
- if m.ispkg and m.name and m.name not in ignore_modules and m.name.startswith(ext_pattern):
- module = pkgutil.find_loader(m.name).load_module()
-
- if hasattr(module, 'controller'):
- manager_class = find_manager(module.controller)
-
- if manager_class:
- locale_path = '{}/resources/locale'.format(module.__path__[0])
-
- if os.path.exists(locale_path):
- context.i18n.update(util.get_locale_keys(locale, locale_path))
-
- managers.append(manager_class(context=context))
-
- return managers
diff --git a/bauh/core/gems.py b/bauh/core/gems.py
new file mode 100644
index 00000000..4e5a1f85
--- /dev/null
+++ b/bauh/core/gems.py
@@ -0,0 +1,40 @@
+import inspect
+import os
+import pkgutil
+from typing import List
+
+from bauh import ROOT_DIR
+from bauh.api.abstract.controller import SoftwareManager, ApplicationContext
+from bauh.util import util
+
+
+def find_manager(member):
+ if not isinstance(member, str):
+ if inspect.isclass(member) and inspect.getmro(member)[1].__name__ == 'SoftwareManager':
+ return member
+ elif inspect.ismodule(member):
+ for name, mod in inspect.getmembers(member):
+ manager_found = find_manager(mod)
+ if manager_found:
+ return manager_found
+
+
+def load_managers(locale: str, context: ApplicationContext, names: List[str] = None) -> List[SoftwareManager]:
+ managers = []
+
+ for f in os.scandir(ROOT_DIR + '/gems'):
+ if f.is_dir() and f.name != '__pycache__' and (not names or f.name in names):
+ module = pkgutil.find_loader('bauh.gems.{}.controller'.format(f.name)).load_module()
+
+ manager_class = find_manager(module)
+
+ if manager_class:
+ locale_path = '{}/resources/locale'.format(f.path)
+
+ if os.path.exists(locale_path):
+ context.i18n.update(util.get_locale_keys(locale, locale_path))
+
+ managers.append(manager_class(context=context))
+
+ return managers
+
diff --git a/bauh/gems/__init__.py b/bauh/gems/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bauh/gems/arch/__init__.py b/bauh/gems/arch/__init__.py
new file mode 100644
index 00000000..3b1cfda6
--- /dev/null
+++ b/bauh/gems/arch/__init__.py
@@ -0,0 +1,4 @@
+import os
+ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
+BUILD_DIR = '/tmp/bauh/aur'
+
diff --git a/bauh/gems/arch/aur.py b/bauh/gems/arch/aur.py
new file mode 100644
index 00000000..884ab71a
--- /dev/null
+++ b/bauh/gems/arch/aur.py
@@ -0,0 +1,28 @@
+import re
+from typing import Set, List
+
+from bauh.api.http import HttpClient
+
+URL_INFO = 'https://aur.archlinux.org/rpc/?v=5&type=info&'
+URL_SEARCH = 'https://aur.archlinux.org/rpc/?v=5&type=search&arg='
+
+
+def map_pkgbuild(pkgbuild: str) -> dict:
+ return {attr: val.replace('"', '').replace("'", '').replace('(', '').replace(')', '') for attr, val in re.findall(r'\n(\w+)=(.+)', pkgbuild)}
+
+
+class AURClient:
+
+ def __init__(self, http_client: HttpClient):
+ self.http_client = http_client
+ self.names_index = set()
+
+ def search(self, words: str) -> dict:
+ return self.http_client.get_json(URL_SEARCH + words)
+
+ def get_info(self, names: Set[str]) -> List[dict]:
+ res = self.http_client.get_json(URL_INFO + self._map_names_as_queries(names))
+ return res['results'] if res and res.get('results') else []
+
+ def _map_names_as_queries(self, names) -> str:
+ return '&'.join(['arg[{}]={}'.format(i, n) for i, n in enumerate(names)])
diff --git a/bauh/gems/arch/confirmation.py b/bauh/gems/arch/confirmation.py
new file mode 100644
index 00000000..4854e777
--- /dev/null
+++ b/bauh/gems/arch/confirmation.py
@@ -0,0 +1,28 @@
+from typing import Set
+
+from bauh.api.abstract.handler import ProcessWatcher
+from bauh.api.abstract.view import MultipleSelectComponent, InputOption
+
+from bauh.commons.html import bold
+
+
+def request_optional_deps(pkgname: str, pkg_mirrors: dict, watcher: ProcessWatcher, i18n: dict) -> Set[str]:
+ view_opts = MultipleSelectComponent(label='',
+ options=[InputOption('{} ( {} )'.format(p, m.upper()), p) for p, m in
+ pkg_mirrors.items()])
+ install = watcher.request_confirmation(title=i18n['arch.install.optdeps.request.title'],
+ body='
{}
'.format(i18n['arch.install.optdeps.request.body'].format(bold(pkgname)) + ':'),
+ components=[view_opts],
+ confirmation_label=i18n['install'],
+ deny_label=i18n['cancel'])
+
+ if install:
+ return {o.value for o in view_opts.values}
+
+
+def request_install_missing_deps(pkgname: str, pkg_mirrors: dict, watcher: ProcessWatcher, i18n: dict) -> bool:
+ deps_str = ''.join(['
- {} ( {} )'.format(d, m.upper()) for d, m in pkg_mirrors.items()])
+ msg = '{}
'.format(i18n['arch.missing_deps.body'].format('{}'.format(pkgname) + ':
' + deps_str))
+ msg += i18n['ask.continue']
+
+ return watcher.request_confirmation(i18n['arch.missing_deps.title'], msg)
diff --git a/bauh/gems/arch/controller.py b/bauh/gems/arch/controller.py
new file mode 100644
index 00000000..f754fe41
--- /dev/null
+++ b/bauh/gems/arch/controller.py
@@ -0,0 +1,570 @@
+import os
+import re
+import shutil
+import time
+from pathlib import Path
+from threading import Thread
+from typing import List, Set, Type
+
+from bauh.api.abstract.controller import SearchResult, SoftwareManager, ApplicationContext
+from bauh.api.abstract.disk import DiskCacheLoader
+from bauh.api.abstract.handler import ProcessWatcher
+from bauh.api.abstract.model import PackageUpdate, PackageHistory, SoftwarePackage, PackageSuggestion
+from bauh.api.abstract.view import MessageType
+from bauh.commons.system import SystemProcess, ProcessHandler, new_subprocess, run_cmd, new_root_subprocess
+
+from bauh.gems.arch import BUILD_DIR, aur, pacman, makepkg, pkgbuild, message, confirmation, disk, git, suggestions
+from bauh.gems.arch.aur import AURClient
+from bauh.gems.arch.mapper import ArchDataMapper
+from bauh.gems.arch.model import ArchPackage
+from bauh.commons.html import bold
+from bauh.gems.arch.worker import AURIndexUpdater, ArchDiskCacheUpdater
+
+URL_GIT = 'https://aur.archlinux.org/{}.git'
+URL_PKG_DOWNLOAD = 'https://aur.archlinux.org/cgit/aur.git/snapshot/{}.tar.gz'
+URL_SRC_INFO = 'https://aur.archlinux.org/cgit/aur.git/plain/.SRCINFO?h='
+
+RE_SPLIT_VERSION = re.compile(r'(=|>|<)')
+
+
+class ArchManager(SoftwareManager):
+
+ def __init__(self, context: ApplicationContext):
+ super(ArchManager, self).__init__(context=context)
+ self.aur_cache = context.cache_factory.new()
+ # context.disk_loader_factory.map(ArchPackage, self.aur_cache) TODO
+
+ self.mapper = ArchDataMapper(http_client=context.http_client)
+ self.i18n = context.i18n
+ self.aur_client = AURClient(context.http_client)
+ self.names_index = {}
+ self.aur_index_updater = AURIndexUpdater(context, self)
+ self.dcache_updater = ArchDiskCacheUpdater(context.logger)
+ self.logger = context.logger
+
+ def _upgrade_search_result(self, apidata: dict, installed_pkgs: dict, downgrade_enabled: bool, res: SearchResult, disk_loader: DiskCacheLoader):
+ app = self.mapper.map_api_data(apidata, installed_pkgs['not_signed'])
+ app.downgrade_enabled = downgrade_enabled
+
+ if app.installed:
+ res.installed.append(app)
+
+ if disk_loader:
+ disk_loader.fill(app)
+ else:
+ res.new.append(app)
+
+ Thread(target=self.mapper.fill_package_build, args=(app,)).start()
+
+ def search(self, words: str, disk_loader: DiskCacheLoader, limit: int = -1) -> SearchResult:
+ downgrade_enabled = git.is_enabled()
+ res = SearchResult([], [], 0)
+
+ installed = {}
+ read_installed = Thread(target=lambda: installed.update(pacman.list_and_map_installed()))
+ read_installed.start()
+
+ api_res = self.aur_client.search(words)
+
+ if api_res and api_res.get('results'):
+ read_installed.join()
+
+ for pkgdata in api_res['results']:
+ self._upgrade_search_result(pkgdata, installed, downgrade_enabled, res, disk_loader)
+
+ else: # if there are no results from the API (it could be because there were too many), tries the names index:
+ if self.names_index:
+
+ to_query = set()
+ for norm_name, real_name in self.names_index.items():
+ if words in norm_name:
+ to_query.add(real_name)
+
+ if len(to_query) == 25:
+ break
+
+ pkgsinfo = self.aur_client.get_info(to_query)
+
+ if pkgsinfo:
+ read_installed.join()
+
+ for pkgdata in pkgsinfo:
+ self._upgrade_search_result(pkgdata, installed, res)
+
+ res.total = len(res.installed) + len(res.new)
+ return res
+
+ def _fill_aur_pkgs(self, not_signed: dict, apps: list, disk_loader: DiskCacheLoader):
+ downgrade_enabled = git.is_enabled()
+ pkgsinfo = self.aur_client.get_info(not_signed.keys())
+
+ if pkgsinfo:
+ for pkgdata in pkgsinfo:
+ app = self.mapper.map_api_data(pkgdata, not_signed)
+ app.downgrade_enabled = downgrade_enabled
+ if disk_loader:
+ disk_loader.fill(app)
+
+ apps.append(app)
+
+ def _fill_mirror_pkgs(self, mirrors: dict, apps: list):
+ # TODO
+ for name, data in mirrors.items():
+ app = ArchPackage(name=name, version=data.get('version'), latest_version=data.get('version'), description=data.get('description'))
+ app.installed = True
+ app.mirror = '' # TODO
+ app.update = False # TODO
+ apps.append(app)
+
+ def read_installed(self, disk_loader: DiskCacheLoader, limit: int = -1, only_apps: bool = False, pkg_types: Set[Type[SoftwarePackage]] = None) -> SearchResult:
+ installed = pacman.list_and_map_installed()
+
+ apps = []
+ if installed and installed['not_signed']:
+ self._fill_aur_pkgs(installed['not_signed'], apps, disk_loader)
+
+ return SearchResult(apps, None, len(apps))
+
+ def downgrade(self, pkg: ArchPackage, root_password: str, watcher: ProcessWatcher) -> bool:
+
+ handler = ProcessHandler(watcher)
+ app_build_dir = '{}/build_{}'.format(BUILD_DIR, int(time.time()))
+ watcher.change_progress(5)
+
+ try:
+ if not os.path.exists(app_build_dir):
+ build_dir = handler.handle(SystemProcess(new_subprocess(['mkdir', '-p', app_build_dir])))
+
+ if build_dir:
+ watcher.change_progress(10)
+ watcher.change_substatus(self.i18n['arch.clone'].format(bold(pkg.name)))
+ clone = handler.handle(SystemProcess(subproc=new_subprocess(['git', 'clone', URL_GIT.format(pkg.name)], cwd=app_build_dir), check_error_output=False))
+ watcher.change_progress(30)
+ if clone:
+ watcher.change_substatus(self.i18n['arch.downgrade.reading_commits'])
+ clone_path = '{}/{}'.format(app_build_dir, pkg.name)
+ pkgbuild_path = '{}/PKGBUILD'.format(clone_path)
+
+ commits = run_cmd("git log", cwd=clone_path)
+ watcher.change_progress(40)
+
+ if commits:
+ commit_list = re.findall(r'commit (.+)\n', commits)
+ if commit_list:
+ if len(commit_list) > 1:
+ for idx in range(1, len(commit_list)):
+ commit = commit_list[idx]
+ with open(pkgbuild_path) as f:
+ pkgdict = aur.map_pkgbuild(f.read())
+
+ if not handler.handle(SystemProcess(subproc=new_subprocess(['git', 'reset', '--hard', commit], cwd=clone_path), check_error_output=False)):
+ watcher.print('Could not downgrade anymore. Aborting...')
+ return False
+
+ if '{}-{}'.format(pkgdict.get('pkgver'), pkgdict.get('pkgrel')) == pkg.version:
+ # current version found
+ watcher.change_substatus(self.i18n['arch.downgrade.version_found'])
+ break
+
+ watcher.change_substatus(self.i18n['arch.downgrade.install_older'])
+ return self._make_pkg(pkg.name, root_password, handler, app_build_dir, clone_path, dependency=False, skip_optdeps=True)
+ else:
+ watcher.show_message(title=self.i18n['arch.downgrade.error'],
+ body=self.i18n['arch.downgrade.impossible'].format(pkg.name),
+ type_=MessageType.ERROR)
+ return False
+
+ watcher.show_message(title=self.i18n['error'], body=self.i18n['arch.downgrade.no_commits'], type_=MessageType.ERROR)
+ return False
+
+ finally:
+ if os.path.exists(app_build_dir):
+ handler.handle(SystemProcess(subproc=new_subprocess(['rm', '-rf', app_build_dir])))
+
+ return False
+
+ def clean_cache_for(self, pkg: ArchPackage):
+ if os.path.exists(pkg.get_disk_cache_path()):
+ shutil.rmtree(pkg.get_disk_cache_path())
+
+ def update(self, pkg: ArchPackage, root_password: str, watcher: ProcessWatcher) -> bool:
+ return self.install(pkg=pkg, root_password=root_password, watcher=watcher, skip_optdeps=True)
+
+ def _uninstall(self, pkg_name: str, root_password: str, handler: ProcessHandler) -> bool:
+ return handler.handle(SystemProcess(new_root_subprocess(['pacman', '-R', pkg_name, '--noconfirm'], root_password)))
+
+ def uninstall(self, pkg: ArchPackage, root_password: str, watcher: ProcessWatcher) -> bool:
+ handler = ProcessHandler(watcher)
+
+ watcher.change_progress(10)
+ info = pacman.get_info_dict(pkg.name)
+ watcher.change_progress(50)
+
+ if info.get('required by'):
+ pkname = '"{}"'.format(pkg.name)
+ msg = '{}:\n\n{}\n\n{}'.format(self.i18n['arch.uninstall.required_by'].format(pkname), info['required by'], self.i18n['arch.uninstall.required_by.advice'].format(pkname))
+ watcher.show_message(title=self.i18n['error'], body=msg, type_=MessageType.WARNING)
+ return False
+
+ uninstalled = self._uninstall(pkg.name, root_password, handler)
+ watcher.change_progress(100)
+ return uninstalled
+
+ def get_managed_types(self) -> Set["type"]:
+ return {ArchPackage}
+
+ def get_info(self, pkg: ArchPackage) -> dict:
+ if pkg.installed:
+ t = Thread(target=self.mapper.fill_package_build, args=(pkg,))
+ t.start()
+
+ info = pacman.get_info_dict(pkg.name)
+
+ t.join()
+
+ if pkg.pkgbuild:
+ info['13_pkg_build'] = pkg.pkgbuild
+
+ return info
+ else:
+ info = {
+ '01_id': pkg.id,
+ '02_name': pkg.name,
+ '03_version': pkg.version,
+ '04_popularity': pkg.popularity,
+ '05_votes': pkg.votes,
+ '06_package_base': pkg.package_base,
+ '07_maintainer': pkg.maintainer,
+ '08_first_submitted': pkg.first_submitted,
+ '09_last_modified': pkg.last_modified,
+ '10_url': pkg.url_download,
+ }
+
+ res_srcinfo = self.context.http_client.get(URL_SRC_INFO + pkg.name)
+
+ if res_srcinfo and res_srcinfo.text:
+ info['11_dependson'] = ' '.join(pkgbuild.read_depends_on(res_srcinfo.text))
+ info['12_optdepends'] = ' '.join(pkgbuild.read_optdeps(res_srcinfo.text))
+
+ if pkg.pkgbuild:
+ info['13_pkg_build'] = pkg.pkgbuild
+ else:
+ info['11_pkg_build_url'] = pkg.get_pkg_build_url()
+
+ return info
+
+ def get_history(self, pkg: ArchPackage) -> PackageHistory:
+ temp_dir = '{}/build_{}'.format(BUILD_DIR, int(time.time()))
+
+ try:
+ Path(temp_dir).mkdir(parents=True)
+ run_cmd('git clone ' + URL_GIT.format(pkg.name), print_error=False, cwd=temp_dir)
+
+ clone_path = '{}/{}'.format(temp_dir, pkg.name)
+ pkgbuild_path = '{}/PKGBUILD'.format(clone_path)
+
+ commits = git.list_commits(clone_path)
+
+ if commits:
+ history, status_idx = [], 0
+
+ for idx, commit in enumerate(commits):
+ with open(pkgbuild_path) as f:
+ pkgdict = aur.map_pkgbuild(f.read())
+
+ if '{}-{}'.format(pkgdict.get('pkgver'), pkgdict.get('pkgrel')) == pkg.version:
+ status_idx = idx
+
+ history.append({'1_version': pkgdict['pkgver'], '2_release': pkgdict['pkgrel'],
+ '3_date': commit['date']}) # the number prefix is to ensure the rendering order
+
+ if idx + 1 < len(commits):
+ if not run_cmd('git reset --hard ' + commits[idx + 1]['commit'], cwd=clone_path):
+ break
+
+ return PackageHistory(pkg=pkg, history=history, pkg_status_idx=status_idx)
+ finally:
+ if os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
+
+ def _install_deps(self, deps: Set[str], pkg_mirrors: dict, root_password: str, handler: ProcessHandler, change_progress: bool = False) -> str:
+ """
+ :param deps:
+ :param pkg_mirrors:
+ :param root_password:
+ :param handler:
+ :return: not installed dependency
+ """
+ progress_increment = int(100 / len(deps))
+ progress = 0
+ self._update_progress(handler.watcher, 1, change_progress)
+
+ for pkgname in deps:
+
+ mirror = pkg_mirrors[pkgname]
+ handler.watcher.change_substatus(self.i18n['arch.install.dependency.install'].format(bold('{} ()'.format(pkgname, mirror))))
+ if mirror == 'aur':
+ installed = self._install_from_aur(pkgname=pkgname, root_password=root_password, handler=handler, dependency=True, change_progress=False)
+ else:
+ installed = self._install(pkgname=pkgname, root_password=root_password, handler=handler, install_file=None, mirror=mirror, change_progress=False)
+
+ if not installed:
+ return pkgname
+
+ progress += progress_increment
+ self._update_progress(handler.watcher, progress, change_progress)
+
+ self._update_progress(handler.watcher, 100, change_progress)
+
+ def _map_mirrors(self, pkgnames: Set[str]) -> dict:
+ pkg_mirrors = pacman.get_mirrors(pkgnames) # getting mirrors set
+
+ if len(pkgnames) != pkg_mirrors: # checking if any dep not found in the distro mirrors are from AUR
+ nomirrors = {p for p in pkgnames if p not in pkg_mirrors}
+ for pkginfo in self.aur_client.get_info(nomirrors):
+ if pkginfo.get('Name') in nomirrors:
+ pkg_mirrors[pkginfo['Name']] = 'aur'
+
+ return pkg_mirrors
+
+ def _make_pkg(self, pkgname: str, root_password: str, handler: ProcessHandler, build_dir: str, project_dir: str, dependency: bool, skip_optdeps: bool = False, change_progress: bool = True) -> bool:
+
+ self._update_progress(handler.watcher, 50, change_progress)
+ if not self._install_missings_deps(pkgname, root_password, handler, project_dir):
+ return False
+
+ # building main package
+ handler.watcher.change_substatus(self.i18n['arch.building.package'].format(bold(pkgname)))
+ pkgbuilt = handler.handle(SystemProcess(new_subprocess(['makepkg', '-Acsmf'], cwd=project_dir)))
+ self._update_progress(handler.watcher, 65, change_progress)
+
+ if pkgbuilt:
+ gen_file = [fname for root, dirs, files in os.walk(build_dir) for fname in files if re.match(r'^{}-.+\.tar\.xz'.format(pkgname), fname)]
+
+ if not gen_file:
+ handler.watcher.print('Could not find generated .tar.xz file. Aborting...')
+ return False
+
+ install_file = '{}/{}'.format(project_dir, gen_file[0])
+
+ if self._install(pkgname=pkgname, root_password=root_password, mirror='aur', handler=handler, install_file=install_file, pkgdir=project_dir, change_progress=change_progress):
+
+ if dependency or skip_optdeps:
+ return True
+
+ handler.watcher.change_substatus(self.i18n['arch.optdeps.checking'].format(bold(pkgname)))
+
+ if self._install_optdeps(pkgname, root_password, handler, project_dir, change_progress=change_progress):
+ return True
+
+ return False
+
+ def _install_missings_deps(self, pkgname: str, root_password: str, handler: ProcessHandler, pkgdir: str) -> bool:
+ handler.watcher.change_substatus(self.i18n['arch.checking.deps'].format(bold(pkgname)))
+ missing_deps = makepkg.check_missing_deps(pkgdir, handler.watcher)
+
+ if missing_deps:
+ depnames = {RE_SPLIT_VERSION.split(dep)[0] for dep in missing_deps}
+ dep_mirrors = self._map_mirrors(depnames)
+
+ for dep in depnames: # cheking if a dependency could not be found in any mirror
+ if dep not in dep_mirrors:
+ message.show_dep_not_found(dep, self.i18n, handler.watcher)
+ return False
+
+ handler.watcher.change_substatus(self.i18n['arch.missing_deps_found'].format(bold(pkgname)))
+
+ if not confirmation.request_install_missing_deps(pkgname, dep_mirrors, handler.watcher, self.i18n):
+ handler.watcher.print(self.i18n['action.cancelled'])
+ return False
+
+ dep_not_installed = self._install_deps(depnames, dep_mirrors, root_password, handler, change_progress=False)
+
+ if dep_not_installed:
+ message.show_dep_not_installed(handler.watcher, pkgname, dep_not_installed, self.i18n)
+ return False
+
+ return True
+
+ def _install_optdeps(self, pkgname: str, root_password: str, handler: ProcessHandler, pkgdir: str, change_progress: bool = True) -> bool:
+ with open('{}/.SRCINFO'.format(pkgdir)) as f:
+ odeps = pkgbuild.read_optdeps(f.read())
+
+ if not odeps:
+ return True
+
+ installed = pacman.list_installed()
+ to_install = {d for d in odeps if d not in installed}
+
+ if not to_install:
+ return True
+
+ pkg_mirrors = self._map_mirrors(to_install)
+
+ if pkg_mirrors:
+ deps_to_install = confirmation.request_optional_deps(pkgname, pkg_mirrors, handler.watcher, self.i18n)
+
+ if not deps_to_install:
+ return True
+ else:
+ dep_not_installed = self._install_deps(deps_to_install, pkg_mirrors, root_password, handler, change_progress=True)
+
+ if dep_not_installed:
+ message.show_optdep_not_installed(dep_not_installed, handler.watcher, self.i18n)
+ return False
+
+ return True
+
+ def _install(self, pkgname: str, root_password: str, mirror: str, handler: ProcessHandler, install_file: str = None, pkgdir: str = '.', change_progress: bool = True):
+ check_install_output = []
+ pkgpath = install_file if install_file else pkgname
+
+ handler.watcher.change_substatus(self.i18n['arch.checking.conflicts'].format(bold(pkgname)))
+
+ for check_out in new_root_subprocess(['pacman', '-U', pkgpath], root_password=root_password, cwd=pkgdir).stdout:
+ if check_out:
+ check_install_output.append(check_out.decode())
+
+ self._update_progress(handler.watcher, 70, change_progress)
+ if check_install_output and 'conflict' in check_install_output[-1]:
+ conflicting_apps = [w[0] for w in re.findall(r'((\w|\-|\.)+)\s(and|are)', check_install_output[-1])]
+ conflict_msg = ' {} '.format(self.i18n['and']).join(conflicting_apps)
+ if not handler.watcher.request_confirmation(title=self.i18n['arch.install.conflict.popup.title'],
+ body=self.i18n['arch.install.conflict.popup.body'].format(conflict_msg)):
+ handler.watcher.print(self.i18n['action.cancelled'])
+ return False
+ else: # uninstall conflicts
+ self._update_progress(handler.watcher, 75, change_progress)
+ to_uninstall = [conflict for conflict in conflicting_apps if conflict != pkgname]
+
+ for conflict in to_uninstall:
+ handler.watcher.change_substatus(self.i18n['arch.uninstalling.conflict'].format(bold(conflict)))
+ if not self._uninstall(conflict, root_password, handler):
+ handler.watcher.show_message(title=self.i18n['error'],
+ body=self.i18n['arch.uninstalling.conflict.fail'].format('"{}"'.format(conflict)),
+ type_=MessageType.ERROR)
+ return False
+
+ handler.watcher.change_substatus(self.i18n['arch.installing.package'].format(bold(pkgname)))
+ self._update_progress(handler.watcher, 80, change_progress)
+ installed = handler.handle(pacman.install_as_process(pkgpath=pkgpath, root_password=root_password, aur=install_file is not None, pkgdir=pkgdir))
+ self._update_progress(handler.watcher, 95, change_progress)
+
+ if installed and self.context.disk_cache:
+ handler.watcher.change_substatus(self.i18n['status.caching_data'].format(bold(pkgname)))
+ disk.save(pkgname, mirror)
+ self._update_progress(handler.watcher, 100, change_progress)
+
+ return installed
+
+ def _update_progress(self, watcher: ProcessWatcher, val: int, change_progress: bool):
+ if change_progress:
+ watcher.change_progress(val)
+
+ def _install_from_aur(self, pkgname: str, root_password: str, handler: ProcessHandler, dependency: bool, skip_optdeps: bool = False, change_progress: bool = True) -> bool:
+ app_build_dir = '{}/build_{}'.format(BUILD_DIR, int(time.time()))
+
+ try:
+ if not os.path.exists(app_build_dir):
+ build_dir = handler.handle(SystemProcess(new_subprocess(['mkdir', '-p', app_build_dir])))
+ self._update_progress(handler.watcher, 10, change_progress)
+
+ if build_dir:
+ file_url = URL_PKG_DOWNLOAD.format(pkgname)
+ file_name = file_url.split('/')[-1]
+ handler.watcher.change_substatus('{} {}'.format(self.i18n['arch.downloading.package'], bold(file_name)))
+ download = handler.handle(SystemProcess(new_subprocess(['wget', file_url], cwd=app_build_dir)))
+
+ if download:
+ self._update_progress(handler.watcher, 30, change_progress)
+ handler.watcher.change_substatus('{} {}'.format(self.i18n['arch.uncompressing.package'], bold(file_name)))
+ uncompress = handler.handle(SystemProcess(new_subprocess(['tar', 'xvzf', '{}.tar.gz'.format(pkgname)], cwd=app_build_dir)))
+ self._update_progress(handler.watcher, 40, change_progress)
+
+ if uncompress:
+ uncompress_dir = '{}/{}'.format(app_build_dir, pkgname)
+ return self._make_pkg(pkgname=pkgname,
+ root_password=root_password,
+ handler=handler,
+ build_dir=app_build_dir,
+ project_dir=uncompress_dir,
+ dependency=dependency,
+ skip_optdeps=skip_optdeps,
+ change_progress=change_progress)
+ finally:
+ if os.path.exists(app_build_dir):
+ handler.handle(SystemProcess(new_subprocess(['rm', '-rf', app_build_dir])))
+
+ return False
+
+ def install(self, pkg: ArchPackage, root_password: str, watcher: ProcessWatcher, skip_optdeps: bool = False) -> bool:
+ return self._install_from_aur(pkg.name, root_password, ProcessHandler(watcher), dependency=False, skip_optdeps=skip_optdeps)
+
+ def _is_wget_available(self):
+ try:
+ new_subprocess(['wget', '--version'])
+ return True
+ except FileNotFoundError:
+ return False
+
+ def is_enabled(self) -> bool:
+ try:
+ return pacman.is_enabled() and self._is_wget_available()
+ except FileNotFoundError:
+ return False
+
+ def is_downgrade_enabled(self) -> bool:
+ try:
+ new_subprocess(['git', '--version'])
+ return True
+ except FileNotFoundError:
+ return False
+
+ def cache_to_disk(self, pkg: ArchPackage, icon_bytes: bytes, only_icon: bool):
+ if self.context.disk_cache and pkg.supports_disk_cache():
+ pass
+
+ def requires_root(self, action: str, pkg: ArchPackage):
+ return action != 'search'
+
+ def prepare(self):
+ self.dcache_updater.start()
+
+ self.aur_index_updater.start()
+
+ def list_updates(self) -> List[PackageUpdate]:
+ return [PackageUpdate(app.id, app.latest_version, 'aur') for app in self.read_installed(disk_loader=None).installed if app.update]
+
+ def list_warnings(self) -> List[str]:
+ warnings = []
+
+ if not pacman.is_enabled():
+ warnings.append(self.i18n['arch.warning.disabled'].format(bold('pacman')))
+
+ if not self._is_wget_available():
+ warnings.append(self.i18n['arch.warning.disabled'].format(bold('wget')))
+
+ if not git.is_enabled():
+ warnings.append(self.i18n['arch.warning.git'].format(bold('git')))
+
+ return warnings
+
+ def list_suggestions(self, limit: int) -> List[PackageSuggestion]:
+ res = []
+
+ sugs = [(i, p) for i, p in suggestions.ALL.items()]
+ sugs.sort(key=lambda t: t[1].value, reverse=True)
+
+ if limit > 0:
+ sugs = sugs[0:limit]
+
+ sug_names = {s[0] for s in sugs}
+
+ api_res = self.aur_client.get_info(sug_names)
+
+ if api_res:
+ for pkg in api_res:
+ if pkg.get('Name') in sug_names:
+ res.append(PackageSuggestion(self.mapper.map_api_data(pkg, {}), suggestions.ALL.get(pkg['Name'])))
+
+ return res
diff --git a/bauh/gems/arch/disk.py b/bauh/gems/arch/disk.py
new file mode 100644
index 00000000..6febf33a
--- /dev/null
+++ b/bauh/gems/arch/disk.py
@@ -0,0 +1,179 @@
+import json
+import os
+import re
+from pathlib import Path
+from typing import Set, List
+
+from bauh.gems.arch import pacman
+from bauh.gems.arch.model import ArchPackage
+
+RE_DESKTOP_ENTRY = re.compile(r'(Exec|Icon)\s*=\s*(.+)')
+RE_CLEAN_NAME = re.compile(r'^(\w+)-?|_?.+')
+
+
+def write(app: ArchPackage):
+ Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True)
+
+ with open(app.get_disk_data_path(), 'w+') as f:
+ f.write(json.dumps(app.get_data_to_cache()))
+
+
+def fill_icon_path(app: ArchPackage, icon_paths: List[str], only_exact_match: bool):
+ ends_with = re.compile(r'.+/{}\.(png|svg)$'.format(app.name), re.IGNORECASE)
+
+ for path in icon_paths:
+ if ends_with.match(path):
+ app.icon_path = path
+ return
+
+ if not only_exact_match:
+ pkg_icons_path = pacman.list_icon_paths({app.name})
+
+ if pkg_icons_path:
+ app.icon_path = pkg_icons_path[0]
+
+
+def set_icon_path(app: ArchPackage, icon_name: str = None):
+ installed_icons = pacman.list_icon_paths(app.name)
+
+ if installed_icons:
+ exact_match = re.compile(r'.+/{}\..+$'.format(icon_name.split('.')[0] if icon_name else app.name))
+ for icon_path in installed_icons:
+ if exact_match.match(icon_path):
+ app.icon_path = icon_path
+ break
+
+
+def save(pkgname: str, mirror: str):
+ app = ArchPackage(name=pkgname, mirror=mirror)
+ desktop_files = pacman.list_desktop_entries({pkgname})
+
+ if desktop_files:
+ if len(desktop_files) > 1:
+ exact_match = [f for f in desktop_files if f.endswith('{}.desktop'.format(pkgname))]
+ to_parse = exact_match[0] if exact_match else desktop_files[0]
+ else:
+ to_parse = desktop_files[0]
+
+ app.desktop_entry = to_parse
+
+ with open(to_parse) as f:
+ desktop_entry = f.read()
+
+ for field in RE_DESKTOP_ENTRY.findall(desktop_entry):
+ if field[0] == 'Exec':
+ app.command = field[1].strip()
+ elif field[0] == 'Icon':
+ app.icon_path = field[1].strip()
+
+ if app.icon_path and '/' not in app.icon_path: # if the icon full path is not defined
+ set_icon_path(app, app.icon_path)
+ else:
+ bin_paths = pacman.list_bin_paths({pkgname})
+
+ if bin_paths:
+ if len(bin_paths) > 1:
+ exact_match = [p for p in bin_paths if p.endswith('/' + pkgname)]
+ app.command = exact_match[0] if exact_match else bin_paths[0]
+ else:
+ app.command = bin_paths[0]
+
+ set_icon_path(app)
+
+ Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True)
+
+ with open(app.get_disk_data_path(), 'w+') as f:
+ f.write(json.dumps(app.get_data_to_cache()))
+
+
+def save_several(pkgnames: Set[str], mirror: str, overwrite: bool = True) -> int:
+ to_cache = {n for n in pkgnames if overwrite or not os.path.exists(ArchPackage.disk_cache_path(n, mirror))}
+ desktop_files = pacman.list_desktop_entries(to_cache)
+
+ no_desktop_files = {}
+
+ to_write = []
+ if desktop_files:
+ desktop_matches, no_exact_match = {}, set()
+ for pkg in to_cache: # first try to find exact matches
+ ends_with = re.compile('.+/{}.desktop$'.format(pkg), re.IGNORECASE)
+
+ for f in desktop_files:
+ if ends_with.match(f):
+ desktop_matches[pkg] = f
+ break
+
+ if pkg not in desktop_matches:
+ no_exact_match.add(pkg)
+
+ if no_exact_match: # check every not matched app individually
+ for pkg in no_exact_match:
+ entries = pacman.list_desktop_entries({pkg})
+
+ if entries:
+ desktop_matches[pkg] = entries[0]
+
+ if not desktop_matches:
+ no_desktop_files = to_cache
+ else:
+ if len(desktop_matches) != len(to_cache):
+ no_desktop_files = {p for p in to_cache if p not in desktop_matches}
+
+ pkgs, apps_icons_noabspath = [], []
+
+ for pkgname, file in desktop_matches.items():
+ p = ArchPackage(name=pkgname, mirror=mirror)
+ p.desktop_entry = file
+
+ with open(file) as f:
+ desktop_entry = f.read()
+
+ for field in RE_DESKTOP_ENTRY.findall(desktop_entry):
+ if field[0] == 'Exec':
+ p.command = field[1].strip()
+ elif field[0] == 'Icon':
+ p.icon_path = field[1].strip()
+
+ if p.icon_path and '/' not in p.icon_path: # if the icon full path is not defined
+ apps_icons_noabspath.append(p)
+
+ pkgs.append(p)
+
+ if apps_icons_noabspath:
+ icon_paths = pacman.list_icon_paths({app.name for app in apps_icons_noabspath})
+
+ if icon_paths:
+ for p in apps_icons_noabspath:
+ fill_icon_path(p, icon_paths, False)
+
+ for p in pkgs:
+ to_write.append(p)
+
+ if no_desktop_files:
+ pkgs = {ArchPackage(name=n, mirror=mirror) for n in no_desktop_files}
+ bin_paths = pacman.list_bin_paths(no_desktop_files)
+
+ if bin_paths:
+ for p in pkgs:
+ ends_with = re.compile(r'.+/{}$'.format(p.name), re.IGNORECASE)
+
+ for path in bin_paths:
+ if ends_with.match(path):
+ p.command = path
+ break
+
+ icon_paths = pacman.list_icon_paths(no_desktop_files)
+
+ if icon_paths:
+ for p in pkgs:
+ fill_icon_path(p, icon_paths, only_exact_match=True)
+
+ for p in pkgs:
+ to_write.append(p)
+
+ if to_write:
+ for p in to_write:
+ write(p)
+ return len(to_write)
+ return 0
+
diff --git a/bauh/gems/arch/git.py b/bauh/gems/arch/git.py
new file mode 100644
index 00000000..c52499e5
--- /dev/null
+++ b/bauh/gems/arch/git.py
@@ -0,0 +1,29 @@
+from datetime import datetime
+from typing import List
+
+from bauh.commons.system import new_subprocess
+
+
+def is_enabled() -> bool:
+ try:
+ new_subprocess(['git', '--version'])
+ return True
+ except FileNotFoundError:
+ return False
+
+
+def list_commits(proj_dir:str) -> List[dict]:
+ logs = new_subprocess(['git', 'log', '--date=iso'], cwd=proj_dir).stdout
+
+ commits, commit = [], {}
+ for out in new_subprocess(['grep', '-E', 'commit|Date:'], stdin=logs).stdout:
+ if out:
+ line = out.decode()
+ if line.startswith('commit'):
+ commit['commit'] = line.split(' ')[1].strip()
+ elif line.startswith('Date'):
+ commit['date'] = datetime.fromisoformat(line.split(':')[1].strip())
+ commits.append(commit)
+ commit = {}
+
+ return commits
diff --git a/bauh/gems/arch/makepkg.py b/bauh/gems/arch/makepkg.py
new file mode 100644
index 00000000..a7993d29
--- /dev/null
+++ b/bauh/gems/arch/makepkg.py
@@ -0,0 +1,28 @@
+import re
+from typing import List
+
+from bauh.api.abstract.handler import ProcessWatcher
+from bauh.commons.system import new_subprocess
+
+RE_DEPS_PATTERN = re.compile(r'\n?\s+->\s(.+)\n')
+
+
+def check_missing_deps(pkgdir: str, watcher: ProcessWatcher) -> List[str]:
+ depcheck = new_subprocess(['makepkg', '--check'], cwd=pkgdir)
+
+ for o in depcheck.stdout:
+ if o:
+ watcher.print(o.decode())
+
+ error_lines = []
+ for s in depcheck.stderr:
+ if s:
+ line = s.decode()
+ print(line)
+ error_lines.append(line)
+
+ if error_lines:
+ error_str = ''.join(error_lines)
+
+ if 'Missing dependencies' in error_str:
+ return RE_DEPS_PATTERN.findall(error_str)
diff --git a/bauh/gems/arch/mapper.py b/bauh/gems/arch/mapper.py
new file mode 100644
index 00000000..bdf12594
--- /dev/null
+++ b/bauh/gems/arch/mapper.py
@@ -0,0 +1,55 @@
+from datetime import datetime
+
+from bauh.api.http import HttpClient
+from bauh.gems.arch.model import ArchPackage
+
+URL_PKG_DOWNLOAD = 'https://aur.archlinux.org/{}'
+
+
+class ArchDataMapper:
+
+ def __init__(self, http_client: HttpClient):
+ self.http_client = http_client
+
+ def fill_api_data(self, pkg: ArchPackage, package: dict, fill_version: bool = True):
+
+ version = package.get('Version')
+
+ if version:
+ version = version.split(':')
+ version = version[0] if len(version) == 1 else version[1]
+
+ pkg.id = package.get('ID')
+ pkg.name = package.get('Name')
+
+ if fill_version:
+ pkg.version = version
+
+ pkg.latest_version = version
+ pkg.description = package.get('Description')
+
+ pkg.package_base = package.get('PackageBase')
+ pkg.popularity = package.get('Popularity')
+ pkg.votes = package.get('NumVotes')
+ pkg.maintainer = package.get('Maintainer')
+ pkg.url_download = URL_PKG_DOWNLOAD.format(package['URLPath']) if package.get('URLPath') else None
+ pkg.first_submitted = datetime.fromtimestamp(package['FirstSubmitted']) if package.get('FirstSubmitted') else None
+ pkg.last_modified = datetime.fromtimestamp(package['LastModified']) if package.get('LastModified') else None
+ pkg.update = pkg.version and pkg.latest_version and pkg.latest_version > pkg.version
+
+ def fill_package_build(self, pkg: ArchPackage):
+ res = self.http_client.get(pkg.get_pkg_build_url())
+
+ if res and res.status_code == 200 and res.text:
+ pkg.pkgbuild = res.text
+
+ def map_api_data(self, apidata: dict, installed: dict) -> ArchPackage:
+ data = installed.get(apidata.get('Name'))
+ app = ArchPackage(name=apidata.get('Name'), installed=bool(data), mirror='aur')
+
+ if data:
+ app.version = data.get('version')
+ app.description = data.get('description')
+
+ self.fill_api_data(app, apidata, fill_version=not data)
+ return app
diff --git a/bauh/gems/arch/message.py b/bauh/gems/arch/message.py
new file mode 100644
index 00000000..5e92c090
--- /dev/null
+++ b/bauh/gems/arch/message.py
@@ -0,0 +1,20 @@
+from bauh.api.abstract.handler import ProcessWatcher
+from bauh.api.abstract.view import MessageType
+
+
+def show_dep_not_installed(watcher: ProcessWatcher, pkgname: str, depname: str, i18n: dict):
+ watcher.show_message(title=i18n['error'],
+ body=i18n['arch.install.dependency.install.error'].format('"{}"'.format(depname), '"{}"'.format(pkgname)),
+ type_=MessageType.ERROR)
+
+
+def show_dep_not_found(depname: str, i18n: dict, watcher: ProcessWatcher):
+ watcher.show_message(title=i18n['arch.install.dep_not_found.title'],
+ body=i18n['arch.install.dep_not_found.body'].format('"{}"'.format(depname)),
+ type_=MessageType.ERROR)
+
+
+def show_optdep_not_installed(depname: str, watcher: ProcessWatcher, i18n: dict):
+ watcher.show_message(title=i18n['error'],
+ body=i18n['arch.install.optdep.error'].format('"{}"'.format(depname)),
+ type_=MessageType.ERROR)
diff --git a/bauh/gems/arch/model.py b/bauh/gems/arch/model.py
new file mode 100644
index 00000000..f7ded25c
--- /dev/null
+++ b/bauh/gems/arch/model.py
@@ -0,0 +1,109 @@
+import datetime
+
+from bauh.api.abstract.model import SoftwarePackage
+from bauh.api.constants import CACHE_PATH
+from bauh.commons import resource
+
+from bauh.gems.arch import ROOT_DIR
+
+
+class ArchPackage(SoftwarePackage):
+
+ def __init__(self, name: str = None, version: str = None, latest_version: str = None, description: str = None,
+ package_base: str = None, votes: int = None, popularity: float = None,
+ first_submitted: datetime.datetime = None, last_modified: datetime.datetime = None,
+ maintainer: str = None, url_download: str = None, pkgbuild: str = None, mirror: str = None,
+ desktop_entry: str = None, installed: bool = False):
+
+ super(ArchPackage, self).__init__(name=name, version=version, latest_version=latest_version, description=description, installed=installed)
+ self.package_base = package_base
+ self.votes = votes
+ self.popularity = popularity
+ self.maintainer = maintainer
+ self.url_download = url_download
+ self.first_submitted = first_submitted
+ self.last_modified = last_modified
+ self.pkgbuild = pkgbuild
+ self.mirror = mirror
+ self.command = None
+ self.icon_path = None
+ self.downgrade_enabled = False
+ self.desktop_entry = desktop_entry
+
+ @staticmethod
+ def disk_cache_path(pkgname: str, mirror: str):
+ return CACHE_PATH + '/arch/installed/' + ('aur' if mirror == 'aur' else 'mirror') + '/' + pkgname
+
+ def get_pkg_build_url(self):
+ if self.package_base:
+ return 'https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h=' + self.package_base
+
+ def has_history(self):
+ return self.installed
+
+ def has_info(self):
+ return True
+
+ def can_be_installed(self):
+ return super(ArchPackage, self).can_be_installed() and self.url_download
+
+ def can_be_downgraded(self):
+ return self.installed and self.downgrade_enabled
+
+ def get_type(self):
+ return 'aur' if self.mirror == 'aur' else 'arch'
+
+ def get_default_icon_path(self) -> str:
+ return self.get_type_icon_path()
+
+ def get_disk_icon_path(self) -> str:
+ return self.icon_path
+
+ def get_type_icon_path(self):
+ return resource.get_path('img/arch.png', ROOT_DIR) # TODO change icon when from mirrors
+
+ def is_application(self):
+ return True
+
+ def supports_disk_cache(self):
+ return True
+
+ def get_disk_cache_path(self) -> str:
+ if self.name:
+ return self.disk_cache_path(self.name, self.mirror)
+
+ def get_data_to_cache(self) -> dict:
+ cache = {}
+
+ # required attrs to cache
+ for a in {'command', 'icon_path', 'mirror'}:
+ val = getattr(self, a)
+
+ if val:
+ cache[a] = val
+ else:
+ return None
+
+ if self.desktop_entry:
+ cache['desktop_entry'] = self.desktop_entry
+
+ return cache
+
+ def fill_cached_data(self, data: dict):
+ for a in {'command', 'icon_path', 'mirror', 'desktop_entry'}:
+ val = data.get(a)
+ if val:
+ setattr(self, a, val)
+
+ if a == 'icon_path':
+ self.icon_url = val
+
+ def can_be_run(self) -> bool:
+ # only returns if there is a desktop entry set for the application to avoid running command-line applications
+ return bool(self.desktop_entry)
+
+ def get_command(self) -> str:
+ return self.command
+
+ def get_publisher(self):
+ return self.maintainer
diff --git a/bauh/gems/arch/pacman.py b/bauh/gems/arch/pacman.py
new file mode 100644
index 00000000..c612f11f
--- /dev/null
+++ b/bauh/gems/arch/pacman.py
@@ -0,0 +1,131 @@
+import re
+from typing import List, Set
+
+from bauh.commons.system import run_cmd, new_subprocess, new_root_subprocess, SystemProcess
+
+
+def is_enabled() -> bool:
+ try:
+ new_subprocess(['pacman', '--version'])
+ return True
+ except FileNotFoundError:
+ return False
+
+
+def get_mirrors(pkgs: Set[str]) -> dict:
+ pkgre = '|'.join(pkgs)
+
+ searchres = new_subprocess(['pacman', '-Ss', pkgre]).stdout
+ mirrors = {}
+
+ for line in new_subprocess(['grep', '-E', '.+/({}) '.format(pkgre)], stdin=searchres).stdout:
+ if line:
+ match = line.decode()
+ for p in pkgs:
+ if p in match:
+ mirrors[p] = match.split('/')[0]
+
+ return mirrors
+
+
+def is_available_from_mirrors(pkg_name: str) -> bool:
+ return bool(run_cmd('pacman -Ss ' + pkg_name))
+
+
+def get_info(pkg_name) -> str:
+ return run_cmd('pacman -Qi ' + pkg_name)
+
+
+def get_info_list(pkg_name: str) -> List[tuple]:
+ info = get_info(pkg_name)
+ if info:
+ return re.findall(r'(\w+\s?\w+)\s+:\s+(.+(\n\s+.+)*)', info)
+
+
+def get_info_dict(pkg_name: str) -> dict:
+ info_list = get_info_list(pkg_name)
+
+ if info_list:
+ info_dict = {}
+ for info_data in info_list:
+ attr = info_data[0].lower()
+ info_dict[attr] = info_data[1] if '\n' not in info_data[1] else ' '.join([l.strip() for l in info_data[1].split('\n')])
+
+ if info_dict[attr] == 'None':
+ info_dict[attr] = None
+
+ return info_dict
+
+
+def list_installed() -> Set[str]:
+ return {out.decode().strip() for out in new_subprocess(['pacman', '-Qq']).stdout if out}
+
+
+def list_and_map_installed() -> dict: # returns a dict with with package names as keys and versions as values
+ installed = new_subprocess(['pacman', '-Qq']).stdout # retrieving all installed package names
+ allinfo = new_subprocess(['pacman', '-Qi'], stdin=installed).stdout # retrieving all installed packages info
+
+ pkgs, current_pkg = {'mirrors': {}, 'not_signed': {}}, {}
+ for out in new_subprocess(['grep', '-E', '(Name|Description|Version|Validated By)'], stdin=allinfo).stdout: # filtering only the Name and Validated By fields:
+ if out:
+ line = out.decode()
+
+ if line.startswith('Name'):
+ current_pkg['name'] = line.split(':')[1].strip()
+ elif line.startswith('Version'):
+ version = line.split(':')
+ current_pkg['version'] = version[len(version) - 1].strip()
+ elif line.startswith('Description'):
+ current_pkg['description'] = line.split(':')[1].strip()
+ elif line.startswith('Validated'):
+
+ if line.split(':')[1].strip().lower() == 'none':
+ pkgs['not_signed'][current_pkg['name']] = {'version': current_pkg['version'], 'description': current_pkg['description']}
+
+ current_pkg = {}
+
+ return pkgs
+
+
+def install_as_process(pkgpath: str, root_password: str, aur: bool, pkgdir: str = '.') -> SystemProcess:
+
+ if aur:
+ cmd = ['pacman', '-U', pkgpath, '--noconfirm'] # pkgpath = install file path
+ else:
+ cmd = ['pacman', '-S', pkgpath, '--noconfirm'] # pkgpath = pkgname
+
+ return SystemProcess(new_root_subprocess(cmd, root_password, cwd=pkgdir))
+
+
+def list_desktop_entries(pkgnames: Set[str]) -> List[str]:
+ if pkgnames:
+ installed_files = new_subprocess(['pacman', '-Qlq', *pkgnames]).stdout
+
+ desktop_files = []
+ for out in new_subprocess(['grep', '-E', '.desktop'], stdin=installed_files).stdout:
+ if out:
+ desktop_files.append(out.decode().strip())
+
+ return desktop_files
+
+
+def list_icon_paths(pkgnames: Set[str]) -> List[str]:
+ installed_files = new_subprocess(['pacman', '-Qlq', *pkgnames]).stdout
+
+ icon_files = []
+ for out in new_subprocess(['grep', '-E', '.(png|svg|jpeg)'], stdin=installed_files).stdout:
+ if out:
+ icon_files.append(out.decode().strip())
+
+ return icon_files
+
+
+def list_bin_paths(pkgnames: Set[str]) -> List[str]:
+ installed_files = new_subprocess(['pacman', '-Qlq', *pkgnames]).stdout
+
+ bin_paths = []
+ for out in new_subprocess(['grep', '-E', '^/usr/bin/.+'], stdin=installed_files).stdout:
+ if out:
+ bin_paths.append(out.decode().strip())
+
+ return bin_paths
diff --git a/bauh/gems/arch/pkgbuild.py b/bauh/gems/arch/pkgbuild.py
new file mode 100644
index 00000000..40d19c29
--- /dev/null
+++ b/bauh/gems/arch/pkgbuild.py
@@ -0,0 +1,13 @@
+import re
+from typing import Set
+
+RE_PKGBUILD_OPTDEPS = re.compile(r"optdepends = ([^:\s]+)")
+RE_PKGBUILD_DEPSON = re.compile(r"depends = ([^:\s]+)")
+
+
+def read_optdeps(srcinfo: str) -> Set[str]:
+ return set(RE_PKGBUILD_OPTDEPS.findall(srcinfo))
+
+
+def read_depends_on(srcinfo: str) -> Set[str]:
+ return set(RE_PKGBUILD_DEPSON.findall(srcinfo))
diff --git a/bauh/gems/arch/resources/img/arch.png b/bauh/gems/arch/resources/img/arch.png
new file mode 100755
index 00000000..c1e2fb1f
Binary files /dev/null and b/bauh/gems/arch/resources/img/arch.png differ
diff --git a/bauh/gems/arch/resources/locale/en b/bauh/gems/arch/resources/locale/en
new file mode 100644
index 00000000..34257bec
--- /dev/null
+++ b/bauh/gems/arch/resources/locale/en
@@ -0,0 +1,49 @@
+gem.arch.label=Arch ( AUR )
+arch.install.conflict.popup.title=Conflict detected
+arch.install.conflict.popup.body=The applications {} are in conflict. You must uninstall one to install the other. Continue ?
+arch.missing_deps.title=Missing dependencies
+arch.missing_deps.body=The following dependencies must be installed before the {} installation continues
+arch.downgrade.error=Error
+arch.downgrade.impossible=It is not possible to downgrade {}
+aur.history.1_version=version
+aur.history.2_release=release
+aur.history.3_date=date
+arch.downloading.package=Downloading the package
+arch.uncompressing.package=Uncompressing the package
+arch.checking.deps=Checking {} dependencies
+arch.missing_deps_found=Missing dependencies for {}
+arch.building.package=Building package {}
+arch.checking.conflicts=Checking any conflicts with {}
+arch.installing.package=Installing {} package
+arch.uninstalling.conflict=Uninstalling conflicting package {}
+arch.uninstalling.conflict.fail=It was not possible to uninstall the conflicting package {}
+arch.clone=Cloning the AUR repository {}
+arch.downgrade.reading_commits=Reading the repository commits
+arch.downgrade.version_found=Current package version found
+arch.downgrade.install_older=Installing older version
+aur.info.01_id=id
+aur.info.02_name=name
+aur.info.03_version=version
+aur.info.04_popularity=popularity
+aur.info.05_votes=votes
+aur.info.06_package_base=package base
+aur.info.07_maintainer=maintainer
+aur.info.08_first_submitted=first submitted
+aur.info.09_last_modified=app.last_modified
+aur.info.10_url=url download
+aur.info.11_pkg_build_url=url pkgbuild
+aur.info.13_pkg_build=pkgbuild
+aur.info.11_dependson=dependencies
+aur.info.12_optdepends=optional dependencies
+arch.install.dep_not_found.title=Dependency not found
+arch.install.dep_not_found.body=Required dependency {} was not found in AUR nor in default mirrors. Installation cancelled.
+arch.install.dependency.install=Installing package dependency {}
+arch.install.dependency.install.error=Could not install dependent package {}. Installation of {} aborted.
+arch.uninstall.required_by={} cannot be uninstalled because it is necessary for these following packages to work
+arch.uninstall.required_by.advice=Uninstall them first before uninstalling {}.
+arch.install.optdeps.request.title=Optional dependencies
+arch.install.optdeps.request.body={} was succesfully installed ! There are some optional associated packages that you might want to install as well (check those you want)
+arch.install.optdep.error=Could not install the optional package {}
+arch.optdeps.checking=Checking {} optional dependencies
+arch.warning.disabled={} seems not to be installed. It will not be possible to manage Arch / AUR packages.
+arch.warning.git={} seems not to be installed. It will not be possible to downgrade AUR packages.
\ No newline at end of file
diff --git a/bauh/gems/arch/resources/locale/es b/bauh/gems/arch/resources/locale/es
new file mode 100644
index 00000000..6ca204d7
--- /dev/null
+++ b/bauh/gems/arch/resources/locale/es
@@ -0,0 +1,81 @@
+gem.arch.label=Arch ( AUR )
+aur.info.architecture=arquitectura
+aur.info.architecture.any=cualquier
+aur.info.build date=fecha de construcción
+aur.info.depends on=depende
+aur.info.description=descripción
+aur.info.install date=fecha de instalación
+aur.info.install reason=razón da instalación
+aur.info.install reason.explicitly installed=instalado explícitamente
+aur.info.install script=script de instalación
+aur.info.install script.no=ninguno
+aur.info.installed size=tamaño da instalación
+aur.info.licenses=licencias
+aur.info.name=nombre
+aur.info.optional deps=dependencias opcionales
+aur.info.packager=empaquetador
+aur.info.packager.unknown packager=desconocido
+aur.info.url=url
+aur.info.version=versión
+aur.info.arch=arquitectura
+aur.info.arch.any=cualquier
+aur.info.depends=depende
+aur.info.pkgdesc=descripción
+aur.info.pkgname=nombre
+aur.info.pkgrel=lanzamiento
+aur.info.pkgver=versión
+aur.info.source=origen
+aur.info.optdepends=dependencias opcionales
+aur.info.license=licencia
+aur.info.validpgpkeys=llaves PGP válidas
+aur.info.options=opciones
+aur.info.provides=provee
+aur.info.conflicts with=conflicta
+arch.install.conflict.popup.title=Conflicto detectado
+arch.install.conflict.popup.body=Los aplicativos {} estan en conflicto. Debe desinstalar uno para instalar el otro. ¿Continuar?
+arch.missing_deps.title=Dependencias faltantes
+arch.missing_deps.body=Deben instalarse las siguientes dependencias antes de continuar la instalación de {}
+arch.downgrade.error=Error
+arch.downgrade.impossible=No es posible revertir la versión de {}
+aur.history.1_version=versión
+aur.history.2_release=lanzamiento
+aur.history.3_date=fecha
+arch.downloading.package=Descargando el paquete
+arch.uncompressing.package=Descomprimindo el paquete
+arch.checking.deps=Verificando las dependencias de {}
+arch.missing_deps_found=Dependencias faltantes para {}
+arch.building.package=Construyendo el paquete {}
+arch.checking.conflicts=Verificando se hay conflictos con {}
+arch.installing.package=Instalando el paquete {}
+arch.uninstalling.conflict=Eliminando el paquete conflictivo {}
+arch.uninstalling.conflict.fail=No fue posible desinstalar el paquete conflictivo {}
+arch.clone=Clonando el repositorio {} de AUR
+arch.downgrade.reading_commits=Leyendo los commits del repositorio
+arch.downgrade.version_found=Version actual del paquete encontrada
+arch.downgrade.install_older=Instalando versión anterior
+aur.info.01_id=id
+aur.info.02_name=nombre
+aur.info.03_version=versión
+aur.info.04_popularity=popularidad
+aur.info.05_votes=votos
+aur.info.06_package_base=paquete base
+aur.info.07_maintainer=mantenedor
+aur.info.08_first_submitted=primero envio
+aur.info.09_last_modified=última modificación
+aur.info.10_url=url download
+aur.info.11_pkg_build_url=url pkgbuild
+aur.info.13_pkg_build=pkgbuild
+aur.info.11_dependson=dependencias
+aur.info.12_optdepends=dependencias opcionales
+arch.install.dep_not_found.title=Dependencia no encontrada
+arch.install.dep_not_found.body=No se encontró la dependencia requerida {} en AUR ni en los espejos predeterminados. Instalación cancelada.
+arch.install.dependency.install=Instalando el paquete dependiente {}
+arch.install.dependency.install.error=No se pudo instalar el paquete dependiente {}. Instalación de {} abortada.
+arch.uninstall.required_by=No se puede desinstalar {} porque es necesario para que los siguientes paquetes funcionen
+arch.uninstall.required_by.advice=Debe desinstalarlos primero antes de desinstalar {}
+arch.install.optdeps.request.title=Dependencias opcionales
+arch.install.optdeps.request.body=¡{} se instaló correctamente! También hay algunos paquetes opcionales asociados que es posible que desee instalar (marque los que desee)
+arch.install.optdep.error=No se pudo instalar el paquete opcional {}
+arch.optdeps.checking=Verificando las dependencias opcionales de {}
+arch.warning.disabled={} parece no estar instalado. No será posible administrar paquetes Arch / AUR.
+arch.warning.git={} parece no estar instalado. No será posible revertir las versiones de paquetes Arch / AUR.
\ No newline at end of file
diff --git a/bauh/gems/arch/resources/locale/pt b/bauh/gems/arch/resources/locale/pt
new file mode 100644
index 00000000..95a8d348
--- /dev/null
+++ b/bauh/gems/arch/resources/locale/pt
@@ -0,0 +1,81 @@
+gem.arch.label=Arch ( AUR )
+aur.info.architecture=arquitetura
+aur.info.architecture.any=qualquer
+aur.info.build date=data de construção
+aur.info.depends on=depende
+aur.info.description=descrição
+aur.info.install date=data de instalação
+aur.info.install reason=razão da instalação
+aur.info.install reason.explicitly installed=instalado explicitamente
+aur.info.install script=script de instalação
+aur.info.install script.no=nenhum
+aur.info.installed size=tamanho da instalação
+aur.info.licenses=licenças
+aur.info.name=nome
+aur.info.optional deps=dependências opcionais
+aur.info.packager=empacotador
+aur.info.packager.unknown packager=desconhecido
+aur.info.url=url
+aur.info.version=versão
+aur.info.arch=arquitetura
+aur.info.arch.any=qualquer
+aur.info.depends=depende
+aur.info.pkgdesc=descrição
+aur.info.pkgname=nome
+aur.info.pkgrel=lançamento
+aur.info.pkgver=versão
+aur.info.source=origem
+aur.info.optdepends=dependências opcionais
+aur.info.license=licença
+aur.info.validpgpkeys=chaves PGP válidas
+aur.info.options=opções
+aur.info.provides=provê
+aur.info.conflicts with=conflita
+arch.install.conflict.popup.title=Conflito detectado
+arch.install.conflict.popup.body=Os aplicativos {} estão em conflito. Você precisa desinstalar um para instalar o outro. Continuar ?
+arch.missing_deps.title=Dependências ausentes
+arch.missing_deps.body=As seguintes dependências devem ser instaladas antes de continuar com a instalação de {}
+arch.downgrade.error=Erro
+arch.downgrade.impossible=Não é possível reverter a versão de {}
+aur.history.1_version=versão
+aur.history.2_release=lançamento
+aur.history.3_date=data
+arch.downloading.package=Baixando o pacote
+arch.uncompressing.package=Descompactando o pacote
+arch.checking.deps=Checando as dependências de {}
+arch.missing_deps_found=Dependencias ausentes para {}
+arch.building.package=Construindo o pacote {}
+arch.checking.conflicts=Verificando se há conflitos com {}
+arch.installing.package=Instalando o pacote {}
+arch.uninstalling.conflict=Desinstalando o pacote conflitante {}
+arch.uninstalling.conflict.fail=Não foi possível desinstalar o pacote conflitante {}
+arch.clone=Clonando o repositório {} do AUR
+arch.downgrade.reading_commits=Lendo os commits do repositório
+arch.downgrade.version_found=Versão atual do pacote encontrada
+arch.downgrade.install_older=Instalando versão anterior
+aur.info.01_id=id
+aur.info.02_name=nome
+aur.info.03_version=versão
+aur.info.04_popularity=popularidade
+aur.info.05_votes=votos
+aur.info.06_package_base=pacote base
+aur.info.07_maintainer=mantenedor
+aur.info.08_first_submitted=primeira submissão
+aur.info.09_last_modified=última modificação
+aur.info.10_url=url download
+aur.info.11_pkg_build_url=url pkgbuild
+aur.info.13_pkg_build=pkgbuild
+aur.info.11_dependson=dependências
+aur.info.12_optdepends=dependências opcionais
+arch.install.dep_not_found.title=Dependência não encontrada
+arch.install.dep_not_found.body=A dependência {} não foi encontrado no AUR nem nos espelhos padrões. Instalação cancelada.
+arch.install.dependency.install=Instalando o pacote dependente {}
+arch.install.dependency.install.error=Não foi possível instalar o pacote dependente {}. Instalação de {} abortada.
+arch.uninstall.required_by={} não pode ser desinstalado porque ele é necessário para o funcionamento dos seguintes pacotes
+arch.uninstall.required_by.advice=Desinstale eles primeiro antes de desinstalar {}
+arch.install.optdeps.request.title=Dependências opcionais
+arch.install.optdeps.request.body={} foi instalado com sucesso ! Existem alguns pacotes opcionais associados que talvez você também queira instalar (marque os desejados)
+arch.install.optdep.error=Não foi possível instalar o pacote opcional {}
+arch.optdeps.checking=Verificando as dependências opcionais de {}
+arch.warning.disabled={} parece não estar instalado. Não será possível gerenciar pacotes Arch / AUR.
+arch.warning.git={} parece não estar instalado. Não será possível reverter versões de pacotes Arch / AUR.
\ No newline at end of file
diff --git a/bauh/gems/arch/suggestions.py b/bauh/gems/arch/suggestions.py
new file mode 100644
index 00000000..393e6b63
--- /dev/null
+++ b/bauh/gems/arch/suggestions.py
@@ -0,0 +1,3 @@
+from bauh.api.abstract.model import SuggestionPriority
+
+ALL = {'google-chrome': SuggestionPriority.HIGH}
diff --git a/bauh/gems/arch/worker.py b/bauh/gems/arch/worker.py
new file mode 100644
index 00000000..5afa4327
--- /dev/null
+++ b/bauh/gems/arch/worker.py
@@ -0,0 +1,53 @@
+import logging
+import os
+import time
+from multiprocessing import Process
+from threading import Thread
+
+from bauh.api.abstract.context import ApplicationContext
+from bauh.api.abstract.controller import SoftwareManager
+
+from bauh.gems.arch import pacman, disk
+
+URL_INDEX = 'https://aur.archlinux.org/packages.gz'
+URL_INFO = 'https://aur.archlinux.org/rpc/?v=5&type=info&arg={}'
+
+
+class AURIndexUpdater(Thread):
+
+ def __init__(self, context: ApplicationContext, man: SoftwareManager):
+ super(AURIndexUpdater, self).__init__(daemon=True)
+ self.http_client = context.http_client
+ self.logger = context.logger
+ self.man = man
+
+ def run(self):
+
+ while True:
+ self.logger.info('Pre-indexing AUR packages in memory')
+ res = self.http_client.get(URL_INDEX)
+
+ if res and res.text:
+ self.man.names_index = {n.replace('-', '').replace('_', '').replace('.', ''): n for n in res.text.split('\n') if n and not n.startswith('#')}
+ self.logger.info('Pre-indexed {} AUR package names in memory'.format(len(self.man.names_index)))
+ else:
+ self.logger.warning('No data returned from: {}'.format(URL_INDEX))
+
+ time.sleep(5 * 60) # updates every 5 minutes
+
+
+class ArchDiskCacheUpdater(Thread if bool(os.getenv('BAUH_DEBUG', 0)) else Process):
+
+ def __init__(self, logger: logging.Logger):
+ super(ArchDiskCacheUpdater, self).__init__(daemon=True)
+ self.logger = logger
+
+ def run(self):
+ self.logger.info('Pre-caching installed AUR packages data to disk')
+ installed = pacman.list_and_map_installed()
+
+ saved = 0
+ if installed and installed['not_signed']:
+ saved = disk.save_several({app for app in installed['not_signed']}, 'aur', overwrite=False)
+
+ self.logger.info('Pre-cached data of {} AUR packages to disk'.format(saved))
diff --git a/bauh/gems/flatpak/__init__.py b/bauh/gems/flatpak/__init__.py
new file mode 100644
index 00000000..92020dec
--- /dev/null
+++ b/bauh/gems/flatpak/__init__.py
@@ -0,0 +1,3 @@
+import os
+ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
+
diff --git a/bauh/gems/flatpak/constants.py b/bauh/gems/flatpak/constants.py
new file mode 100644
index 00000000..687356af
--- /dev/null
+++ b/bauh/gems/flatpak/constants.py
@@ -0,0 +1,2 @@
+FLATHUB_URL = 'https://flathub.org'
+FLATHUB_API_URL = FLATHUB_URL + '/api/v1'
diff --git a/bauh/gems/flatpak/controller.py b/bauh/gems/flatpak/controller.py
new file mode 100644
index 00000000..a70dde9c
--- /dev/null
+++ b/bauh/gems/flatpak/controller.py
@@ -0,0 +1,208 @@
+from datetime import datetime
+from typing import List, Set, Type
+
+from bauh.api.abstract.controller import SearchResult, SoftwareManager, ApplicationContext
+from bauh.api.abstract.disk import DiskCacheLoader
+from bauh.api.abstract.handler import ProcessWatcher
+from bauh.api.abstract.model import PackageHistory, PackageUpdate, SoftwarePackage, PackageSuggestion
+from bauh.api.abstract.view import MessageType
+from bauh.commons.html import strip_html
+from bauh.commons.system import SystemProcess, ProcessHandler
+
+from bauh.gems.flatpak import flatpak, suggestions
+from bauh.gems.flatpak.model import FlatpakApplication
+from bauh.gems.flatpak.worker import FlatpakAsyncDataLoader, FlatpakUpdateLoader
+
+
+class FlatpakManager(SoftwareManager):
+
+ def __init__(self, context: ApplicationContext):
+ super(FlatpakManager, self).__init__(context=context)
+ self.i18n = context.i18n
+ self.api_cache = context.cache_factory.new()
+ context.disk_loader_factory.map(FlatpakApplication, self.api_cache)
+
+ def get_managed_types(self) -> Set["type"]:
+ return {FlatpakApplication}
+
+ def _map_to_model(self, app_json: dict, installed: bool, disk_loader: DiskCacheLoader) -> FlatpakApplication:
+
+ app = FlatpakApplication(arch=app_json.get('arch'),
+ branch=app_json.get('branch'),
+ origin=app_json.get('origin'),
+ runtime=app_json.get('runtime'),
+ ref=app_json.get('ref'),
+ commit=app_json.get('commit'),
+ id=app_json.get('id'),
+ name=app_json.get('name'),
+ version=app_json.get('version'),
+ latest_version=app_json.get('latest_version'))
+
+ app.installed = installed
+
+ api_data = self.api_cache.get(app_json['id'])
+
+ expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow()
+
+ if not api_data or expired_data:
+ if not app_json['runtime']:
+ if disk_loader:
+ disk_loader.fill(app) # preloading cached disk data
+ FlatpakAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, context=self.context).start()
+
+ else:
+ app.fill_cached_data(api_data)
+
+ return app
+
+ def search(self, words: str, disk_loader: DiskCacheLoader, limit: int = -1) -> SearchResult:
+
+ res = SearchResult([], [], 0)
+ apps_found = flatpak.search(words)
+
+ if apps_found:
+ already_read = set()
+ installed_apps = self.read_installed(disk_loader=disk_loader).installed
+
+ if installed_apps:
+ for app_found in apps_found:
+ for installed_app in installed_apps:
+ if app_found['id'] == installed_app.id:
+ res.installed.append(installed_app)
+ already_read.add(app_found['id'])
+
+ if len(apps_found) > len(already_read):
+ for app_found in apps_found:
+ if app_found['id'] not in already_read:
+ res.new.append(self._map_to_model(app_found, False, disk_loader))
+
+ res.total = len(res.installed) + len(res.new)
+ return res
+
+ def read_installed(self, disk_loader: DiskCacheLoader, limit: int = -1, only_apps: bool = False, pkg_types: Set[Type[SoftwarePackage]] = None) -> SearchResult:
+ installed = flatpak.list_installed()
+ models = []
+
+ if installed:
+
+ available_updates = flatpak.list_updates_as_str()
+
+ for app_json in installed:
+ model = self._map_to_model(app_json=app_json, installed=True, disk_loader=disk_loader)
+ model.update = app_json['id'] in available_updates
+ models.append(model)
+
+ return SearchResult(models, None, len(models))
+
+ def downgrade(self, pkg: FlatpakApplication, root_password: str, watcher: ProcessWatcher) -> bool:
+ watcher.change_progress(10)
+ watcher.change_substatus(self.i18n['flatpak.downgrade.commits'])
+ commits = flatpak.get_app_commits(pkg.ref, pkg.origin)
+
+ commit_idx = commits.index(pkg.commit)
+
+ # downgrade is not possible if the app current commit in the first one:
+ if commit_idx == len(commits) - 1:
+ watcher.show_message(self.i18n['flatpak.downgrade.impossible.title'], self.i18n['flatpak.downgrade.impossible.body'], MessageType.WARNING)
+ return False
+
+ commit = commits[commit_idx + 1]
+ watcher.change_substatus(self.i18n['flatpak.downgrade.reverting'])
+ watcher.change_progress(50)
+ success = ProcessHandler(watcher).handle(SystemProcess(subproc=flatpak.downgrade(pkg.ref, commit, root_password), success_phrase='Updates complete.'))
+ watcher.change_progress(100)
+ return success
+
+ def clean_cache_for(self, pkg: FlatpakApplication):
+ super(FlatpakManager, self).clean_cache_for(pkg)
+ self.api_cache.delete(pkg.id)
+
+ def update(self, pkg: FlatpakApplication, root_password: str, watcher: ProcessWatcher) -> bool:
+ return ProcessHandler(watcher).handle(SystemProcess(subproc=flatpak.update(pkg.ref)))
+
+ def uninstall(self, pkg: FlatpakApplication, root_password: str, watcher: ProcessWatcher) -> bool:
+ return ProcessHandler(watcher).handle(SystemProcess(subproc=flatpak.uninstall(pkg.ref)))
+
+ def get_info(self, app: FlatpakApplication) -> dict:
+ app_info = flatpak.get_app_info_fields(app.id, app.branch)
+ app_info['name'] = app.name
+ app_info['type'] = 'runtime' if app.runtime else 'app'
+ app_info['description'] = strip_html(app.description)
+ return app_info
+
+ def get_history(self, pkg: FlatpakApplication) -> PackageHistory:
+ commits = flatpak.get_app_commits_data(pkg.ref, pkg.origin)
+ status_idx = 0
+
+ for idx, data in enumerate(commits):
+ if data['commit'] == pkg.commit:
+ status_idx = idx
+ break
+
+ return PackageHistory(pkg=pkg, history=commits, pkg_status_idx=status_idx)
+
+ def install(self, pkg: FlatpakApplication, root_password: str, watcher: ProcessWatcher) -> bool:
+ return ProcessHandler(watcher).handle(SystemProcess(subproc=flatpak.install(pkg.id, pkg.origin)))
+
+ def is_enabled(self):
+ return flatpak.is_installed()
+
+ def requires_root(self, action: str, pkg: FlatpakApplication):
+ return action == 'downgrade'
+
+ def prepare(self):
+ pass
+
+ def list_updates(self) -> List[PackageUpdate]:
+ updates = []
+ installed = flatpak.list_installed(extra_fields=False)
+
+ if installed:
+ available_updates = flatpak.list_updates_as_str()
+
+ if available_updates:
+ loaders = None
+
+ for app_json in installed:
+ if app_json['id'] in available_updates:
+ loader = FlatpakUpdateLoader(app=app_json, http_client=self.context.http_client)
+ loader.start()
+
+ if loaders is None:
+ loaders = []
+
+ loaders.append(loader)
+
+ if loaders:
+ for loader in loaders:
+ loader.join()
+ app = loader.app
+ updates.append(PackageUpdate(pkg_id='{}:{}'.format(app['id'], app['branch']),
+ pkg_type='flatpak',
+ version=app.get('version')))
+
+ return updates
+
+ def list_warnings(self) -> List[str]:
+ if flatpak.is_installed():
+ if not flatpak.has_remotes_set():
+ return [self.i18n['flatpak.notification.no_remotes']]
+
+ def list_suggestions(self, limit: int) -> List[PackageSuggestion]:
+
+ res = []
+
+ sugs = [(i, p) for i, p in suggestions.ALL.items()]
+ sugs.sort(key=lambda t: t[1].value, reverse=True)
+
+ for sug in sugs:
+
+ if limit <= 0 or len(res) < limit:
+ app_json = flatpak.search(sug[0], app_id=True)
+
+ if app_json:
+ res.append(PackageSuggestion(self._map_to_model(app_json[0], False, None), sug[1]))
+ else:
+ break
+
+ return res
diff --git a/bauh/gems/flatpak/flatpak.py b/bauh/gems/flatpak/flatpak.py
new file mode 100755
index 00000000..132cb091
--- /dev/null
+++ b/bauh/gems/flatpak/flatpak.py
@@ -0,0 +1,245 @@
+import re
+import subprocess
+from typing import List
+
+from bauh.api.exception import NoInternetException
+from bauh.commons.system import new_subprocess, run_cmd, new_root_subprocess
+
+BASE_CMD = 'flatpak'
+
+
+def app_str_to_json(line: str, version: str, extra_fields: bool = True) -> dict:
+
+ app_array = line.split('\t')
+
+ if version >= '1.3.0':
+ app = {'name': app_array[0],
+ 'id': app_array[1],
+ 'version': app_array[2],
+ 'branch': app_array[3]}
+
+ elif '1.0' <= version < '1.1':
+
+ ref_data = app_array[0].split('/')
+
+ app = {'id': ref_data[0],
+ 'arch': ref_data[1],
+ 'name': ref_data[0].split('.')[-1],
+ 'ref': app_array[0],
+ 'options': app_array[1],
+ 'branch': ref_data[2],
+ 'version': None}
+ elif '1.2' <= version < '1.3':
+ app = {'id': app_array[1],
+ 'name': app_array[1].strip().split('.')[-1],
+ 'version': app_array[2],
+ 'branch': app_array[3],
+ 'arch': app_array[4],
+ 'origin': app_array[5]}
+ else:
+ raise Exception('Unsupported version')
+
+ if extra_fields:
+ 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 = run_cmd('{} --version'.format(BASE_CMD), print_error=False)
+ return res.split(' ')[1].strip() if res else None
+
+
+def get_app_info(app_id: str, branch: str):
+ return run_cmd('{} info {} {}'.format(BASE_CMD, app_id, branch))
+
+
+def list_installed(extra_fields: bool = True) -> List[dict]:
+ apps_str = run_cmd('{} list'.format(BASE_CMD))
+
+ if apps_str:
+ version = get_version()
+ app_lines = apps_str.split('\n')
+ return [app_str_to_json(line, version, extra_fields=extra_fields) for line in app_lines if line]
+
+ return []
+
+
+def update(app_ref: str):
+ """
+ Updates the app reference
+ :param app_ref:
+ :return:
+ """
+ return new_subprocess([BASE_CMD, 'update', '-y', app_ref])
+
+
+def uninstall(app_ref: str):
+ """
+ Removes the app by its reference
+ :param app_ref:
+ :return:
+ """
+ return new_subprocess([BASE_CMD, 'uninstall', app_ref, '-y'])
+
+
+def list_updates_as_str():
+ return run_cmd('{} update'.format(BASE_CMD), ignore_return_code=True)
+
+
+def downgrade(app_ref: str, commit: str, root_password: str) -> subprocess.Popen:
+ return new_root_subprocess([BASE_CMD, 'update', '--commit={}'.format(commit), app_ref, '-y'], root_password)
+
+
+def get_app_commits(app_ref: str, origin: str) -> List[str]:
+ log = run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref))
+
+ if log:
+ return re.findall(r'Commit+:\s(.+)', log)
+ else:
+ raise NoInternetException()
+
+
+def get_app_commits_data(app_ref: str, origin: str) -> List[dict]:
+ log = run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref))
+
+ if not log:
+ raise NoInternetException()
+
+ res = re.findall(r'(Commit|Subject|Date):\s(.+)', log)
+
+ commits = []
+
+ 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, app_id: bool = False) -> List[dict]:
+ cli_version = get_version()
+
+ res = 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':
+ id_ = info_list[2].strip()
+
+ if app_id and id_ != word:
+ continue
+
+ version = info_list[3].strip()
+ app = {
+ 'name': info_list[0].strip(),
+ 'description': info_list[1].strip(),
+ 'id': id_,
+ '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':
+ id_ = info_list[1].strip()
+
+ if app_id and id_ != word:
+ continue
+
+ desc = info_list[0].split('-')
+ version = info_list[2].strip()
+ app = {
+ 'name': desc[0].strip(),
+ 'description': desc[1].strip(),
+ 'id': id_,
+ '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:
+ id_ = info_list[0].strip()
+
+ if app_id and id_ != word:
+ continue
+
+ version = info_list[1].strip()
+ app = {
+ 'name': '',
+ 'description': info_list[4].strip(),
+ 'id': id_,
+ '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
+ }
+
+ found.append(app)
+
+ if app_id and len(found) > 0:
+ break
+
+ return found
+
+
+def install(app_id: str, origin: str):
+ return new_subprocess([BASE_CMD, 'install', origin, app_id, '-y'])
+
+
+def set_default_remotes():
+ run_cmd('{} remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo'.format(BASE_CMD))
+
+
+def has_remotes_set() -> bool:
+ return bool(run_cmd('{} remotes'.format(BASE_CMD)).strip())
diff --git a/bauh/gems/flatpak/model.py b/bauh/gems/flatpak/model.py
new file mode 100644
index 00000000..3ace0f0c
--- /dev/null
+++ b/bauh/gems/flatpak/model.py
@@ -0,0 +1,69 @@
+from bauh.api.abstract.model import SoftwarePackage
+
+from bauh.commons import resource
+from bauh.gems.flatpak import ROOT_DIR
+
+
+class FlatpakApplication(SoftwarePackage):
+
+ def __init__(self, id: str = None, name: str = None, version: str = None, latest_version: str = None, description: str = None,
+ branch: str = None, arch: str = None, origin: str = None, runtime: bool = False, ref: str = str, commit: str = str):
+ super(FlatpakApplication, self).__init__(id=id, name=name, version=version,
+ latest_version=latest_version, description=description)
+ self.ref = ref
+ self.branch = branch
+ self.arch = arch
+ self.origin = origin
+ self.runtime = runtime
+ self.commit = commit
+
+ def is_incomplete(self):
+ return self.description is None and self.icon_url
+
+ def has_history(self):
+ return self.installed
+
+ def has_info(self):
+ return self.installed
+
+ def can_be_downgraded(self):
+ return self.installed
+
+ def get_type(self):
+ return 'flatpak'
+
+ def get_default_icon_path(self):
+ return resource.get_path('img/flathub.svg', ROOT_DIR)
+
+ def get_type_icon_path(self):
+ return self.get_default_icon_path()
+
+ def is_application(self):
+ return not self.runtime
+
+ def get_disk_cache_path(self):
+ return super(FlatpakApplication, self).get_disk_cache_path() + '/installed/' + self.id
+
+ def get_data_to_cache(self):
+ return {
+ 'description': self.description,
+ 'icon_url': self.icon_url,
+ 'latest_version': self.latest_version,
+ 'version': self.version,
+ 'name': self.name,
+ 'categories': self.categories
+ }
+
+ def fill_cached_data(self, data: dict):
+ for attr in self.get_data_to_cache().keys():
+ if data.get(attr) and not getattr(self, attr):
+ setattr(self, attr, data[attr])
+
+ def get_command(self) -> str:
+ return "flatpak run {}".format(self.id)
+
+ def can_be_run(self) -> bool:
+ return self.installed and not self.runtime
+
+ def get_publisher(self):
+ return 'flathub'
diff --git a/bauh/gems/flatpak/resources/img/flathub.svg b/bauh/gems/flatpak/resources/img/flathub.svg
new file mode 100755
index 00000000..1b44a5d4
--- /dev/null
+++ b/bauh/gems/flatpak/resources/img/flathub.svg
@@ -0,0 +1,179 @@
+
+
+
+
diff --git a/bauh/gems/flatpak/resources/img/flatpak.png b/bauh/gems/flatpak/resources/img/flatpak.png
new file mode 100755
index 00000000..4fa5bb94
Binary files /dev/null and b/bauh/gems/flatpak/resources/img/flatpak.png differ
diff --git a/bauh/gems/flatpak/resources/locale/en b/bauh/gems/flatpak/resources/locale/en
new file mode 100644
index 00000000..a801fdfb
--- /dev/null
+++ b/bauh/gems/flatpak/resources/locale/en
@@ -0,0 +1,5 @@
+flatpak.notification.no_remotes=No Flatpak remotes set. It will not be possible to search for Flatpak apps.
+flatpak.downgrade.impossible.title=Error
+flatpak.downgrade.impossible.body=Impossible to downgrade: the app is in its first version
+flatpak.downgrade.commits=Reading package commits
+flatpak.downgrade.reverting=Reverting version
\ No newline at end of file
diff --git a/bauh/gems/flatpak/resources/locale/es b/bauh/gems/flatpak/resources/locale/es
new file mode 100644
index 00000000..7db5ad27
--- /dev/null
+++ b/bauh/gems/flatpak/resources/locale/es
@@ -0,0 +1,27 @@
+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
+flatpak.notification.no_remotes=No hay repositorios (remotes) Flatpak configurados. No será posible buscar aplicativos Flatpak.
+flatpak.downgrade.impossible.title=Error
+flatpak.downgrade.impossible.body=Imposible revertir la versión: el aplicativo está en su primera versión
+flatpak.history.date=fecha
+flatpak.history.commit=commit
+flatpak.history.subject=tema
+flatpak.downgrade.commits=Leyendo commits del paquete
+flatpak.downgrade.reverting=Revirtiendo la versión
\ No newline at end of file
diff --git a/bauh/gems/flatpak/resources/locale/pt b/bauh/gems/flatpak/resources/locale/pt
new file mode 100644
index 00000000..be69f0fb
--- /dev/null
+++ b/bauh/gems/flatpak/resources/locale/pt
@@ -0,0 +1,27 @@
+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
+flatpak.notification.no_remotes=Não há repositórios (remotes) Flatpak configurados. Não será possível buscar aplicativos Flatpak.
+flatpak.downgrade.impossible.title=Erro
+flatpak.downgrade.impossible.body=Impossível reverter a versão: o aplicativo está na sua primeira versão
+flatpak.history.date=data
+flatpak.history.commit=commit
+flatpak.history.subject=assunto
+flatpak.downgrade.commits=Lendo os commits do pacote
+flatpak.downgrade.reverting=Revertendo a versão
\ No newline at end of file
diff --git a/bauh/gems/flatpak/suggestions.py b/bauh/gems/flatpak/suggestions.py
new file mode 100644
index 00000000..169edae6
--- /dev/null
+++ b/bauh/gems/flatpak/suggestions.py
@@ -0,0 +1,15 @@
+from bauh.api.abstract.model import SuggestionPriority
+
+ALL = {
+ 'com.spotify.Client': SuggestionPriority.TOP,
+ 'com.skype.Client': SuggestionPriority.HIGH,
+ 'com.dropbox.Client': SuggestionPriority.MEDIUM,
+ 'us.zoom.Zoom': SuggestionPriority.MEDIUM,
+ 'org.telegram.desktop': SuggestionPriority.MEDIUM,
+ 'com.visualstudio.code': SuggestionPriority.LOW,
+ 'org.inkscape.Inkscape': SuggestionPriority.LOW,
+ 'org.libretro.RetroArch': SuggestionPriority.LOW,
+ 'org.kde.kdenlive': SuggestionPriority.LOW,
+ 'org.videolan.VLC': SuggestionPriority.LOW
+}
+
diff --git a/bauh/gems/flatpak/worker.py b/bauh/gems/flatpak/worker.py
new file mode 100644
index 00000000..ef59f0f1
--- /dev/null
+++ b/bauh/gems/flatpak/worker.py
@@ -0,0 +1,96 @@
+import traceback
+from threading import Thread
+
+from bauh.api.abstract.cache import MemoryCache
+from bauh.api.abstract.context import ApplicationContext
+from bauh.api.abstract.controller import SoftwareManager
+from bauh.api.abstract.model import PackageStatus
+from bauh.api.http import HttpClient
+
+from bauh.gems.flatpak import flatpak
+from bauh.gems.flatpak.constants import FLATHUB_API_URL, FLATHUB_URL
+from bauh.gems.flatpak.model import FlatpakApplication
+
+
+class FlatpakAsyncDataLoader(Thread):
+
+ def __init__(self, app: FlatpakApplication, manager: SoftwareManager, context: ApplicationContext, api_cache: MemoryCache):
+ super(FlatpakAsyncDataLoader, self).__init__(daemon=True)
+ self.app = app
+ self.manager = manager
+ self.http_client = context.http_client
+ self.api_cache = api_cache
+ self.persist = False
+ self.logger = context.logger
+
+ def run(self):
+ if self.app:
+ self.app.status = PackageStatus.LOADING_DATA
+
+ try:
+ res = self.http_client.get('{}/apps/{}'.format(FLATHUB_API_URL, self.app.id))
+
+ if res.status_code == 200 and res.text:
+ data = res.json()
+
+ if not self.app.version:
+ self.app.version = data.get('version')
+
+ if not self.app.name:
+ self.app.name = data.get('name')
+
+ self.app.description = data.get('description', data.get('summary', None))
+ self.app.icon_url = data.get('iconMobileUrl', None)
+ self.app.latest_version = data.get('currentReleaseVersion', self.app.version)
+
+ if not self.app.version and self.app.latest_version:
+ self.app.version = self.app.latest_version
+
+ if not self.app.installed and self.app.latest_version:
+ self.app.version = self.app.latest_version
+
+ if self.app.icon_url and self.app.icon_url.startswith('/'):
+ self.app.icon_url = FLATHUB_URL + self.app.icon_url
+
+ if data.get('categories'):
+ self.app.categories = [c['name'] for c in data['categories']]
+
+ loaded_data = self.app.get_data_to_cache()
+
+ self.api_cache.add(self.app.id, loaded_data)
+ self.persist = self.app.supports_disk_cache()
+ else:
+ self.logger.warning("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(self.app.id, res.status_code, res.content.decode()))
+ except:
+ self.logger.error("Could not retrieve app data for id '{}'".format(self.app.id))
+ traceback.print_exc()
+
+ self.app.status = PackageStatus.READY
+
+ if self.persist:
+ self.manager.cache_to_disk(pkg=self.app, icon_bytes=None, only_icon=False)
+
+
+class FlatpakUpdateLoader(Thread):
+
+ def __init__(self, app: dict, http_client: HttpClient):
+ super(FlatpakUpdateLoader, self).__init__(daemon=True)
+ self.app = app
+ self.http_client = http_client
+
+ def run(self):
+
+ if self.app.get('ref') is None:
+ self.app.update(flatpak.get_app_info_fields(self.app['id'], self.app['branch'], fields=['ref'], check_runtime=True))
+ else:
+ self.app['runtime'] = self.app['ref'].startswith('runtime/')
+
+ if not self.app['runtime']:
+
+ try:
+ data = self.http_client.get_json('{}/apps/{}'.format(FLATHUB_API_URL, self.app['id']))
+
+ if data and data.get('currentReleaseVersion'):
+ self.app['version'] = data['currentReleaseVersion']
+ except:
+ traceback.print_exc()
diff --git a/bauh/gems/snap/__init__.py b/bauh/gems/snap/__init__.py
new file mode 100644
index 00000000..8824c2d4
--- /dev/null
+++ b/bauh/gems/snap/__init__.py
@@ -0,0 +1,2 @@
+import os
+ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
diff --git a/bauh/gems/snap/constants.py b/bauh/gems/snap/constants.py
new file mode 100644
index 00000000..099a232e
--- /dev/null
+++ b/bauh/gems/snap/constants.py
@@ -0,0 +1 @@
+SNAP_API_URL = 'https://search.apps.ubuntu.com/api/v1'
diff --git a/bauh/gems/snap/controller.py b/bauh/gems/snap/controller.py
new file mode 100644
index 00000000..7fdd8c7c
--- /dev/null
+++ b/bauh/gems/snap/controller.py
@@ -0,0 +1,145 @@
+from datetime import datetime
+from typing import List, Set, Type
+
+from bauh.api.abstract.controller import SoftwareManager, SearchResult, ApplicationContext
+from bauh.api.abstract.disk import DiskCacheLoader
+from bauh.api.abstract.handler import ProcessWatcher
+from bauh.api.abstract.model import SoftwarePackage, PackageHistory, PackageUpdate, PackageSuggestion
+from bauh.commons.system import SystemProcess, ProcessHandler
+
+from bauh.gems.snap import snap, suggestions
+from bauh.gems.snap.model import SnapApplication
+from bauh.gems.snap.worker import SnapAsyncDataLoader
+
+
+class SnapManager(SoftwareManager):
+
+ def __init__(self, context: ApplicationContext):
+ super(SnapManager, self).__init__(context=context)
+ self.i18n = context.i18n
+ self.api_cache = context.cache_factory.new()
+ context.disk_loader_factory.map(SnapApplication, self.api_cache)
+
+ def map_json(self, app_json: dict, installed: bool, disk_loader: DiskCacheLoader) -> SnapApplication:
+ app = SnapApplication(publisher=app_json.get('publisher'),
+ rev=app_json.get('rev'),
+ notes=app_json.get('notes'),
+ app_type=app_json.get('type'),
+ id=app_json.get('name'),
+ name=app_json.get('name'),
+ version=app_json.get('version'),
+ latest_version=app_json.get('version'),
+ description=app_json.get('description'))
+
+ if app.publisher:
+ app.publisher = app.publisher.replace('*', '')
+
+ app.installed = installed
+
+ api_data = self.api_cache.get(app_json['name'])
+ expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow()
+
+ if (not api_data or expired_data) and app.is_application():
+ if disk_loader and app.installed:
+ disk_loader.fill(app)
+
+ SnapAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, context=self.context).start()
+ else:
+ app.fill_cached_data(api_data)
+
+ return app
+
+ def search(self, words: str, disk_loader: DiskCacheLoader, limit: int = -1) -> SearchResult:
+ installed = self.read_installed(disk_loader).installed
+
+ res = SearchResult([], [], 0)
+
+ for app_json in snap.search(words):
+
+ already_installed = None
+
+ if installed:
+ already_installed = [i for i in installed if i.id == app_json.get('name')]
+ already_installed = already_installed[0] if already_installed else None
+
+ if already_installed:
+ res.installed.append(already_installed)
+ else:
+ res.new.append(self.map_json(app_json, installed=False, disk_loader=disk_loader))
+
+ res.total = len(res.installed) + len(res.new)
+ return res
+
+ def read_installed(self, disk_loader: DiskCacheLoader, limit: int = -1, only_apps: bool = False, pkg_types: Set[Type[SoftwarePackage]] = None) -> SearchResult:
+ installed = [self.map_json(app_json, installed=True, disk_loader=disk_loader) for app_json in snap.read_installed()]
+ return SearchResult(installed, None, len(installed))
+
+ def downgrade(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool:
+ return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.downgrade_and_stream(pkg.name, root_password), wrong_error_phrase=None))
+
+ def update(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> SystemProcess:
+ raise Exception("'update' is not supported by {}".format(pkg.__class__.__name__))
+
+ def uninstall(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool:
+ return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.uninstall_and_stream(pkg.name, root_password)))
+
+ def get_managed_types(self) -> Set[Type[SoftwarePackage]]:
+ return {SnapApplication}
+
+ def clean_cache_for(self, pkg: SnapApplication):
+ super(SnapManager, self).clean_cache_for(pkg)
+ self.api_cache.delete(pkg.id)
+
+ def get_info(self, pkg: SnapApplication) -> dict:
+ info = snap.get_info(pkg.name, attrs=('license', 'contact', 'commands', 'snap-id', 'tracking', 'installed'))
+ info['description'] = pkg.description
+ info['publisher'] = pkg.publisher
+ info['revision'] = pkg.rev
+ info['name'] = pkg.name
+
+ if info.get('commands'):
+ info['commands'] = ' '.join(info['commands'])
+
+ return info
+
+ def get_history(self, pkg: SnapApplication) -> PackageHistory:
+ raise Exception("'get_history' is not supported by {}".format(pkg.__class__.__name__))
+
+ def install(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool:
+ return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.install_and_stream(pkg.name, pkg.confinement, root_password)))
+
+ def is_enabled(self) -> bool:
+ return snap.is_installed()
+
+ def requires_root(self, action: str, pkg: SnapApplication):
+ return action != 'search'
+
+ def refresh(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool:
+ return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.refresh_and_stream(pkg.name, root_password)))
+
+ def prepare(self):
+ pass
+
+ def list_updates(self) -> List[PackageUpdate]:
+ pass
+
+ def list_warnings(self) -> List[str]:
+ if snap.get_snapd_version() == 'unavailable':
+ return [self.i18n['snap.notification.snapd_unavailable']]
+
+ def list_suggestions(self, limit: int) -> List[PackageSuggestion]:
+ res = []
+
+ sugs = [(i, p) for i, p in suggestions.ALL.items()]
+ sugs.sort(key=lambda t: t[1].value, reverse=True)
+
+ for sug in sugs:
+
+ if limit <= 0 or len(res) < limit:
+ found = snap.search(sug[0], exact_name=True)
+ if found:
+ res.append(PackageSuggestion(self.map_json(found[0], installed=False, disk_loader=None), sug[1]))
+ else:
+ break
+
+ return res
diff --git a/bauh/gems/snap/model.py b/bauh/gems/snap/model.py
new file mode 100644
index 00000000..4ca9bf76
--- /dev/null
+++ b/bauh/gems/snap/model.py
@@ -0,0 +1,88 @@
+from typing import List
+
+from bauh.api.abstract.model import SoftwarePackage, PackageAction
+from bauh.commons import resource
+
+from bauh.gems.snap import ROOT_DIR
+
+EXTRA_INSTALLED_ACTIONS = [
+ PackageAction(i18n_status_key='snap.action.refresh.status',
+ i18_label_key='snap.action.refresh.label',
+ icon_path=resource.get_path('img/refresh.svg', ROOT_DIR),
+ manager_method='refresh',
+ requires_root=True)
+]
+
+
+class SnapApplication(SoftwarePackage):
+
+ def __init__(self, id: str = None, name: str = None, version: str = None, latest_version: str = None,
+ description: str = None, publisher: str = None, rev: str = None, notes: str = None,
+ app_type: str = None, confinement: str = None):
+ super(SnapApplication, self).__init__(id=id, name=name, version=version,
+ latest_version=latest_version, description=description)
+ self.publisher = publisher
+ self.rev = rev
+ self.notes = notes
+ self.type = app_type
+ self.confinement = confinement
+
+ def has_history(self):
+ return False
+
+ def has_info(self):
+ return True
+
+ def can_be_downgraded(self):
+ return self.installed
+
+ def get_type(self):
+ return 'snap'
+
+ def get_default_icon_path(self):
+ return resource.get_path('img/snap.png', ROOT_DIR)
+
+ def get_type_icon_path(self):
+ return self.get_default_icon_path()
+
+ def is_application(self):
+ return not self.type and (self.type not in ('core', 'base', 'snapd') and not self._name_starts_with(('gtk-', 'gnome-', 'kde-', 'gtk2-')))
+
+ def _name_starts_with(self, words: tuple):
+ for word in words:
+ if self.name.startswith(word):
+ return True
+
+ return False
+
+ def get_disk_cache_path(self):
+ return super(SnapApplication, self).get_disk_cache_path() + '/installed/' + self.name
+
+ def get_data_to_cache(self):
+ return {
+ "icon_url": self.icon_url,
+ 'confinement': self.confinement,
+ 'description': self.description
+ }
+
+ def fill_cached_data(self, data: dict):
+ if data:
+ for base_attr in ('icon_url', 'description'):
+ if data.get(base_attr):
+ setattr(self, base_attr, data[base_attr])
+
+ if data.get('confinement'):
+ self.confinement = data['confinement']
+
+ def get_command(self) -> str:
+ return "snap run " + self.name
+
+ def can_be_run(self) -> bool:
+ return self.installed and self.is_application()
+
+ def get_publisher(self):
+ return self.publisher
+
+ def get_custom_supported_actions(self) -> List[PackageAction]:
+ if self.installed:
+ return EXTRA_INSTALLED_ACTIONS
diff --git a/bauh/gems/snap/resources/img/refresh.svg b/bauh/gems/snap/resources/img/refresh.svg
new file mode 100755
index 00000000..16952792
--- /dev/null
+++ b/bauh/gems/snap/resources/img/refresh.svg
@@ -0,0 +1,146 @@
+
+
+
+
\ No newline at end of file
diff --git a/bauh/gems/snap/resources/img/snap.png b/bauh/gems/snap/resources/img/snap.png
new file mode 100755
index 00000000..c0b792bf
Binary files /dev/null and b/bauh/gems/snap/resources/img/snap.png differ
diff --git a/bauh/gems/snap/resources/locale/en b/bauh/gems/snap/resources/locale/en
new file mode 100644
index 00000000..f2d56736
--- /dev/null
+++ b/bauh/gems/snap/resources/locale/en
@@ -0,0 +1,3 @@
+snap.notification.snapd_unavailable=snapd seems not to be enabled. snap packages will not be available.
+snap.action.refresh.status=Refreshing
+snap.action.refresh.label=Refresh
\ No newline at end of file
diff --git a/bauh/gems/snap/resources/locale/es b/bauh/gems/snap/resources/locale/es
new file mode 100644
index 00000000..84d60632
--- /dev/null
+++ b/bauh/gems/snap/resources/locale/es
@@ -0,0 +1,12 @@
+snap.info.commands=comandos
+snap.info.contact=contacto
+snap.info.description=descripción
+snap.info.license=licencia
+snap.info.license.unset=no está definida
+snap.info.revision=revisión
+snap.info.tracking=tracking
+snap.info.installed=instalado
+snap.info.publisher=publicador
+snap.notification.snapd_unavailable=snapd no parece estar habilitado. los paquetes snap estarán indisponibles.
+snap.action.refresh.status=Actualizando
+snap.action.refresh.label=Actualizar
\ No newline at end of file
diff --git a/bauh/gems/snap/resources/locale/pt b/bauh/gems/snap/resources/locale/pt
new file mode 100644
index 00000000..4e743a93
--- /dev/null
+++ b/bauh/gems/snap/resources/locale/pt
@@ -0,0 +1,12 @@
+snap.info.commands=comandos
+snap.info.contact=contato
+snap.info.description=descrição
+snap.info.license=licença
+snap.info.license.unset=não definida
+snap.info.revision=revisão
+snap.info.tracking=tracking
+snap.info.installed=instalado
+snap.info.publisher=publicador
+snap.notification.snapd_unavailable=snapd não parece estar habilitado. os pacotes snap estarão indisponíveis.
+snap.action.refresh.status=Atualizando
+snap.action.refresh.label=Atualizar
\ No newline at end of file
diff --git a/bauh/gems/snap/snap.py b/bauh/gems/snap/snap.py
new file mode 100644
index 00000000..36cab302
--- /dev/null
+++ b/bauh/gems/snap/snap.py
@@ -0,0 +1,141 @@
+import re
+import subprocess
+from typing import List
+
+from bauh.commons.system import new_root_subprocess, run_cmd
+
+BASE_CMD = 'snap'
+
+
+def is_installed():
+ version = get_snapd_version()
+ return False if version is None else True
+
+
+def get_version():
+ res = run_cmd('{} --version'.format(BASE_CMD), print_error=False)
+ return res.split('\n')[0].split(' ')[-1].strip() if res else None
+
+
+def get_snapd_version():
+ res = run_cmd('{} --version'.format(BASE_CMD), print_error=False)
+
+ if not res:
+ return None
+ else:
+ lines = res.split('\n')
+
+ if lines and len(lines) >= 2:
+ version = lines[1].split(' ')[-1].strip()
+ return version if version and version.lower() != 'unavailable' else None
+ else:
+ return None
+
+
+def app_str_to_json(app: str) -> dict:
+ app_data = [word for word in app.split(' ') if word]
+ app_json = {
+ 'name': app_data[0],
+ 'version': app_data[1],
+ 'rev': app_data[2],
+ 'tracking': app_data[3],
+ 'publisher': app_data[4] if len(app_data) >= 5 else None,
+ 'notes': app_data[5] if len(app_data) >= 6 else None
+ }
+
+ app_json.update(get_info(app_json['name'], ('summary', 'type', 'description')))
+ return app_json
+
+
+def get_info(app_name: str, attrs: tuple = None):
+ full_info_lines = run_cmd('{} info {}'.format(BASE_CMD, app_name))
+
+ data = {}
+
+ if full_info_lines:
+ re_attrs = r'\w+' if not attrs else '|'.join(attrs)
+ info_map = re.findall(r'({}):\s+(.+)'.format(re_attrs), full_info_lines)
+
+ for info in info_map:
+ data[info[0]] = info[1].strip()
+
+ if not attrs or 'description' in attrs:
+ desc = re.findall(r'\|\n+((\s+.+\n+)+)', full_info_lines)
+ data['description'] = ''.join([w.strip() for w in desc[0][0].strip().split('\n')]).replace('.', '.\n') if desc else None
+
+ if not attrs or 'commands' in attrs:
+ commands = re.findall(r'commands:\s*\n*((\s+-\s.+\s*\n)+)', full_info_lines)
+ data['commands'] = commands[0][0].replace('-', '').strip().split('\n') if commands else None
+
+ return data
+
+
+def read_installed() -> List[dict]:
+ res = run_cmd('{} list'.format(BASE_CMD), print_error=False)
+
+ apps = []
+
+ if res and len(res) > 0:
+ lines = res.split('\n')
+
+ if not lines[0].startswith('error'):
+ for idx, app_str in enumerate(lines):
+ if idx > 0 and app_str:
+ apps.append(app_str_to_json(app_str))
+
+ return apps
+
+
+def search(word: str, exact_name: bool = False) -> List[dict]:
+ apps = []
+
+ res = run_cmd('{} find "{}"'.format(BASE_CMD, word), print_error=False)
+
+ if res:
+ res = res.split('\n')
+
+ if not res[0].startswith('No matching'):
+ for idx, app_str in enumerate(res):
+ if idx > 0 and app_str:
+ app_data = [word for word in app_str.split(' ') if word]
+
+ if exact_name and app_data[0] != word:
+ continue
+
+ apps.append({
+ 'name': app_data[0],
+ 'version': app_data[1],
+ 'publisher': app_data[2],
+ 'notes': app_data[3] if app_data[3] != '-' else None,
+ 'summary': app_data[4] if len(app_data) == 5 else '',
+ 'rev': None,
+ 'tracking': None,
+ 'type': None
+ })
+
+ if exact_name and len(apps) > 0:
+ break
+
+ return apps
+
+
+def uninstall_and_stream(app_name: str, root_password: str):
+ return new_root_subprocess([BASE_CMD, 'remove', app_name], root_password)
+
+
+def install_and_stream(app_name: str, confinement: str, root_password: str) -> subprocess.Popen:
+
+ install_cmd = [BASE_CMD, 'install', app_name] # default
+
+ if confinement == 'classic':
+ install_cmd.append('--classic')
+
+ return new_root_subprocess(install_cmd, root_password)
+
+
+def downgrade_and_stream(app_name: str, root_password: str) -> subprocess.Popen:
+ return new_root_subprocess([BASE_CMD, 'revert', app_name], root_password)
+
+
+def refresh_and_stream(app_name: str, root_password: str) -> subprocess.Popen:
+ return new_root_subprocess([BASE_CMD, 'refresh', app_name], root_password)
diff --git a/bauh/gems/snap/suggestions.py b/bauh/gems/snap/suggestions.py
new file mode 100644
index 00000000..f96e9c9d
--- /dev/null
+++ b/bauh/gems/snap/suggestions.py
@@ -0,0 +1,13 @@
+from bauh.api.abstract.model import SuggestionPriority
+
+ALL = {
+ 'whatsdesk': SuggestionPriority.TOP,
+ 'slack': SuggestionPriority.HIGH,
+ 'discord': SuggestionPriority.HIGH,
+ 'yakyak': SuggestionPriority.MEDIUM,
+ 'instagraph': SuggestionPriority.MEDIUM,
+ 'gimp': SuggestionPriority.MEDIUM,
+ 'pycharm-professional': SuggestionPriority.LOW,
+ 'eclipse': SuggestionPriority.LOW,
+ 'supertuxkart': SuggestionPriority.LOW
+}
\ No newline at end of file
diff --git a/bauh/gems/snap/worker.py b/bauh/gems/snap/worker.py
new file mode 100644
index 00000000..fce9e339
--- /dev/null
+++ b/bauh/gems/snap/worker.py
@@ -0,0 +1,71 @@
+import traceback
+from threading import Thread
+
+from bauh.api.abstract.cache import MemoryCache
+from bauh.api.abstract.context import ApplicationContext
+from bauh.api.abstract.controller import SoftwareManager
+from bauh.api.abstract.model import PackageStatus
+
+from bauh.gems.snap import snap
+from bauh.gems.snap.constants import SNAP_API_URL
+from bauh.gems.snap.model import SnapApplication
+
+
+class SnapAsyncDataLoader(Thread):
+
+ def __init__(self, app: SnapApplication, manager: SoftwareManager, api_cache: MemoryCache,
+ context: ApplicationContext):
+ super(SnapAsyncDataLoader, self).__init__(daemon=True)
+ self.app = app
+ self.id_ = '{}#{}'.format(self.__class__.__name__, id(self))
+ self.manager = manager
+ self.http_client = context.http_client
+ self.api_cache = api_cache
+ self.persist = False
+ self.download_icons = context.download_icons
+ self.logger = context.logger
+
+ def run(self):
+ if self.app:
+ self.app.status = PackageStatus.LOADING_DATA
+
+ try:
+ res = self.http_client.session.get('{}/search?q={}'.format(SNAP_API_URL, self.app.name))
+
+ if res:
+ try:
+ snap_list = res.json()['_embedded']['clickindex:package']
+ except:
+ self.logger.warning('Snap API response responded differently from expected for app: {}'.format(self.app.name))
+ return
+
+ if not snap_list:
+ self.logger.warning("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(self.app.id, res.status_code, res.content.decode()))
+ else:
+ snap_data = snap_list[0]
+
+ api_data = {
+ 'confinement': snap_data.get('confinement'),
+ 'description': snap_data.get('description'),
+ 'icon_url': snap_data.get('icon_url') if self.download_icons else None
+ }
+
+ self.api_cache.add(self.app.id, api_data)
+ self.app.confinement = api_data['confinement']
+ self.app.icon_url = api_data['icon_url']
+
+ if not api_data.get('description'):
+ api_data['description'] = snap.get_info(self.app.name, ('description',)).get('description')
+
+ self.app.description = api_data['description']
+ self.persist = self.app.supports_disk_cache()
+ else:
+ self.logger.warning("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(self.app.id, res.status_code, res.content.decode()))
+ except:
+ self.logger.error("Could not retrieve app data for id '{}'".format(self.app.id))
+ traceback.print_exc()
+
+ self.app.status = PackageStatus.READY
+
+ if self.persist:
+ self.manager.cache_to_disk(pkg=self.app, icon_bytes=None, only_icon=False)
diff --git a/bauh/util/cache.py b/bauh/util/cache.py
index 5683a1e6..7caffef9 100644
--- a/bauh/util/cache.py
+++ b/bauh/util/cache.py
@@ -2,7 +2,7 @@ import datetime
import time
from threading import Lock, Thread
-from bauh_api.abstract.cache import MemoryCache, MemoryCacheFactory
+from bauh.api.abstract.cache import MemoryCache, MemoryCacheFactory
class DefaultMemoryCache(MemoryCache):
diff --git a/bauh/util/disk.py b/bauh/util/disk.py
index 3bdb3a98..a25d386a 100644
--- a/bauh/util/disk.py
+++ b/bauh/util/disk.py
@@ -3,9 +3,9 @@ import os
from threading import Thread, Lock
from typing import Type, Dict
-from bauh_api.abstract.cache import MemoryCache
-from bauh_api.abstract.disk import DiskCacheLoader, DiskCacheLoaderFactory
-from bauh_api.abstract.model import SoftwarePackage
+from bauh.api.abstract.cache import MemoryCache
+from bauh.api.abstract.disk import DiskCacheLoader, DiskCacheLoaderFactory
+from bauh.api.abstract.model import SoftwarePackage
class AsyncDiskCacheLoader(Thread, DiskCacheLoader):
diff --git a/bauh/view/qt/apps_table.py b/bauh/view/qt/apps_table.py
index 056ff15b..b5831762 100644
--- a/bauh/view/qt/apps_table.py
+++ b/bauh/view/qt/apps_table.py
@@ -7,8 +7,8 @@ from PyQt5.QtGui import QPixmap, QIcon, QCursor
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \
QHeaderView, QLabel, QHBoxLayout, QPushButton, QToolBar
-from bauh_api.abstract.cache import MemoryCache
-from bauh_api.abstract.model import PackageStatus
+from bauh.api.abstract.cache import MemoryCache
+from bauh.api.abstract.model import PackageStatus
from bauh.util import resource
from bauh.util.html import strip_html
diff --git a/bauh/view/qt/components.py b/bauh/view/qt/components.py
index 01cbb498..143ce1d2 100644
--- a/bauh/view/qt/components.py
+++ b/bauh/view/qt/components.py
@@ -1,7 +1,10 @@
-from PyQt5.QtCore import QEvent, Qt
+from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QRadioButton, QGroupBox, QCheckBox, QComboBox, QGridLayout, QWidget, \
QLabel, QSizePolicy, QLineEdit
-from bauh_api.abstract.view import SingleSelectComponent, InputOption, MultipleSelectComponent, SelectViewType
+
+from bauh import ROOT_DIR
+from bauh.api.abstract.view import SingleSelectComponent, InputOption, MultipleSelectComponent, SelectViewType
+from bauh.commons import resource
class RadioButtonQt(QRadioButton):
@@ -19,19 +22,30 @@ class RadioButtonQt(QRadioButton):
class CheckboxQt(QCheckBox):
- def __init__(self, model: InputOption, model_parent: MultipleSelectComponent):
+ def __init__(self, model: InputOption, model_parent: MultipleSelectComponent, callback):
super(CheckboxQt, self).__init__()
self.model = model
self.model_parent = model_parent
self.stateChanged.connect(self._set_checked)
+ self.callback = callback
+ self.setText(model.label)
+ self.setToolTip(model.tooltip)
+
+ if model.icon_path:
+ self.setIcon(QIcon(model.icon_path))
def _set_checked(self, state):
- if state == 2:
+ checked = state == 2
+
+ if checked:
self.model_parent.values.add(self.model)
else:
if self.model in self.model_parent.values:
self.model_parent.values.remove(self.model)
+ if self.callback:
+ self.callback(self.model, checked)
+
class ComboBoxQt(QComboBox):
@@ -101,8 +115,8 @@ class ComboSelectQt(QGroupBox):
class MultipleSelectQt(QGroupBox):
- def __init__(self, model: MultipleSelectComponent):
- super(MultipleSelectQt, self).__init__(model.label + ' :' if model.label else None)
+ def __init__(self, model: MultipleSelectComponent, callback):
+ super(MultipleSelectQt, self).__init__(model.label if model.label else None)
self.setStyleSheet("QGroupBox { font-weight: bold }")
self.model = model
self._layout = QGridLayout()
@@ -110,9 +124,7 @@ class MultipleSelectQt(QGroupBox):
line, col = 0, 0
for op in model.options:
- comp = CheckboxQt(op, model)
- comp.setText(op.label)
- comp.setToolTip(op.tooltip)
+ comp = CheckboxQt(op, model, callback)
if model.values and op in model.values:
self.value = comp
diff --git a/bauh/view/qt/confirmation.py b/bauh/view/qt/confirmation.py
index 7edcbc13..91c9f036 100644
--- a/bauh/view/qt/confirmation.py
+++ b/bauh/view/qt/confirmation.py
@@ -1,7 +1,7 @@
from typing import List
from PyQt5.QtWidgets import QMessageBox, QVBoxLayout, QLabel, QWidget
-from bauh_api.abstract.view import ViewComponent, SingleSelectComponent, MultipleSelectComponent
+from bauh.api.abstract.view import ViewComponent, SingleSelectComponent, MultipleSelectComponent
from bauh.view.qt.components import MultipleSelectQt, new_single_select
@@ -29,7 +29,7 @@ class ConfirmationDialog(QMessageBox):
if isinstance(comp, SingleSelectComponent):
inst = new_single_select(comp)
elif isinstance(comp, MultipleSelectComponent):
- inst = MultipleSelectQt(comp)
+ inst = MultipleSelectQt(comp, None)
else:
raise Exception("Cannot render instances of " + comp.__class__.__name__)
diff --git a/bauh/view/qt/dialog.py b/bauh/view/qt/dialog.py
index 1f2f9e9f..00326ccc 100644
--- a/bauh/view/qt/dialog.py
+++ b/bauh/view/qt/dialog.py
@@ -2,7 +2,7 @@ from typing import List
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QMessageBox, QLabel, QWidget, QHBoxLayout
-from bauh_api.abstract.view import MessageType
+from bauh.api.abstract.view import MessageType
from bauh.util import resource
diff --git a/bauh/view/qt/gem_selector.py b/bauh/view/qt/gem_selector.py
new file mode 100644
index 00000000..d457856a
--- /dev/null
+++ b/bauh/view/qt/gem_selector.py
@@ -0,0 +1,69 @@
+import subprocess
+import sys
+from typing import List
+
+from PyQt5.QtCore import Qt, QCoreApplication
+from PyQt5.QtGui import QIcon
+from PyQt5.QtWidgets import QWidget, QLabel, QGridLayout, QPushButton
+
+from bauh import ROOT_DIR
+from bauh.api.abstract.controller import SoftwareManager
+from bauh.api.abstract.view import MultipleSelectComponent, InputOption
+from bauh.commons import resource
+from bauh.core.config import Configuration, save
+from bauh.view.qt.components import MultipleSelectQt, CheckboxQt, new_spacer
+
+
+class GemSelectorPanel(QWidget):
+
+ def __init__(self, managers: List[SoftwareManager], i18n: dict, boot: bool):
+ super(GemSelectorPanel, self).__init__()
+ self.managers = managers
+ self.setLayout(QGridLayout())
+ self.setWindowIcon(QIcon(resource.get_path('img/logo.svg', ROOT_DIR)))
+ self.setWindowTitle('Welcome' if boot else 'Supported types')
+ self.resize(400, 400)
+
+ self.label_question = QLabel('What types of applications do you want to find here ?')
+ self.label_question.setStyleSheet('QLabel { font-weight: bold}')
+ self.layout().addWidget(self.label_question, 0, 1, Qt.AlignHCenter)
+
+ self.bt_proceed = QPushButton('Proceed')
+ self.bt_proceed.setStyleSheet('QPushButton { background: green; color: white; font-weight: bold}')
+ self.bt_proceed.setEnabled(True)
+ self.bt_proceed.clicked.connect(self.save)
+
+ self.bt_exit = QPushButton('Exit')
+ self.bt_exit.setStyleSheet('QPushButton { background: red; color: white; font-weight: bold}')
+ self.bt_exit.clicked.connect(lambda: QCoreApplication.exit())
+
+ gem_options = []
+
+ for m in managers:
+ modname = m.__module__.split('.')[-2]
+ gem_options.append(InputOption(label=i18n.get('gem.{}.label'.format(modname), modname.capitalize()),
+ value=modname,
+ icon_path='{r}/gems/{n}/resources/img/{n}.png'.format(r=ROOT_DIR, n=modname)))
+
+ self.gem_select_model = MultipleSelectComponent(label='', options=gem_options, default_options=set(gem_options), max_per_line=3)
+
+ self.gem_select = MultipleSelectQt(self.gem_select_model, self.check_state)
+ self.layout().addWidget(self.gem_select, 1, 1)
+
+ self.layout().addWidget(new_spacer(), 2, 1)
+
+ self.layout().addWidget(self.bt_proceed, 3, 1, Qt.AlignRight)
+ self.layout().addWidget(self.bt_exit, 3, 1, Qt.AlignLeft)
+
+ self.adjustSize()
+ self.setFixedSize(self.size())
+
+ def check_state(self, model: CheckboxQt, checked: bool):
+ self.bt_proceed.setEnabled(bool(self.gem_select_model.values))
+
+ def save(self):
+ config = Configuration(gems=[o.value for o in self.gem_select_model.values])
+ save(config)
+ subprocess.Popen([sys.executable, *sys.argv])
+ self.close()
+
diff --git a/bauh/view/qt/history.py b/bauh/view/qt/history.py
index 35de496f..504bfa90 100644
--- a/bauh/view/qt/history.py
+++ b/bauh/view/qt/history.py
@@ -4,8 +4,8 @@ from functools import reduce
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QColor
from PyQt5.QtWidgets import QDialog, QVBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView
-from bauh_api.abstract.cache import MemoryCache
-from bauh_api.abstract.model import PackageHistory
+from bauh.api.abstract.cache import MemoryCache
+from bauh.api.abstract.model import PackageHistory
class HistoryDialog(QDialog):
diff --git a/bauh/view/qt/info.py b/bauh/view/qt/info.py
index 4459f539..1f8adf16 100644
--- a/bauh/view/qt/info.py
+++ b/bauh/view/qt/info.py
@@ -2,7 +2,7 @@ from PyQt5.QtCore import QSize
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QDialog, QVBoxLayout, QGroupBox, \
QLineEdit, QLabel, QGridLayout, QPushButton, QPlainTextEdit, QToolBar
-from bauh_api.abstract.cache import MemoryCache
+from bauh.api.abstract.cache import MemoryCache
from bauh.util.html import strip_html
diff --git a/bauh/view/qt/root.py b/bauh/view/qt/root.py
index bf05c7c9..3dffb749 100644
--- a/bauh/view/qt/root.py
+++ b/bauh/view/qt/root.py
@@ -3,7 +3,7 @@ import os
import subprocess
from PyQt5.QtWidgets import QInputDialog, QLineEdit
-from bauh_api.abstract.view import MessageType
+from bauh.api.abstract.view import MessageType
from bauh.view.qt.dialog import show_message
diff --git a/bauh/view/qt/systray.py b/bauh/view/qt/systray.py
index 979d2968..72f3e967 100755
--- a/bauh/view/qt/systray.py
+++ b/bauh/view/qt/systray.py
@@ -5,10 +5,10 @@ from typing import List
from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication, Qt
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QSystemTrayIcon, QMenu
-from bauh_api.abstract.model import PackageUpdate
+from bauh.api.abstract.model import PackageUpdate
from bauh import __app_name__
-from bauh_api.abstract.controller import SoftwareManager
+from bauh.api.abstract.controller import SoftwareManager
from bauh.util import util, resource
from bauh.view.qt.about import AboutDialog
from bauh.view.qt.window import ManageWindow
diff --git a/bauh/view/qt/thread.py b/bauh/view/qt/thread.py
index 6ed1485e..0480759b 100644
--- a/bauh/view/qt/thread.py
+++ b/bauh/view/qt/thread.py
@@ -5,12 +5,12 @@ from typing import List, Type, Set
import requests
from PyQt5.QtCore import QThread, pyqtSignal
-from bauh_api.abstract.cache import MemoryCache
-from bauh_api.abstract.controller import SoftwareManager
-from bauh_api.abstract.handler import ProcessWatcher
-from bauh_api.abstract.model import PackageStatus, SoftwarePackage, PackageAction
-from bauh_api.abstract.view import InputViewComponent, MessageType
-from bauh_api.exception import NoInternetException
+from bauh.api.abstract.cache import MemoryCache
+from bauh.api.abstract.controller import SoftwareManager
+from bauh.api.abstract.handler import ProcessWatcher
+from bauh.api.abstract.model import PackageStatus, SoftwarePackage, PackageAction
+from bauh.api.abstract.view import InputViewComponent, MessageType
+from bauh.api.exception import NoInternetException
from bauh.view.qt import commons
from bauh.view.qt.view_model import PackageView
diff --git a/bauh/view/qt/view_model.py b/bauh/view/qt/view_model.py
index 44ec9ec2..45c7dd0b 100644
--- a/bauh/view/qt/view_model.py
+++ b/bauh/view/qt/view_model.py
@@ -1,6 +1,6 @@
from enum import Enum
-from bauh_api.abstract.model import SoftwarePackage
+from bauh.api.abstract.model import SoftwarePackage
class PackageViewStatus(Enum):
diff --git a/bauh/view/qt/window.py b/bauh/view/qt/window.py
index 3383b499..d9f8b8fc 100755
--- a/bauh/view/qt/window.py
+++ b/bauh/view/qt/window.py
@@ -6,10 +6,10 @@ from PyQt5.QtCore import QEvent, Qt, QSize, pyqtSignal
from PyQt5.QtGui import QIcon, QWindowStateChangeEvent, QPixmap
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \
QLabel, QPlainTextEdit, QLineEdit, QProgressBar, QPushButton, QComboBox
-from bauh_api.abstract.cache import MemoryCache
-from bauh_api.abstract.controller import SoftwareManager
-from bauh_api.abstract.model import SoftwarePackage, PackageAction
-from bauh_api.abstract.view import MessageType
+from bauh.api.abstract.cache import MemoryCache
+from bauh.api.abstract.controller import SoftwareManager
+from bauh.api.abstract.model import SoftwarePackage, PackageAction
+from bauh.api.abstract.view import MessageType
from bauh.util import util, resource
from bauh.view.qt import dialog, commons
diff --git a/requirements.txt b/requirements.txt
index 1a1074da..d7d761f6 100755
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
pyqt5>=5.12
-bauh_api>=0.1.0<0.2
\ No newline at end of file
+requests>=2.22
+colorama>=0.4.1