mirror of
https://github.com/spalencsar/bearhub.git
synced 2026-07-07 00:04:15 +02:00
939 lines
42 KiB
Python
939 lines
42 KiB
Python
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import traceback
|
|
from datetime import datetime
|
|
from math import floor
|
|
from pathlib import Path
|
|
from typing import Set, Type, List, Tuple, Optional, Iterable, Generator
|
|
|
|
from colorama import Fore
|
|
from packaging.version import parse as parse_version
|
|
|
|
from bauh.api.abstract.context import ApplicationContext
|
|
from bauh.api.abstract.controller import SoftwareManager, SearchResult, UpgradeRequirements, UpgradeRequirement, \
|
|
TransactionResult, SoftwareAction
|
|
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 MessageType, ViewComponent, FormComponent, InputOption, SingleSelectComponent, \
|
|
SelectViewType, TextInputComponent, PanelComponent, FileChooserComponent, ViewObserver
|
|
from bauh.api.paths import DESKTOP_ENTRIES_DIR
|
|
from bauh.commons import resource
|
|
from bauh.commons.boot import CreateConfigFile
|
|
from bauh.commons.html import bold
|
|
from bauh.commons.system import SystemProcess, new_subprocess, ProcessHandler, SimpleProcess
|
|
from bauh.gems.appimage import query, INSTALLATION_DIR, APPIMAGE_SHARED_DIR, ROOT_DIR, \
|
|
APPIMAGE_CONFIG_DIR, UPDATES_IGNORED_FILE, util, get_default_manual_installation_file_dir, DATABASE_APPS_FILE, \
|
|
DATABASE_RELEASES_FILE, APPIMAGE_CACHE_DIR, get_icon_path, DOWNLOAD_DIR
|
|
from bauh.gems.appimage.config import AppImageConfigManager
|
|
from bauh.gems.appimage.model import AppImage
|
|
from bauh.gems.appimage.util import replace_desktop_entry_exec_command
|
|
from bauh.gems.appimage.worker import DatabaseUpdater, SymlinksVerifier, AppImageSuggestionsDownloader
|
|
|
|
RE_DESKTOP_ICON = re.compile(r'Icon\s*=\s*.+\n')
|
|
RE_ICON_ENDS_WITH = re.compile(r'.+\.(png|svg)$')
|
|
RE_APPIMAGE_NAME = re.compile(r'(.+)\.appimage', flags=re.IGNORECASE)
|
|
|
|
|
|
class ManualInstallationFileObserver(ViewObserver):
|
|
|
|
def __init__(self, name: Optional[TextInputComponent], version: TextInputComponent):
|
|
self.name = name
|
|
self.version = version
|
|
|
|
def on_change(self, file_path: str):
|
|
if file_path:
|
|
name_found = RE_APPIMAGE_NAME.findall(file_path.split('/')[-1])
|
|
|
|
if name_found:
|
|
name_split = name_found[0].split('-')
|
|
|
|
if self.name:
|
|
self.name.set_value(name_split[0].strip())
|
|
|
|
if len(name_split) > 1:
|
|
self.version.set_value(name_split[1].strip())
|
|
else:
|
|
if self.name:
|
|
self.name.set_value(None)
|
|
|
|
self.version.set_value(None)
|
|
|
|
|
|
class AppImageManager(SoftwareManager):
|
|
|
|
def __init__(self, context: ApplicationContext):
|
|
super(AppImageManager, self).__init__(context=context)
|
|
self.i18n = context.i18n
|
|
self.api_cache = context.cache_factory.new()
|
|
context.disk_loader_factory.map(AppImage, self.api_cache)
|
|
self.enabled = True
|
|
self.http_client = context.http_client
|
|
self.logger = context.logger
|
|
self.file_downloader = context.file_downloader
|
|
self.configman = AppImageConfigManager()
|
|
self._custom_actions: Optional[Iterable[CustomSoftwareAction]] = None
|
|
self.custom_app_actions = [CustomSoftwareAction(i18n_label_key='appimage.custom_action.manual_update',
|
|
i18n_status_key='appimage.custom_action.manual_update.status',
|
|
manager_method='update_file',
|
|
requires_root=False,
|
|
icon_path=resource.get_path('img/upgrade.svg', ROOT_DIR))]
|
|
|
|
def install_file(self, root_password: str, watcher: ProcessWatcher) -> bool:
|
|
file_chooser = FileChooserComponent(label=self.i18n['file'].capitalize(),
|
|
allowed_extensions={'AppImage', '*'},
|
|
search_path=get_default_manual_installation_file_dir())
|
|
input_name = TextInputComponent(label=self.i18n['name'].capitalize())
|
|
input_version = TextInputComponent(label=self.i18n['version'].capitalize())
|
|
file_chooser.observers.append(ManualInstallationFileObserver(input_name, input_version))
|
|
|
|
input_description = TextInputComponent(label=self.i18n['description'].capitalize())
|
|
|
|
cat_ops = [InputOption(label=self.i18n['category.none'].capitalize(), value=0)]
|
|
cat_ops.extend([InputOption(label=self.i18n.get(f'category.{c.lower()}', c.lower()).capitalize(), value=c) for c in self.context.default_categories])
|
|
inp_cat = SingleSelectComponent(label=self.i18n['category'], type_=SelectViewType.COMBO, options=cat_ops,
|
|
default_option=cat_ops[0])
|
|
|
|
form = FormComponent(label='', components=[file_chooser, input_name, input_version, input_description, inp_cat], spaces=False)
|
|
|
|
while True:
|
|
if watcher.request_confirmation(title=self.i18n['appimage.custom_action.install_file.details'], body=None,
|
|
components=[form],
|
|
confirmation_label=self.i18n['proceed'].capitalize(),
|
|
deny_label=self.i18n['cancel'].capitalize()):
|
|
if not file_chooser.file_path or not os.path.isfile(file_chooser.file_path) or not file_chooser.file_path.lower().strip().endswith('.appimage'):
|
|
watcher.request_confirmation(title=self.i18n['error'].capitalize(),
|
|
body=self.i18n['appimage.custom_action.install_file.invalid_file'],
|
|
deny_button=False)
|
|
elif not input_name.get_value() or not input_name.get_value().strip():
|
|
watcher.request_confirmation(title=self.i18n['error'].capitalize(),
|
|
body=self.i18n['appimage.custom_action.install_file.invalid_name'],
|
|
deny_button=False)
|
|
else:
|
|
break
|
|
else:
|
|
return False
|
|
|
|
appim = AppImage(i18n=self.i18n, imported=True, custom_actions=self.custom_app_actions)
|
|
appim.name = input_name.get_value().strip()
|
|
appim.local_file_path = file_chooser.file_path
|
|
appim.version = input_version.get_value()
|
|
appim.latest_version = input_version.get_value()
|
|
appim.description = input_description.get_value()
|
|
appim.categories = ['Imported']
|
|
|
|
if inp_cat.get_selected() != cat_ops[0].value:
|
|
appim.categories.append(inp_cat.get_selected())
|
|
|
|
res = self.install(root_password=root_password, pkg=appim, disk_loader=None, watcher=watcher).success
|
|
|
|
if res:
|
|
appim.installed = True
|
|
self.cache_to_disk(appim, None, False)
|
|
|
|
return res
|
|
|
|
def update_file(self, pkg: AppImage, root_password: str, watcher: ProcessWatcher):
|
|
file_chooser = FileChooserComponent(label=self.i18n['file'].capitalize(),
|
|
allowed_extensions={'AppImage', '*'},
|
|
search_path=get_default_manual_installation_file_dir())
|
|
input_version = TextInputComponent(label=self.i18n['version'].capitalize())
|
|
file_chooser.observers.append(ManualInstallationFileObserver(None, input_version))
|
|
|
|
while True:
|
|
if watcher.request_confirmation(title=self.i18n['appimage.custom_action.manual_update.details'], body=None,
|
|
components=[FormComponent(label='', components=[file_chooser, input_version], spaces=False)],
|
|
confirmation_label=self.i18n['proceed'].capitalize(),
|
|
deny_label=self.i18n['cancel'].capitalize()):
|
|
|
|
if not file_chooser.file_path or not os.path.isfile(file_chooser.file_path) or not file_chooser.file_path.lower().strip().endswith('.appimage'):
|
|
watcher.request_confirmation(title=self.i18n['error'].capitalize(),
|
|
body=self.i18n['appimage.custom_action.install_file.invalid_file'],
|
|
deny_button=False)
|
|
else:
|
|
break
|
|
else:
|
|
return False
|
|
|
|
pkg.local_file_path = file_chooser.file_path
|
|
pkg.version = input_version.get_value()
|
|
|
|
reqs = UpgradeRequirements(to_install=None, to_remove=None, to_upgrade=[UpgradeRequirement(pkg=pkg)], cannot_upgrade=None)
|
|
return self.upgrade(reqs, root_password=root_password, watcher=watcher)
|
|
|
|
def _get_db_connection(self, db_path: str) -> sqlite3.Connection:
|
|
if os.path.exists(db_path):
|
|
try:
|
|
return sqlite3.connect(db_path)
|
|
except:
|
|
self.logger.error(f"Could not connect to database file '{db_path}'")
|
|
traceback.print_exc()
|
|
else:
|
|
self.logger.warning(f"Could not get a connection for database '{db_path}'")
|
|
|
|
def _gen_app_key(self, app: AppImage):
|
|
return f"{app.name.lower()}{app.github.lower() if app.github else ''}"
|
|
|
|
def search(self, words: str, disk_loader: DiskCacheLoader, limit: int = -1, is_url: bool = False) -> SearchResult:
|
|
if is_url:
|
|
return SearchResult.empty()
|
|
|
|
apps_conn = self._get_db_connection(DATABASE_APPS_FILE)
|
|
|
|
if not apps_conn:
|
|
return SearchResult.empty()
|
|
|
|
not_installed, found_map = [], {}
|
|
|
|
try:
|
|
cursor = apps_conn.cursor()
|
|
cursor.execute(query.SEARCH_APPS_BY_NAME_OR_DESCRIPTION.format(words, words))
|
|
|
|
idx = 0
|
|
for r in cursor.fetchall():
|
|
app = AppImage(*r, i18n=self.i18n, custom_actions=self.custom_app_actions)
|
|
not_installed.append(app)
|
|
found_map[self._gen_app_key(app)] = {'app': app, 'idx': idx}
|
|
idx += 1
|
|
except:
|
|
self.logger.error("An exception happened while querying the 'apps' database")
|
|
traceback.print_exc()
|
|
|
|
try:
|
|
installed = self.read_installed(connection=apps_conn, disk_loader=disk_loader, limit=limit, only_apps=False, pkg_types=None, internet_available=True).installed
|
|
except:
|
|
installed = None
|
|
|
|
installed_found = []
|
|
|
|
if installed:
|
|
lower_words = words.lower()
|
|
for appim in installed:
|
|
found = False
|
|
|
|
if not_installed and found_map:
|
|
key = self._gen_app_key(appim)
|
|
new_found = found_map.get(key)
|
|
|
|
if new_found:
|
|
del not_installed[new_found['idx']]
|
|
installed_found.append(appim)
|
|
found = True
|
|
|
|
if not found and lower_words in appim.name.lower() or (appim.description and lower_words in appim.description.lower()):
|
|
installed_found.append(appim)
|
|
try:
|
|
apps_conn.close()
|
|
except:
|
|
self.logger.error(f"An exception happened when trying to close the connection to database file '{DATABASE_APPS_FILE}'")
|
|
traceback.print_exc()
|
|
|
|
return SearchResult(new=not_installed, installed=installed_found, total=len(not_installed) + len(installed_found))
|
|
|
|
def read_installed(self, disk_loader: Optional[DiskCacheLoader], limit: int = -1, only_apps: bool = False,
|
|
pkg_types: Optional[Set[Type[SoftwarePackage]]] = None, internet_available: bool = None, connection: sqlite3.Connection = None) -> SearchResult:
|
|
installed_apps = []
|
|
res = SearchResult(installed_apps, [], 0)
|
|
|
|
if os.path.exists(INSTALLATION_DIR):
|
|
installed = glob.glob(f'{INSTALLATION_DIR}/*/data.json')
|
|
|
|
if installed:
|
|
names = set()
|
|
for path in installed:
|
|
if path:
|
|
with open(path) as f:
|
|
app = AppImage(installed=True, i18n=self.i18n, custom_actions=self.custom_app_actions, **json.loads(f.read()))
|
|
app.icon_url = app.icon_path
|
|
|
|
installed_apps.append(app)
|
|
names.add(f"'{app.name.lower()}'")
|
|
|
|
if installed_apps:
|
|
apps_con = self._get_db_connection(DATABASE_APPS_FILE) if not connection else connection
|
|
|
|
if apps_con:
|
|
try:
|
|
cursor = apps_con.cursor()
|
|
cursor.execute(query.FIND_APPS_BY_NAME.format(','.join(names)))
|
|
|
|
for tup in cursor.fetchall():
|
|
for app in installed_apps:
|
|
if app.name.lower() == tup[0].lower() and (not app.github or app.github.lower() == tup[1].lower()):
|
|
continuous_version = app.version == 'continuous'
|
|
continuous_update = tup[2] == 'continuous'
|
|
|
|
if tup[3]:
|
|
if continuous_version and not continuous_update:
|
|
app.update = True
|
|
elif continuous_update and not continuous_version:
|
|
app.update = False
|
|
else:
|
|
try:
|
|
app.update = parse_version(tup[2]) > parse_version(app.version) if tup[2] else False
|
|
except:
|
|
app.update = False
|
|
traceback.print_exc()
|
|
|
|
if app.update:
|
|
app.latest_version = tup[2]
|
|
app.url_download_latest_version = tup[3]
|
|
|
|
break
|
|
except:
|
|
self.logger.error(f"An exception happened while querying the database file '{DATABASE_APPS_FILE}'")
|
|
traceback.print_exc()
|
|
finally:
|
|
if not connection: # the connection can only be closed if it was opened within this method
|
|
apps_con.close()
|
|
|
|
ignored_updates = self._read_ignored_updates()
|
|
|
|
if ignored_updates:
|
|
for app in installed_apps:
|
|
if app.supports_ignored_updates() and app.name in ignored_updates:
|
|
app.updates_ignored = True
|
|
|
|
res.total = len(res.installed)
|
|
return res
|
|
|
|
def downgrade(self, pkg: AppImage, root_password: str, watcher: ProcessWatcher) -> bool:
|
|
versions = self.get_history(pkg)
|
|
|
|
if len(versions.history) == 1:
|
|
watcher.show_message(title=self.i18n['appimage.downgrade.impossible.title'],
|
|
body=self.i18n['appimage.downgrade.impossible.body'].format(bold(pkg.name)),
|
|
type_=MessageType.ERROR)
|
|
return False
|
|
elif versions.pkg_status_idx == -1:
|
|
watcher.show_message(title=self.i18n['appimage.downgrade.impossible.title'],
|
|
body=self.i18n['appimage.downgrade.unknown_version.body'].format(bold(pkg.name)),
|
|
type_=MessageType.ERROR)
|
|
return False
|
|
elif versions.pkg_status_idx == len(versions.history) - 1:
|
|
watcher.show_message(title=self.i18n['appimage.downgrade.impossible.title'],
|
|
body=self.i18n['appimage.downgrade.first_version'].format(bold(pkg.name)),
|
|
type_=MessageType.ERROR)
|
|
return False
|
|
else:
|
|
old_release = versions.history[versions.pkg_status_idx + 1]
|
|
pkg.version = old_release['0_version']
|
|
pkg.latest_version = pkg.version
|
|
pkg.url_download = old_release['2_url_download']
|
|
|
|
download_data = self._download(pkg=pkg, watcher=watcher)
|
|
|
|
if not download_data:
|
|
return False
|
|
|
|
if self.uninstall(pkg, root_password, watcher).success:
|
|
if self._install(pkg=pkg, watcher=watcher, pre_downloaded_file=download_data).success:
|
|
self.cache_to_disk(pkg, None, False)
|
|
return True
|
|
else:
|
|
watcher.show_message(title=self.i18n['error'],
|
|
body=self.i18n['appimage.downgrade.install_version'].format(bold(pkg.version), bold(pkg.url_download)),
|
|
type_=MessageType.ERROR)
|
|
else:
|
|
watcher.show_message(title=self.i18n['error'],
|
|
body=self.i18n['appimage.error.uninstall_current_version'].format(bold(pkg.name)),
|
|
type_=MessageType.ERROR)
|
|
|
|
return False
|
|
|
|
def upgrade(self, requirements: UpgradeRequirements, root_password: str, watcher: ProcessWatcher) -> bool:
|
|
not_upgraded = []
|
|
|
|
for req in requirements.to_upgrade:
|
|
watcher.change_status(f"{self.i18n['manage_window.status.upgrading']} {req.pkg.name} ({req.pkg.version})...")
|
|
|
|
download_data = None
|
|
|
|
if not req.pkg.imported:
|
|
download_data = self._download(req.pkg, watcher)
|
|
|
|
if not download_data:
|
|
not_upgraded.append(req.pkg)
|
|
watcher.change_substatus('')
|
|
continue
|
|
|
|
if not self.uninstall(req.pkg, root_password, watcher).success:
|
|
not_upgraded.append(req.pkg)
|
|
watcher.change_substatus('')
|
|
continue
|
|
|
|
if not self._install(pkg=req.pkg, watcher=watcher, pre_downloaded_file=download_data).success:
|
|
not_upgraded.append(req.pkg)
|
|
watcher.change_substatus('')
|
|
continue
|
|
|
|
self.cache_to_disk(req.pkg, None, False)
|
|
|
|
all_failed = len(not_upgraded) == len(requirements.to_upgrade)
|
|
|
|
if not_upgraded:
|
|
pkgs_str = ''.join((f'<li>{app.name}</li>' for app in not_upgraded))
|
|
watcher.show_message(title=self.i18n['error' if all_failed else 'warning'].capitalize(),
|
|
body=self.i18n['appimage.upgrade.failed'].format(apps=f'<ul>{pkgs_str}</ul>'),
|
|
type_=MessageType.ERROR if all_failed else MessageType.WARNING)
|
|
|
|
watcher.change_substatus('')
|
|
return not all_failed
|
|
|
|
def uninstall(self, pkg: AppImage, root_password: str, watcher: ProcessWatcher, disk_loader: DiskCacheLoader = None) -> TransactionResult:
|
|
if os.path.exists(pkg.get_disk_cache_path()):
|
|
handler = ProcessHandler(watcher)
|
|
|
|
if not handler.handle(SystemProcess(new_subprocess(['rm', '-rf', pkg.get_disk_cache_path()]))):
|
|
watcher.show_message(title=self.i18n['error'], body=self.i18n['appimage.uninstall.error.remove_folder'].format(bold(pkg.get_disk_cache_path())))
|
|
return TransactionResult.fail()
|
|
|
|
de_path = self._gen_desktop_entry_path(pkg)
|
|
if os.path.exists(de_path):
|
|
os.remove(de_path)
|
|
|
|
self.revert_ignored_update(pkg)
|
|
|
|
if pkg.symlink and os.path.islink(pkg.symlink):
|
|
self.logger.info(f"Removing symlink '{pkg.symlink}'")
|
|
|
|
try:
|
|
os.remove(pkg.symlink)
|
|
self.logger.info(f"symlink '{pkg.symlink}' successfully removed")
|
|
except:
|
|
msg = f"could not remove symlink '{pkg.symlink}'"
|
|
self.logger.error(msg)
|
|
|
|
if watcher:
|
|
watcher.print(f"[error] {msg}")
|
|
|
|
return TransactionResult(success=True, installed=None, removed=[pkg])
|
|
|
|
def get_managed_types(self) -> Set[Type[SoftwarePackage]]:
|
|
return {AppImage}
|
|
|
|
def clean_cache_for(self, pkg: AppImage):
|
|
pass
|
|
|
|
def get_info(self, pkg: AppImage) -> dict:
|
|
data = pkg.get_data_to_cache()
|
|
|
|
if data.get('url_download'):
|
|
size = self.http_client.get_content_length(data['url_download'])
|
|
if size:
|
|
data['size'] = size
|
|
|
|
categories = data.get('categories')
|
|
|
|
if categories:
|
|
data['categories'] = [self.i18n.get(f'category.{c.lower()}', self.i18n.get(c, c)).capitalize() for c in data['categories']]
|
|
|
|
if data.get('symlink') and not os.path.islink(data['symlink']):
|
|
del data['symlink']
|
|
|
|
return data
|
|
|
|
def get_history(self, pkg: AppImage) -> PackageHistory:
|
|
history = []
|
|
res = PackageHistory(pkg, history, -1)
|
|
|
|
app_con = self._get_db_connection(DATABASE_APPS_FILE)
|
|
|
|
if not app_con:
|
|
return res
|
|
|
|
try:
|
|
cursor = app_con.cursor()
|
|
|
|
cursor.execute(query.FIND_APP_ID_BY_NAME_AND_GITHUB.format(pkg.name.lower(), pkg.github.lower() if pkg.github else ''))
|
|
app_tuple = cursor.fetchone()
|
|
|
|
if not app_tuple:
|
|
self.logger.warning(f"Could not retrieve {pkg} from the database '{DATABASE_APPS_FILE}'")
|
|
return res
|
|
except:
|
|
self.logger.error(f"An exception happened while querying the database file '{DATABASE_APPS_FILE}'")
|
|
traceback.print_exc()
|
|
app_con.close()
|
|
return res
|
|
|
|
app_con.close()
|
|
|
|
releases_con = self._get_db_connection(DATABASE_RELEASES_FILE)
|
|
|
|
if not releases_con:
|
|
return res
|
|
|
|
try:
|
|
cursor = releases_con.cursor()
|
|
|
|
releases = cursor.execute(query.FIND_RELEASES_BY_APP_ID.format(app_tuple[0]))
|
|
|
|
if releases:
|
|
for idx, tup in enumerate(releases):
|
|
history.append({'0_version': tup[0],
|
|
'1_published_at': datetime.strptime(tup[2], '%Y-%m-%dT%H:%M:%SZ') if tup[
|
|
2] else '', '2_url_download': tup[1]})
|
|
|
|
if res.pkg_status_idx == -1 and pkg.version == tup[0]:
|
|
res.pkg_status_idx = idx
|
|
|
|
return res
|
|
except:
|
|
self.logger.error(f"An exception happened while querying the database file '{DATABASE_RELEASES_FILE}'")
|
|
traceback.print_exc()
|
|
finally:
|
|
releases_con.close()
|
|
|
|
def _find_desktop_file(self, folder: str) -> str:
|
|
for r, d, files in os.walk(folder):
|
|
for f in files:
|
|
if f.endswith('.desktop'):
|
|
return f
|
|
|
|
def _find_icon_file(self, folder: str) -> str:
|
|
for f in glob.glob(folder + ('/**' if not folder.endswith('/') else '**'), recursive=True):
|
|
if RE_ICON_ENDS_WITH.match(f):
|
|
return f
|
|
|
|
def _download(self, pkg: AppImage, watcher: ProcessWatcher) -> Optional[Tuple[str, str]]:
|
|
appimage_url = pkg.url_download_latest_version if pkg.update else pkg.url_download
|
|
|
|
if not appimage_url:
|
|
watcher.show_message(title=self.i18n['error'],
|
|
body=self.i18n['appimage.download.no_url'].format(app=bold(pkg.name)),
|
|
type_=MessageType.ERROR)
|
|
return
|
|
|
|
file_name = appimage_url.split('/')[-1]
|
|
pkg.version = pkg.latest_version
|
|
pkg.url_download = appimage_url
|
|
|
|
try:
|
|
Path(DOWNLOAD_DIR).mkdir(exist_ok=True, parents=True)
|
|
except OSError:
|
|
watcher.show_message(title=self.i18n['error'],
|
|
body=self.i18n['error.mkdir'].format(dir=bold(DOWNLOAD_DIR)),
|
|
type_=MessageType.ERROR)
|
|
return
|
|
|
|
file_path = f'{DOWNLOAD_DIR}/{file_name}'
|
|
downloaded = self.file_downloader.download(file_url=pkg.url_download, watcher=watcher,
|
|
output_path=file_path, cwd=str(Path.home()))
|
|
|
|
if not downloaded:
|
|
watcher.show_message(title=self.i18n['error'],
|
|
body=self.i18n['appimage.download.error'].format(bold(pkg.url_download)),
|
|
type_=MessageType.ERROR)
|
|
return
|
|
|
|
return file_name, file_path
|
|
|
|
def install(self, pkg: AppImage, root_password: str, disk_loader: Optional[DiskCacheLoader], watcher: ProcessWatcher) -> TransactionResult:
|
|
return self._install(pkg=pkg, watcher=watcher)
|
|
|
|
def _install(self, pkg: AppImage, watcher: ProcessWatcher, pre_downloaded_file: Optional[Tuple[str, str]] = None):
|
|
|
|
handler = ProcessHandler(watcher)
|
|
out_dir = f'{INSTALLATION_DIR}/{pkg.get_clean_name()}'
|
|
counter = 0
|
|
while True:
|
|
if os.path.exists(out_dir):
|
|
self.logger.info(f"Installation dir '{out_dir}' already exists. Generating a different one")
|
|
out_dir += f'-{counter}'
|
|
counter += 1
|
|
else:
|
|
break
|
|
|
|
Path(out_dir).mkdir(parents=True, exist_ok=True)
|
|
pkg.install_dir = out_dir
|
|
|
|
if pkg.imported:
|
|
|
|
downloaded, file_name = True, pkg.local_file_path.split('/')[-1]
|
|
|
|
install_file_path = out_dir + '/' + file_name
|
|
|
|
try:
|
|
moved, output = handler.handle_simple(SimpleProcess(['mv', pkg.local_file_path, install_file_path]))
|
|
except:
|
|
output = ''
|
|
self.logger.error(f"Could not rename file '{pkg.local_file_path}' as '{install_file_path}'")
|
|
moved = False
|
|
|
|
if not moved:
|
|
watcher.show_message(title=self.i18n['error'].capitalize(),
|
|
body=self.i18n['appimage.install.imported.rename_error'].format(bold(pkg.local_file_path.split('/')[-1]),
|
|
bold(output)),
|
|
type_=MessageType.ERROR)
|
|
|
|
return TransactionResult.fail()
|
|
|
|
else:
|
|
download_data = pre_downloaded_file if pre_downloaded_file else self._download(pkg, watcher)
|
|
|
|
if not download_data:
|
|
return TransactionResult.fail()
|
|
|
|
file_name, download_path = download_data[0], download_data[1]
|
|
|
|
install_file_path = f'{out_dir}/{file_name}'
|
|
|
|
try:
|
|
shutil.move(download_path, install_file_path)
|
|
except OSError:
|
|
watcher.show_message(title=self.i18n['error'],
|
|
body=self.i18n['error.mvfile'].formmat(src=bold(download_path),
|
|
dest=bold(install_file_path)))
|
|
return TransactionResult.fail()
|
|
|
|
watcher.change_substatus(self.i18n['appimage.install.permission'].format(bold(file_name)))
|
|
permission_given = handler.handle(SystemProcess(new_subprocess(['chmod', 'a+x', install_file_path])))
|
|
|
|
if permission_given:
|
|
|
|
watcher.change_substatus(self.i18n['appimage.install.extract'].format(bold(file_name)))
|
|
|
|
try:
|
|
res, output = handler.handle_simple(
|
|
SimpleProcess([install_file_path, '--appimage-extract'], cwd=out_dir))
|
|
|
|
if 'Error: Failed to register AppImage in AppImageLauncherFS' in output:
|
|
watcher.show_message(title=self.i18n['error'],
|
|
body=self.i18n['appimage.install.appimagelauncher.error'].format(
|
|
appimgl=bold('AppImageLauncher'), app=bold(pkg.name)),
|
|
type_=MessageType.ERROR)
|
|
handler.handle(SystemProcess(new_subprocess(['rm', '-rf', out_dir])))
|
|
return TransactionResult.fail()
|
|
except:
|
|
watcher.show_message(title=self.i18n['error'],
|
|
body=traceback.format_exc(),
|
|
type_=MessageType.ERROR)
|
|
traceback.print_exc()
|
|
handler.handle(SystemProcess(new_subprocess(['rm', '-rf', out_dir])))
|
|
return TransactionResult.fail()
|
|
|
|
watcher.change_substatus(self.i18n['appimage.install.desktop_entry'])
|
|
extracted_folder = f'{out_dir}/squashfs-root'
|
|
|
|
if os.path.exists(extracted_folder):
|
|
desktop_entry = self._find_desktop_file(extracted_folder)
|
|
|
|
with open(f'{extracted_folder}/{desktop_entry}') as f:
|
|
de_content = f.read()
|
|
|
|
if de_content:
|
|
de_content = replace_desktop_entry_exec_command(desktop_entry=de_content,
|
|
appname=pkg.name,
|
|
file_path=install_file_path)
|
|
extracted_icon = self._find_icon_file(extracted_folder)
|
|
|
|
if extracted_icon:
|
|
icon_path = out_dir + '/logo.' + extracted_icon.split('/')[-1].split('.')[-1]
|
|
shutil.copy(extracted_icon, icon_path)
|
|
|
|
if de_content:
|
|
de_content = RE_DESKTOP_ICON.sub(f'Icon={icon_path}\n', de_content)
|
|
|
|
pkg.icon_path = icon_path
|
|
|
|
if not de_content:
|
|
de_content = pkg.to_desktop_entry()
|
|
|
|
Path(DESKTOP_ENTRIES_DIR).mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(self._gen_desktop_entry_path(pkg), 'w+') as f:
|
|
f.write(de_content)
|
|
|
|
try:
|
|
shutil.rmtree(extracted_folder)
|
|
except:
|
|
traceback.print_exc()
|
|
|
|
SymlinksVerifier.create_symlink(app=pkg, file_path=install_file_path, logger=self.logger,
|
|
watcher=watcher)
|
|
return TransactionResult(success=True, installed=[pkg], removed=[])
|
|
else:
|
|
watcher.show_message(title=self.i18n['error'],
|
|
body=f'Could extract content from {bold(file_name)}',
|
|
type_=MessageType.ERROR)
|
|
|
|
handler.handle(SystemProcess(new_subprocess(['rm', '-rf', out_dir])))
|
|
return TransactionResult.fail()
|
|
|
|
def _gen_desktop_entry_path(self, app: AppImage) -> str:
|
|
return f'{DESKTOP_ENTRIES_DIR}/bauh_appimage_{app.get_clean_name()}.desktop'
|
|
|
|
def is_enabled(self) -> bool:
|
|
return self.enabled
|
|
|
|
def set_enabled(self, enabled: bool):
|
|
self.enabled = enabled
|
|
|
|
def _is_sqlite3_available(self) -> bool:
|
|
return bool(shutil.which('sqlite3'))
|
|
|
|
def can_work(self) -> Tuple[bool, Optional[str]]:
|
|
if not self.context.is_system_x86_64():
|
|
return False, self.i18n['message.requires_architecture'].format(arch=bold('x86_64'))
|
|
|
|
if not self._is_sqlite3_available():
|
|
return False, self.i18n['missing_dep'].format(dep=bold('sqlite3'))
|
|
|
|
if not self.file_downloader.can_work():
|
|
download_clients = ', '.join(self.file_downloader.get_supported_clients())
|
|
return False, self.i18n['appimage.missing_downloader'].format(clients=download_clients)
|
|
|
|
return True, None
|
|
|
|
def requires_root(self, action: SoftwareAction, pkg: AppImage) -> bool:
|
|
return False
|
|
|
|
def prepare(self, task_manager: TaskManager, root_password: str, internet_available: bool):
|
|
create_config = CreateConfigFile(taskman=task_manager, configman=self.configman, i18n=self.i18n,
|
|
task_icon_path=get_icon_path(), logger=self.logger)
|
|
create_config.start()
|
|
|
|
symlink_check = SymlinksVerifier(taskman=task_manager, i18n=self.i18n, logger=self.logger)
|
|
symlink_check.start()
|
|
|
|
if internet_available:
|
|
DatabaseUpdater(taskman=task_manager, i18n=self.context.i18n,
|
|
create_config=create_config, http_client=self.context.http_client,
|
|
logger=self.context.logger).start()
|
|
|
|
AppImageSuggestionsDownloader(taskman=task_manager, i18n=self.context.i18n,
|
|
http_client=self.context.http_client, logger=self.context.logger,
|
|
create_config=create_config).start()
|
|
|
|
def list_updates(self, internet_available: bool) -> List[PackageUpdate]:
|
|
res = self.read_installed(disk_loader=None, internet_available=internet_available)
|
|
|
|
updates = []
|
|
if res.installed:
|
|
for app in res.installed:
|
|
if app.update and not app.is_update_ignored():
|
|
updates.append(PackageUpdate(pkg_id=app.name, pkg_type='AppImage', version=app.latest_version, name=app.name))
|
|
|
|
return updates
|
|
|
|
def list_warnings(self, internet_available: bool) -> List[str]:
|
|
dbfiles = glob.glob(f'{APPIMAGE_CACHE_DIR}/*.db')
|
|
|
|
if not dbfiles or len({f for f in (DATABASE_APPS_FILE, DATABASE_RELEASES_FILE) if f in dbfiles}) != 2:
|
|
return [self.i18n['appimage.warning.missing_db_files'].format(appimage=bold('AppImage'))]
|
|
|
|
def list_suggestions(self, limit: int, filter_installed: bool) -> List[PackageSuggestion]:
|
|
res = []
|
|
|
|
connection = self._get_db_connection(DATABASE_APPS_FILE)
|
|
|
|
if connection:
|
|
suggestions = AppImageSuggestionsDownloader(appimage_config=self.configman.get_config(), logger=self.logger,
|
|
i18n=self.i18n, http_client=self.http_client,
|
|
taskman=TaskManager()).read()
|
|
|
|
if not suggestions:
|
|
self.logger.warning("Could not read suggestions")
|
|
return res
|
|
else:
|
|
self.logger.info("Mapping suggestions")
|
|
try:
|
|
if filter_installed:
|
|
installed = {i.name.lower() for i in self.read_installed(disk_loader=None, connection=connection).installed}
|
|
else:
|
|
installed = None
|
|
|
|
sugs_map = {}
|
|
|
|
for s in suggestions:
|
|
lsplit = s.split('=')
|
|
|
|
name = lsplit[1].strip()
|
|
|
|
if limit <= 0 or len(sugs_map) < limit:
|
|
if not installed or not name.lower() in installed:
|
|
sugs_map[name] = SuggestionPriority(int(lsplit[0]))
|
|
else:
|
|
break
|
|
|
|
cursor = connection.cursor()
|
|
cursor.execute(query.FIND_APPS_BY_NAME_FULL.format(','.join([f"'{s}'" for s in sugs_map.keys()])))
|
|
|
|
for t in cursor.fetchall():
|
|
app = AppImage(*t, i18n=self.i18n, custom_actions=self.custom_app_actions)
|
|
res.append(PackageSuggestion(app, sugs_map[app.name.lower()]))
|
|
self.logger.info(f"Mapped {len(res)} suggestions")
|
|
except:
|
|
traceback.print_exc()
|
|
finally:
|
|
connection.close()
|
|
|
|
return res
|
|
|
|
def is_default_enabled(self) -> bool:
|
|
return True
|
|
|
|
def launch(self, pkg: AppImage):
|
|
installation_dir = pkg.get_disk_cache_path()
|
|
if os.path.exists(installation_dir):
|
|
appimag_path = util.find_appimage_file(installation_dir)
|
|
|
|
if appimag_path:
|
|
subprocess.Popen(args=[appimag_path], shell=True, env={**os.environ},
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
|
|
else:
|
|
self.logger.error(f"Could not find the AppImage file of '{pkg.name}' in '{installation_dir}'")
|
|
|
|
def cache_to_disk(self, pkg: SoftwarePackage, icon_bytes: Optional[bytes], only_icon: bool):
|
|
self.serialize_to_disk(pkg, icon_bytes, only_icon)
|
|
|
|
def get_screenshots(self, pkg: AppImage) -> List[str]:
|
|
if pkg.has_screenshots():
|
|
return [pkg.url_screenshot]
|
|
|
|
return []
|
|
|
|
def clear_data(self, logs: bool = True):
|
|
for f in glob.glob(f'{APPIMAGE_SHARED_DIR}/*.db'):
|
|
try:
|
|
if logs:
|
|
print(f'[bauh][appimage] Deleting {f}')
|
|
os.remove(f)
|
|
|
|
if logs:
|
|
print(f'{Fore.YELLOW}[bauh][appimage] {f} deleted{Fore.RESET}')
|
|
except:
|
|
if logs:
|
|
print(f'{Fore.RED}[bauh][appimage] An exception has happened when deleting {f}{Fore.RESET}')
|
|
traceback.print_exc()
|
|
|
|
def get_settings(self, screen_width: int, screen_height: int) -> Optional[ViewComponent]:
|
|
appimage_config = self.configman.get_config()
|
|
max_width = floor(screen_width * 0.15)
|
|
|
|
comps = [
|
|
TextInputComponent(label=self.i18n['appimage.config.database.expiration'],
|
|
value=int(appimage_config['database']['expiration']) if isinstance(
|
|
appimage_config['database']['expiration'], int) else '',
|
|
tooltip=self.i18n['appimage.config.database.expiration.tip'],
|
|
only_int=True,
|
|
max_width=max_width,
|
|
id_='appim_db_exp'),
|
|
TextInputComponent(label=self.i18n['appimage.config.suggestions.expiration'],
|
|
value=int(appimage_config['suggestions']['expiration']) if isinstance(
|
|
appimage_config['suggestions']['expiration'], int) else '',
|
|
tooltip=self.i18n['appimage.config.suggestions.expiration.tip'],
|
|
only_int=True,
|
|
max_width=max_width,
|
|
id_='appim_sugs_exp')
|
|
]
|
|
|
|
return PanelComponent([FormComponent(components=comps, id_='form')])
|
|
|
|
def save_settings(self, component: PanelComponent) -> Tuple[bool, Optional[List[str]]]:
|
|
appimage_config = self.configman.get_config()
|
|
|
|
form = component.get_form_component('form')
|
|
appimage_config['database']['expiration'] = form.get_text_input('appim_db_exp').get_int_value()
|
|
appimage_config['suggestions']['expiration'] = form.get_text_input('appim_sugs_exp').get_int_value()
|
|
|
|
try:
|
|
self.configman.save_config(appimage_config)
|
|
return True, None
|
|
except:
|
|
return False, [traceback.format_exc()]
|
|
|
|
def gen_custom_actions(self) -> Generator[CustomSoftwareAction, None, None]:
|
|
if self._custom_actions is None:
|
|
self._custom_actions = (CustomSoftwareAction(i18n_label_key='appimage.custom_action.install_file',
|
|
i18n_status_key='appimage.custom_action.install_file.status',
|
|
manager=self,
|
|
manager_method='install_file',
|
|
icon_path=resource.get_path('img/appimage.svg', ROOT_DIR),
|
|
requires_root=False,
|
|
requires_confirmation=False),
|
|
CustomSoftwareAction(i18n_label_key='appimage.custom_action.update_db',
|
|
i18n_status_key='appimage.custom_action.update_db.status',
|
|
manager=self,
|
|
manager_method='update_database',
|
|
icon_path=resource.get_path('img/appimage.svg', ROOT_DIR),
|
|
requires_root=False,
|
|
requires_internet=True))
|
|
yield from self._custom_actions
|
|
|
|
def get_upgrade_requirements(self, pkgs: List[AppImage], root_password: str, watcher: ProcessWatcher) -> UpgradeRequirements:
|
|
to_update = []
|
|
|
|
for pkg in pkgs:
|
|
requirement = UpgradeRequirement(pkg)
|
|
installed_size = self.http_client.get_content_length_in_bytes(pkg.url_download)
|
|
upgrade_size = self.http_client.get_content_length_in_bytes(pkg.url_download_latest_version)
|
|
requirement.required_size = upgrade_size
|
|
|
|
if upgrade_size and installed_size:
|
|
requirement.extra_size = upgrade_size - installed_size
|
|
|
|
to_update.append(requirement)
|
|
|
|
return UpgradeRequirements([], [], to_update, [])
|
|
|
|
def _read_ignored_updates(self) -> Set[str]:
|
|
ignored = set()
|
|
if os.path.exists(UPDATES_IGNORED_FILE):
|
|
with open(UPDATES_IGNORED_FILE) as f:
|
|
ignored_txt = f.read()
|
|
|
|
for l in ignored_txt.split('\n'):
|
|
if l:
|
|
line_clean = l.strip()
|
|
|
|
if line_clean:
|
|
ignored.add(line_clean)
|
|
|
|
return ignored
|
|
|
|
def ignore_update(self, pkg: AppImage):
|
|
current_ignored = self._read_ignored_updates()
|
|
|
|
if pkg.name not in current_ignored:
|
|
current_ignored.add(pkg.name)
|
|
self._write_ignored_updates(current_ignored)
|
|
|
|
pkg.updates_ignored = True
|
|
|
|
def _write_ignored_updates(self, names: Set[str]):
|
|
Path(APPIMAGE_CONFIG_DIR).mkdir(parents=True, exist_ok=True)
|
|
ignored_list = [*names]
|
|
ignored_list.sort()
|
|
|
|
with open(UPDATES_IGNORED_FILE, 'w+') as f:
|
|
if ignored_list:
|
|
for ignored in ignored_list:
|
|
f.write(f'{ignored}\n')
|
|
else:
|
|
f.write('')
|
|
|
|
def revert_ignored_update(self, pkg: AppImage):
|
|
current_ignored = self._read_ignored_updates()
|
|
|
|
if current_ignored and pkg.name in current_ignored:
|
|
current_ignored.remove(pkg.name)
|
|
|
|
self._write_ignored_updates(current_ignored)
|
|
|
|
pkg.updates_ignored = False
|
|
|
|
def update_database(self, root_password: str, watcher: ProcessWatcher) -> bool:
|
|
db_updater = DatabaseUpdater(i18n=self.i18n, http_client=self.context.http_client,
|
|
logger=self.context.logger, watcher=watcher, taskman=TaskManager())
|
|
|
|
res = db_updater.download_databases()
|
|
return res
|