From c66bd4d900f89000c4af3b48d5da459ac000976b Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 16 Jun 2020 18:51:13 -0300 Subject: [PATCH] [improvement][appimage] new initialization task that checks if the installed AppImage files have symlinks associated with --- CHANGELOG.md | 6 +- bauh/gems/appimage/controller.py | 73 ++----------- bauh/gems/appimage/resources/locale/ca | 1 + bauh/gems/appimage/resources/locale/de | 1 + bauh/gems/appimage/resources/locale/en | 1 + bauh/gems/appimage/resources/locale/es | 1 + bauh/gems/appimage/resources/locale/it | 1 + bauh/gems/appimage/resources/locale/pt | 1 + bauh/gems/appimage/resources/locale/ru | 1 + bauh/gems/appimage/resources/locale/tr | 1 + bauh/gems/appimage/util.py | 8 ++ bauh/gems/appimage/worker.py | 120 ++++++++++++++++++++- pictures/releases/0.9.6/appim_symlinks.png | Bin 0 -> 12104 bytes 13 files changed, 147 insertions(+), 68 deletions(-) create mode 100644 bauh/gems/appimage/util.py create mode 100644 pictures/releases/0.9.6/appim_symlinks.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 00636e09..5cc4fe56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [0.9.6] 2020 +## [0.9.6] 2020-06 ### Improvements - AppImage - creating a symlink for the installed applications at **~.local/bin** (the link will have the same name of the application. if the link already exists, it will be named as 'app_name-appimage') [#122](https://github.com/vinifmor/bauh/issues/122) + - new initialization task that checks if the installed AppImage files have symlinks associated with (it creates new symlinks if they don't) +

+ +

