import re import time from datetime import datetime from threading import Thread from typing import List, Set, Type from bauh.api.abstract.controller import SoftwareManager, SearchResult, ApplicationContext, UpgradeRequirements, \ TransactionResult from bauh.api.abstract.disk import DiskCacheLoader from bauh.api.abstract.handler import ProcessWatcher, TaskManager from bauh.api.abstract.model import SoftwarePackage, PackageHistory, PackageUpdate, PackageSuggestion, \ SuggestionPriority, CustomSoftwareAction from bauh.api.abstract.view import SingleSelectComponent, SelectViewType, InputOption from bauh.commons import resource from bauh.commons.category import CategoriesDownloader from bauh.commons.html import bold from bauh.commons.system import SystemProcess, ProcessHandler, new_root_subprocess from bauh.gems.snap import snap, URL_CATEGORIES_FILE, SNAP_CACHE_PATH, CATEGORIES_FILE_PATH, SUGGESTIONS_FILE, \ get_icon_path from bauh.gems.snap.constants import SNAP_API_URL from bauh.gems.snap.model import SnapApplication from bauh.gems.snap.worker import SnapAsyncDataLoader RE_AVAILABLE_CHANNELS = re.compile(re.compile(r'(\w+)\s+(snap install.+)')) 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) self.enabled = True self.http_client = context.http_client self.logger = context.logger self.ubuntu_distro = context.distro == 'ubuntu' self.categories = {} self.suggestions_cache = context.cache_factory.new() self.info_path = None self.custom_actions = [ CustomSoftwareAction(i18n_status_key='snap.action.refresh.status', i18_label_key='snap.action.refresh.label', icon_path=resource.get_path('img/refresh.svg', context.get_view_path()), manager_method='refresh', requires_root=True) ] def get_info_path(self) -> str: if self.info_path is None: self.info_path = snap.get_app_info_path() return self.info_path def map_json(self, app_json: dict, installed: bool, disk_loader: DiskCacheLoader, internet: bool = True) -> SnapApplication: app = SnapApplication(publisher=app_json.get('publisher'), rev=app_json.get('rev'), notes=app_json.get('notes'), has_apps_field=app_json.get('apps_field', False), 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', app_json.get('summary')), verified_publisher=app_json.get('developer_validation', '') == 'verified', extra_actions=self.custom_actions) if app.publisher and app.publisher.endswith('*'): app.verified_publisher = True app.publisher = app.publisher.replace('*', '') categories = self.categories.get(app.name.lower()) if categories: app.categories = categories app.installed = installed if not app.is_application(): categories = app.categories if categories is None: categories = [] app.categories = categories if 'runtime' not in categories: categories.append('runtime') 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) if internet: 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, is_url: bool = False) -> SearchResult: if is_url: return SearchResult([], [], 0) if snap.is_snapd_running(): 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 else: return SearchResult([], [], 0) def read_installed(self, disk_loader: DiskCacheLoader, limit: int = -1, only_apps: bool = False, pkg_types: Set[Type[SoftwarePackage]] = None, internet_available: bool = None) -> SearchResult: info_path = self.get_info_path() if snap.is_snapd_running() and info_path: installed = [self.map_json(app_json, installed=True, disk_loader=disk_loader, internet=internet_available) for app_json in snap.read_installed(info_path)] return SearchResult(installed, None, len(installed)) else: return SearchResult([], None, 0) 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 upgrade(self, requirements: UpgradeRequirements, root_password: str, watcher: ProcessWatcher) -> SystemProcess: raise Exception("'upgrade' is not supported by {}".format(SnapManager.__class__.__name__)) def uninstall(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool: uninstalled = ProcessHandler(watcher).handle(SystemProcess(subproc=snap.uninstall_and_stream(pkg.name, root_password))) if self.suggestions_cache: self.suggestions_cache.delete(pkg.name) return uninstalled 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']) if info.get('license') and info['license'] == 'unset': del info['license'] 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, disk_loader: DiskCacheLoader, watcher: ProcessWatcher) -> TransactionResult: info_path = self.get_info_path() if not info_path: self.logger.warning('Information directory was not found. It will not be possible to determine if the installed application can be launched') res, output = ProcessHandler(watcher).handle_simple(snap.install_and_stream(pkg.name, pkg.confinement, root_password)) if 'error:' in output: res = False if 'not available on stable' in output: channels = RE_AVAILABLE_CHANNELS.findall(output) if channels: opts = [InputOption(label=c[0], value=c[1]) for c in channels] channel_select = SingleSelectComponent(type_=SelectViewType.RADIO, label='', options=opts, default_option=opts[0]) body = '

{}.

'.format(self.i18n['snap.install.available_channels.message'].format(bold(self.i18n['stable']), bold(pkg.name))) body += '

{}:

'.format(self.i18n['snap.install.available_channels.help']) if watcher.request_confirmation(title=self.i18n['snap.install.available_channels.title'], body=body, components=[channel_select], confirmation_label=self.i18n['continue'], deny_label=self.i18n['cancel']): self.logger.info("Installing '{}' with the custom command '{}'".format(pkg.name, channel_select.value)) res = ProcessHandler(watcher).handle(SystemProcess(new_root_subprocess(channel_select.value.value.split(' '), root_password=root_password))) if res and info_path: pkg.has_apps_field = snap.has_apps_field(pkg.name, info_path) pkg.installed = res return TransactionResult(success=res, installed=[pkg] if res else [], removed=[]) else: self.logger.error("Could not find available channels in the installation output: {}".format(output)) else: if info_path: pkg.has_apps_field = snap.has_apps_field(pkg.name, info_path) pkg.installed = res return TransactionResult(success=res, installed=[pkg] if res else [], removed=[]) def is_enabled(self) -> bool: return self.enabled def set_enabled(self, enabled: bool): self.enabled = enabled def can_work(self) -> bool: return snap.is_installed() def requires_root(self, action: str, pkg: SnapApplication): return action not in ('search', 'prepare') 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 _start_category_task(self, task_man: TaskManager): if task_man: task_man.register_task('snap_cats', self.i18n['task.download_categories'].format('Snap'), get_icon_path()) task_man.update_progress('snap_cats', 50, None) def _finish_category_task(self, task_man: TaskManager): if task_man: task_man.update_progress('snap_cats', 100, None) task_man.finish_task('snap_cats') def prepare(self, task_manager: TaskManager, root_password: str, internet_available: bool): CategoriesDownloader(id_='snap', manager=self, http_client=self.http_client, logger=self.logger, url_categories_file=URL_CATEGORIES_FILE, disk_cache_dir=SNAP_CACHE_PATH, categories_path=CATEGORIES_FILE_PATH, before=lambda: self._start_category_task(task_manager), after=lambda: self._finish_category_task(task_manager)).start() def list_updates(self, internet_available: bool) -> List[PackageUpdate]: pass def list_warnings(self, internet_available: bool) -> List[str]: if snap.is_installed(): if not snap.is_snapd_running(): snap_bold = bold('Snap') return [self.i18n['snap.notification.snapd_unavailable'].format(bold('snapd'), snap_bold), self.i18n['snap.notification.snap.disable'].format(snap_bold, bold('{} > {}'.format(self.i18n['settings'].capitalize(), self.i18n['core.config.tab.types'])))] elif internet_available: available, output = snap.is_api_available() if not available: self.logger.warning('It seems Snap API is not available. Search output: {}'.format(output)) return [self.i18n['snap.notifications.api.unavailable'].format(bold('Snaps'), bold('Snap'))] def _fill_suggestion(self, pkg_name: str, priority: SuggestionPriority, out: List[PackageSuggestion]): res = self.http_client.get_json(SNAP_API_URL + '/search?q=package_name:{}'.format(pkg_name)) if res and res['_embedded']['clickindex:package']: pkg = res['_embedded']['clickindex:package'][0] pkg['rev'] = pkg['revision'] pkg['name'] = pkg_name sug = PackageSuggestion(self.map_json(pkg, installed=False, disk_loader=None), priority) self.suggestions_cache.add(pkg_name, sug) out.append(sug) else: self.logger.warning("Could not retrieve suggestion '{}'".format(pkg_name)) def list_suggestions(self, limit: int, filter_installed: bool) -> List[PackageSuggestion]: res = [] if snap.is_snapd_running(): self.logger.info('Downloading suggestions file {}'.format(SUGGESTIONS_FILE)) file = self.http_client.get(SUGGESTIONS_FILE) if not file or not file.text: self.logger.warning("No suggestion found in {}".format(SUGGESTIONS_FILE)) return res else: self.logger.info('Mapping suggestions') suggestions, threads = [], [] installed = {i.name.lower() for i in self.read_installed(disk_loader=None).installed} if filter_installed else None for l in file.text.split('\n'): if l: if limit <= 0 or len(suggestions) < limit: sug = l.strip().split('=') name = sug[1] if not installed or name not in installed: cached_sug = self.suggestions_cache.get(name) if cached_sug: res.append(cached_sug) else: t = Thread(target=self._fill_suggestion, args=(name, SuggestionPriority(int(sug[0])), res)) t.start() threads.append(t) time.sleep(0.001) # to avoid being blocked else: break for t in threads: t.join() res.sort(key=lambda s: s.priority.value, reverse=True) return res def is_default_enabled(self) -> bool: return True def launch(self, pkg: SnapApplication): snap.run(pkg, self.context.logger) def get_screenshots(self, pkg: SoftwarePackage) -> List[str]: res = self.http_client.get_json('{}/search?q={}'.format(SNAP_API_URL, pkg.name)) if res: if res.get('_embedded') and res['_embedded'].get('clickindex:package'): snap_data = res['_embedded']['clickindex:package'][0] if snap_data.get('screenshot_urls'): return snap_data['screenshot_urls'] else: self.logger.warning("No 'screenshots_urls' defined for {}".format(pkg)) else: self.logger.error('It seems the API is returning a different response: {}'.format(res)) else: self.logger.warning('Could not retrieve data for {}'.format(pkg)) return []