- able to update AppImages with continuous releases - UI - not performing a full table refresh after installing and uninstalling packages diff --git a/bauh/gems/appimage/controller.py b/bauh/gems/appimage/controller.py index 74bd401f..18bac25d 100644 --- a/bauh/gems/appimage/controller.py +++ b/bauh/gems/appimage/controller.py @@ -29,10 +29,10 @@ from bauh.commons.config import save_config from bauh.commons.html import bold from bauh.commons.system import SystemProcess, new_subprocess, ProcessHandler, run_cmd, SimpleProcess from bauh.gems.appimage import query, INSTALLATION_PATH, LOCAL_PATH, SUGGESTIONS_FILE, CONFIG_FILE, ROOT_DIR, \ - CONFIG_DIR, UPDATES_IGNORED_FILE, SYMLINKS_DIR + CONFIG_DIR, UPDATES_IGNORED_FILE, util from bauh.gems.appimage.config import read_config from bauh.gems.appimage.model import AppImage -from bauh.gems.appimage.worker import DatabaseUpdater +from bauh.gems.appimage.worker import DatabaseUpdater, SymlinksVerifier DB_APPS_PATH = '{}/{}'.format(str(Path.home()), '.local/share/bauh/appimage/apps.db') DB_RELEASES_PATH = '{}/{}'.format(str(Path.home()), '.local/share/bauh/appimage/releases.db') @@ -50,7 +50,7 @@ class AppImageManager(SoftwareManager): super(AppImageManager, self).__init__(context=context) self.i18n = context.i18n self.api_cache = context.cache_factory.new() - context.disk_loader_factory.map(AppImageManager, self.api_cache) + context.disk_loader_factory.map(AppImage, self.api_cache) self.enabled = True self.http_client = context.http_client self.logger = context.logger @@ -413,12 +413,6 @@ class AppImageManager(SoftwareManager): if f.endswith('.desktop'): return f - def _find_appimage_file(self, folder: str) -> str: - for r, d, files in os.walk(folder): - for f in files: - if f.lower().endswith('.appimage'): - return '{}/{}'.format(folder, f) - def _find_icon_file(self, folder: str) -> str: for r, d, files in os.walk(folder): for f in files: @@ -524,8 +518,7 @@ class AppImageManager(SoftwareManager): except: traceback.print_exc() - self._add_symlink(pkg, file_path, watcher) - + SymlinksVerifier.create_symlink(app=pkg, file_path=file_path, logger=self.logger, watcher=watcher) return TransactionResult(success=True, installed=[pkg], removed=[]) else: watcher.show_message(title=self.i18n['error'], @@ -539,59 +532,6 @@ class AppImageManager(SoftwareManager): handler.handle(SystemProcess(new_subprocess(['rm', '-rf', out_dir]))) return TransactionResult.fail() - def _add_symlink(self, app: AppImage, file_path: str, watcher: ProcessWatcher = None): - possible_names = (app.name.lower(), '{}-appimage'.format(app.name.lower())) - - if os.path.exists(SYMLINKS_DIR) and not os.path.isdir(SYMLINKS_DIR): - self.logger.warning("'{}' is not a directory. It will not be possible to create a symlink for '{}'".format(SYMLINKS_DIR, app.name)) - return - - available_system_dirs = (SYMLINKS_DIR, *(l for l in ('/usr/bin', '/usr/local/bin') if os.path.isdir(l))) - - # checking if the link already exists: - - available_name = None - for name in possible_names: - available_name = name - for sysdir in available_system_dirs: - if os.path.exists('{}/{}'.format(sysdir, name)): - available_name = None - break - - if available_name: - break - - if not available_name: - msg = "It was not possible to create a symlink for '{}' because the names {} are already available on the system".format(app.name, - possible_names) - self.logger.warning(msg) - if watcher: - watcher.print('[warning] {}'.format(msg)) - else: - try: - Path(SYMLINKS_DIR).mkdir(parents=True, exist_ok=True) - except: - self.logger.error("Could not create symlink directory '{}'".format(SYMLINKS_DIR)) - return - - symlink_path = '{}/{}'.format(SYMLINKS_DIR, available_name) - - try: - os.symlink(src=file_path, dst=symlink_path) - app.symlink = symlink_path - - msg = "symlink successfully created at {}".format(symlink_path) - self.logger.info(msg) - - if watcher: - watcher.print(msg) - except: - msg = "Could not create the symlink '{}'".format(symlink_path) - self.logger.error(msg) - - if watcher: - watcher.print('[error] {}'.format(msg)) - def _gen_desktop_entry_path(self, app: AppImage) -> str: return '{}/bauh_appimage_{}.desktop'.format(DESKTOP_ENTRIES_PATH, app.name.lower()) @@ -624,6 +564,9 @@ class AppImageManager(SoftwareManager): elif internet_available: updater.download_databases() # only once + symlink_check = SymlinksVerifier(taskman=task_manager, i18n=self.i18n, logger=self.logger) + symlink_check.start() + def list_updates(self, internet_available: bool) -> List[PackageUpdate]: res = self.read_installed(disk_loader=None, internet_available=internet_available) @@ -692,7 +635,7 @@ class AppImageManager(SoftwareManager): def launch(self, pkg: AppImage): installation_dir = pkg.get_disk_cache_path() if os.path.exists(installation_dir): - appimag_path = self._find_appimage_file(installation_dir) + appimag_path = util.find_appimage_file(installation_dir) if appimag_path: subprocess.Popen([appimag_path]) diff --git a/bauh/gems/appimage/resources/locale/ca b/bauh/gems/appimage/resources/locale/ca index dd4377eb..bfb71078 100644 --- a/bauh/gems/appimage/resources/locale/ca +++ b/bauh/gems/appimage/resources/locale/ca @@ -32,6 +32,7 @@ appimage.install.extract=S’està extraient el contingut de {} appimage.install.imported.rename_error=It was not possible to move the file {} to {} appimage.install.permission=S’està concedint el permís d’execució a {} appimage.task.db_update=Actualització de bases de dades +appimage.task.symlink_check=Checking symlinks appimage.uninstall.error.remove_folder=No s’ha pogut suprimir el directori d’instal·lació de l’aplicació {} gem.appimage.info=Aplicacions publicades a https://appimage.github.io/ gem.appimage.label=AppImage \ No newline at end of file diff --git a/bauh/gems/appimage/resources/locale/de b/bauh/gems/appimage/resources/locale/de index 0872e55d..e7a36881 100644 --- a/bauh/gems/appimage/resources/locale/de +++ b/bauh/gems/appimage/resources/locale/de @@ -32,6 +32,7 @@ appimage.install.extract=Entpacke Inhalt von {} appimage.install.imported.rename_error=It was not possible to move the file {} to {} appimage.install.permission=Ausführberechtigungen für {} appimage.task.db_update=Updating databases +appimage.task.symlink_check=Checking symlinks appimage.uninstall.error.remove_folder=Anwendungordner {} konnte nicht entfernt werden gem.appimage.info=Anwendungen veröffentlicht auf https://appimage.github.io/ gem.appimage.label=AppImage \ No newline at end of file diff --git a/bauh/gems/appimage/resources/locale/en b/bauh/gems/appimage/resources/locale/en index 7dc5af3d..5394cf31 100644 --- a/bauh/gems/appimage/resources/locale/en +++ b/bauh/gems/appimage/resources/locale/en @@ -32,6 +32,7 @@ appimage.install.extract=Extracting the content from {} appimage.install.imported.rename_error=It was not possible to move the file {} to {} appimage.install.permission=Giving execution permission to {} appimage.task.db_update=Updating databases +appimage.task.symlink_check=Checking symlinks appimage.uninstall.error.remove_folder=Could not remove the application installation directory {} gem.appimage.info=Applications published at https://appimage.github.io/ gem.appimage.label=AppImage \ No newline at end of file diff --git a/bauh/gems/appimage/resources/locale/es b/bauh/gems/appimage/resources/locale/es index 34ba9f13..47adc1b8 100644 --- a/bauh/gems/appimage/resources/locale/es +++ b/bauh/gems/appimage/resources/locale/es @@ -32,6 +32,7 @@ appimage.install.extract=Extrayendo el contenido de {} appimage.install.imported.rename_error=No fue posible mover el archivo {} a {} appimage.install.permission=Concediendo permiso de ejecución a {} appimage.task.db_update=Actualizando base de datos +appimage.task.symlink_check=Verificando links simbólicos appimage.uninstall.error.remove_folder=No se pudo eliminar el directorio de instalación de la aplicación {} gem.appimage.info=Aplicativos publicados en https://appimage.github.io/ gem.appimage.label=AppImage \ No newline at end of file diff --git a/bauh/gems/appimage/resources/locale/it b/bauh/gems/appimage/resources/locale/it index 491cb3e2..df393fef 100644 --- a/bauh/gems/appimage/resources/locale/it +++ b/bauh/gems/appimage/resources/locale/it @@ -32,6 +32,7 @@ appimage.install.extract=Estrarre il contenuto da {} appimage.install.imported.rename_error=It was not possible to move the file {} to {} appimage.install.permission=Gdare il permesso di esecuzione a {} appimage.task.db_update=Aggiornamento dei database +appimage.task.symlink_check=Checking symlinks appimage.uninstall.error.remove_folder=Impossibile rimuovere la directory di installazione dell'applicazione {} gem.appimage.info=Applicazioni pubblicate su https://appimage.github.io/ gem.appimage.label=AppImage \ No newline at end of file diff --git a/bauh/gems/appimage/resources/locale/pt b/bauh/gems/appimage/resources/locale/pt index 1b5cb021..be125a45 100644 --- a/bauh/gems/appimage/resources/locale/pt +++ b/bauh/gems/appimage/resources/locale/pt @@ -32,6 +32,7 @@ appimage.install.extract=Extraindo o conteúdo de {} appimage.install.imported.rename_error=Não foi possível mover o arquivo {} para {} appimage.install.permission=Concedendo permissão de execução para {} appimage.task.db_update=Atualizando base de dados +appimage.task.symlink_check=Verificando links simbólicos appimage.uninstall.error.remove_folder=Não foi possível remover o diretóri ode instalação do aplicativo {} gem.appimage.info=Aplicativos publicados em https://appimage.github.io/ gem.appimage.label=AppImage \ No newline at end of file diff --git a/bauh/gems/appimage/resources/locale/ru b/bauh/gems/appimage/resources/locale/ru index 4649bfe8..eaaac1f5 100644 --- a/bauh/gems/appimage/resources/locale/ru +++ b/bauh/gems/appimage/resources/locale/ru @@ -32,6 +32,7 @@ appimage.install.extract=Извлечь содержимое из {} appimage.install.imported.rename_error=Не удалось переместить файл {} в {} appimage.install.permission=Установить права на выполнение для {} appimage.task.db_update=Обновление базы данных +appimage.task.symlink_check=Checking symlinks appimage.uninstall.error.remove_folder=Не удалось удалить каталог приложения {} gem.appimage.info=Приложения, опубликованные на https://appimage.github.io/ gem.appimage.label=AppImage diff --git a/bauh/gems/appimage/resources/locale/tr b/bauh/gems/appimage/resources/locale/tr index d92b9e88..6dd8e0cb 100644 --- a/bauh/gems/appimage/resources/locale/tr +++ b/bauh/gems/appimage/resources/locale/tr @@ -32,6 +32,7 @@ appimage.install.extract={} 'den içerik ayıklanıyor appimage.install.imported.rename_error={} dosyasını {} klasörüne taşımak mümkün değildi appimage.install.permission={} için yürütme izni veriliyor appimage.task.db_update=Veritabanları güncelleniyor +appimage.task.symlink_check=Checking symlinks appimage.uninstall.error.remove_folder={} uygulama kurulum dizini kaldırılamadı gem.appimage.info=https://appimage.github.io/ adresinde yayınlanan uygulamalar gem.appimage.label=AppImage diff --git a/bauh/gems/appimage/util.py b/bauh/gems/appimage/util.py new file mode 100644 index 00000000..c717f5b4 --- /dev/null +++ b/bauh/gems/appimage/util.py @@ -0,0 +1,8 @@ +import os + + +def find_appimage_file(folder: str) -> str: + for r, d, files in os.walk(folder): + for f in files: + if f.lower().endswith('.appimage'): + return '{}/{}'.format(folder, f) diff --git a/bauh/gems/appimage/worker.py b/bauh/gems/appimage/worker.py index 5e494d00..7ffe047f 100644 --- a/bauh/gems/appimage/worker.py +++ b/bauh/gems/appimage/worker.py @@ -1,4 +1,5 @@ import glob +import json import logging import os import tarfile @@ -9,10 +10,11 @@ from threading import Thread import requests -from bauh.api.abstract.handler import TaskManager +from bauh.api.abstract.handler import TaskManager, ProcessWatcher from bauh.api.http import HttpClient from bauh.commons import internet -from bauh.gems.appimage import LOCAL_PATH, get_icon_path +from bauh.gems.appimage import LOCAL_PATH, get_icon_path, INSTALLATION_PATH, SYMLINKS_DIR, util +from bauh.gems.appimage.model import AppImage from bauh.view.util.translation import I18n @@ -102,3 +104,117 @@ class DatabaseUpdater(Thread): self.download_databases() self.logger.info('Sleeping') time.sleep(self.sleep) + + +class SymlinksVerifier(Thread): + + def __init__(self, taskman: TaskManager, i18n: I18n, logger: logging.Logger): + super(SymlinksVerifier, self).__init__(daemon=True) + self.taskman = taskman + self.i18n = i18n + self.logger = logger + self.task_id = 'appim_symlink_check' + + @staticmethod + def create_symlink(app: AppImage, file_path: str, logger: logging.Logger, watcher: ProcessWatcher = None): + logger.info("Creating a symlink for '{}'".format(app.name)) + possible_names = (app.name.lower(), '{}-appimage'.format(app.name.lower())) + + if os.path.exists(SYMLINKS_DIR) and not os.path.isdir(SYMLINKS_DIR): + logger.warning("'{}' is not a directory. It will not be possible to create a symlink for '{}'".format(SYMLINKS_DIR, app.name)) + return + + available_system_dirs = (SYMLINKS_DIR, *(l for l in ('/usr/bin', '/usr/local/bin') if os.path.isdir(l))) + + # checking if the link already exists: + + available_name = None + for name in possible_names: + available_name = name + for sysdir in available_system_dirs: + if os.path.exists('{}/{}'.format(sysdir, name)): + available_name = None + break + + if available_name: + break + + if not available_name: + msg = "It was not possible to create a symlink for '{}' because the names {} are already available on the system".format(app.name, + possible_names) + logger.warning(msg) + if watcher: + watcher.print('[warning] {}'.format(msg)) + else: + try: + Path(SYMLINKS_DIR).mkdir(parents=True, exist_ok=True) + except: + logger.error("Could not create symlink directory '{}'".format(SYMLINKS_DIR)) + return + + symlink_path = '{}/{}'.format(SYMLINKS_DIR, available_name) + + try: + os.symlink(src=file_path, dst=symlink_path) + app.symlink = symlink_path + + msg = "symlink successfully created at {}".format(symlink_path) + logger.info(msg) + + if watcher: + watcher.print(msg) + except: + msg = "Could not create the symlink '{}'".format(symlink_path) + logger.error(msg) + + if watcher: + watcher.print('[error] {}'.format(msg)) + + def run(self): + self.taskman.register_task(self.task_id, self.i18n['appimage.task.symlink_check'], get_icon_path()) + + if os.path.exists(INSTALLATION_PATH): + installed_files = glob.glob('{}/*/*.json'.format(INSTALLATION_PATH)) + + if installed_files: + self.logger.info("Checking installed AppImage files with no symlinks created") + + progress_per_file = (1/len(installed_files)) * 100 + total_progress = 0 + for json_file in installed_files: + with open(json_file) as f: + try: + data = json.loads(f.read()) + except: + self.logger.warning("Could not parse data from '{}'".format(json_file)) + data = None + + if data and not data.get('symlink'): + app = AppImage(**data, i18n=self.i18n) + file_path = util.find_appimage_file(app.install_dir) + + if file_path: + self.create_symlink(app, file_path, self.logger) + data['symlink'] = app.symlink + + # caching + try: + with open(json_file, 'w+') as f: + f.write(json.dumps(data)) + except: + self.logger.warning("Could not update cached data on '{}'".format(json_file)) + traceback.print_exc() + + else: + self.logger.warning("No AppImage file found on installation dir '{}'".format(file_path)) + + total_progress += progress_per_file + self.taskman.update_progress(self.task_id, total_progress, '') + + self.taskman.update_progress(self.task_id, 100, '') + self.taskman.finish_task(self.task_id) + return + + self.logger.info("No AppImage applications found. Aborting") + self.taskman.update_progress(self.task_id, 100, '') + self.taskman.finish_task(self.task_id) diff --git a/pictures/releases/0.9.6/appim_symlinks.png b/pictures/releases/0.9.6/appim_symlinks.png new file mode 100644 index 0000000000000000000000000000000000000000..e9b6a710c9f407c9d7b00504a798a9c15ceaba4d GIT binary patch literal 12104 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 literal 0 HcmV?d00001