diff --git a/.gitignore b/.gitignore index 6c1324ac..f5d3d1d1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ env/ venv/ .venv/ .idea/ -*.egg-info \ No newline at end of file +*.egg-info +.vscode \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d302bf29..621f2381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +- 4.0.0-beta.5 + - Implemented network cache [N. Shaaban] + - Refactored configuration handling (no user action required) [N. Shaaban] + - Fix #17 [T. H. Wright] + - Make dependency lists more precise [M. Marti, N. Shaaban] + - Fix #230 [N. Shaaban] - 4.0.0-beta.4 - Fix #220 [N. Shaaban] - 4.0.0-beta.3 diff --git a/ou_dedetai/app.py b/ou_dedetai/app.py new file mode 100644 index 00000000..64064081 --- /dev/null +++ b/ou_dedetai/app.py @@ -0,0 +1,257 @@ + +import abc +import logging +import os +from pathlib import Path +import sys +import threading +from typing import Callable, NoReturn, Optional + +from ou_dedetai import constants +from ou_dedetai.constants import ( + PROMPT_OPTION_DIRECTORY, + PROMPT_OPTION_FILE +) + + +class App(abc.ABC): + # FIXME: consider weighting install steps. Different steps take different lengths + installer_step_count: int = 0 + """Total steps in the installer, only set the installation process has started.""" + installer_step: int = 1 + """Step the installer is on. Starts at 0""" + + _threads: list[threading.Thread] + """List of threads + + Non-daemon threads will be joined before shutdown + """ + _last_status: Optional[str] = None + """The last status we had""" + config_updated_hooks: list[Callable[[], None]] = [] + _config_updated_event: threading.Event = threading.Event() + + def __init__(self, config, **kwargs) -> None: + # This lazy load is required otherwise these would be circular imports + from ou_dedetai.config import Config + from ou_dedetai.logos import LogosManager + from ou_dedetai.system import check_incompatibilities + + self.conf = Config(config, self) + self.logos = LogosManager(app=self) + self._threads = [] + # Ensure everything is good to start + check_incompatibilities(self) + + def _config_updated_hook_runner(): + while True: + self._config_updated_event.wait() + self._config_updated_event.clear() + for hook in self.config_updated_hooks: + try: + hook() + except Exception: + logging.exception("Failed to run config update hook") + _config_updated_hook_runner.__name__ = "Config Update Hook" + self.start_thread(_config_updated_hook_runner, daemon_bool=True) + + def ask(self, question: str, options: list[str]) -> str: + """Asks the user a question with a list of supplied options + + Returns the option the user picked. + + If the internal ask function returns None, the process will exit with 1 + """ + def validate_result(answer: str, options: list[str]) -> Optional[str]: + special_cases = set([PROMPT_OPTION_DIRECTORY, PROMPT_OPTION_FILE]) + # These constants have special meaning, don't worry about them to start with + simple_options = list(set(options) - special_cases) + # This MUST have the same indexes as above + simple_options_lower = [opt.lower() for opt in simple_options] + + # Case sensitive check first + if answer in simple_options: + return answer + # Also do a case insensitive match, no reason to fail due to casing + if answer.lower() in simple_options_lower: + # Return the correct casing to simplify the parsing of the ask result + return simple_options[simple_options.index(answer.lower())] + + # Now check the special cases + if PROMPT_OPTION_FILE in options and Path(answer).is_file(): + return answer + if PROMPT_OPTION_DIRECTORY in options and Path(answer).is_dir(): + return answer + + # Not valid + return None + + # Check to see if we're supposed to prompt the user + if self.conf._overrides.assume_yes: + # Get the first non-dynamic option + for option in options: + if option not in [PROMPT_OPTION_DIRECTORY, PROMPT_OPTION_FILE]: + return option + + passed_options: list[str] | str = options + if len(passed_options) == 1 and ( + PROMPT_OPTION_DIRECTORY in passed_options + or PROMPT_OPTION_FILE in passed_options + ): + # Set the only option to be the follow up prompt + passed_options = options[0] + elif passed_options is not None and self._exit_option is not None: + passed_options = options + [self._exit_option] + + answer = self._ask(question, passed_options) + while answer is None or validate_result(answer, options) is None: + invalid_response = "That response is not valid, please try again." + new_question = f"{invalid_response}\n{question}" + answer = self._ask(new_question, passed_options) + + if answer is not None: + answer = validate_result(answer, options) + if answer is None: + # Huh? coding error, this should have been checked earlier + logging.critical("An invalid response slipped by, please report this incident to the developers") #noqa: E501 + self.exit("Failed to get a valid value from user") + + if answer == self._exit_option: + answer = None + + if answer is None: + self.exit("Failed to get a valid value from user") + + return answer + + def approve_or_exit(self, question: str, context: Optional[str] = None): + """Asks the user a question, if they refuse, shutdown""" + if not self.approve(question, context): + self.exit(f"User refused the prompt: {question}") + + def approve(self, question: str, context: Optional[str] = None) -> bool: + """Asks the user a y/n question""" + question = f"{context}\n" if context is not None else "" + question + options = ["Yes", "No"] + return self.ask(question, options) == "Yes" + + def exit(self, reason: str, intended: bool = False) -> NoReturn: + """Exits the application cleanly with a reason.""" + logging.debug(f"Closing {constants.APP_NAME}.") + # Shutdown logos/indexer if we spawned it + self.logos.end_processes() + # Join threads + for thread in self._threads: + # Only wait on non-daemon threads. + if not thread.daemon: + try: + thread.join() + except RuntimeError: + # Will happen if we try to join the current thread + pass + # Remove pid file if exists + try: + os.remove(constants.PID_FILE) + except FileNotFoundError: # no pid file when testing functions + pass + # exit from the process + if intended: + sys.exit(0) + else: + logging.critical(f"Cannot continue because {reason}\n{constants.SUPPORT_MESSAGE}") #noqa: E501 + sys.exit(1) + + _exit_option: Optional[str] = "Exit" + + @abc.abstractmethod + def _ask(self, question: str, options: list[str] | str) -> Optional[str]: + """Implementation for asking a question pre-front end + + Options may include ability to prompt for an additional value. + Such as asking for one of strings or a directory. + If the user selects choose a new directory, the + implementations MUST handle the follow up prompt before returning + + Options may be a single value, + Implementations MUST handle this single option being a follow up prompt + """ + raise NotImplementedError() + + def is_installed(self) -> bool: + """Returns whether the install was successful by + checking if the installed exe exists and is executable""" + if self.conf.logos_exe is not None: + return os.access(self.conf.logos_exe, os.X_OK) + return False + + def status(self, message: str, percent: Optional[int | float] = None): + """A status update + + Args: + message: str - if it ends with a \r that signifies that this message is + intended to be overrighten next time + percent: Optional[int] - percent of the way through the current install step + (if installing) + """ + # Check to see if we want to suppress all output + if self.conf._overrides.quiet: + return + + if isinstance(percent, float): + percent = round(percent * 100) + # If we're installing + if self.installer_step_count != 0: + current_step_percent = percent or 0 + # We're further than the start of our current step, percent more + installer_percent = round((self.installer_step * 100 + current_step_percent) / self.installer_step_count) # noqa: E501 + logging.debug(f"Install {installer_percent}: {message}") + self._status(message, percent=installer_percent) + else: + # Otherwise just print status using the progress given + logging.debug(f"{message}: {percent}") + self._status(message, percent) + self._last_status = message + + @abc.abstractmethod + def _status(self, message: str, percent: Optional[int] = None): + """Implementation for updating status pre-front end + + Args: + message: str - if it ends with a \r that signifies that this message is + intended to be overrighten next time + percent: Optional[int] - percent complete of the current overall operation + if None that signifies we can't track the progress. + Feel free to implement a spinner + """ + # De-dup + if message != self._last_status: + if message.endswith("\r"): + print(f"{message}", end="\r") + else: + print(f"{message}") + + @property + def superuser_command(self) -> str: + """Command when root privileges are needed. + + Raises: + SuperuserCommandNotFound + + May be sudo or pkexec for example""" + from ou_dedetai.system import get_superuser_command + return get_superuser_command() + + def start_thread(self, task, *args, daemon_bool: bool = True, **kwargs): + """Starts a new thread + + Non-daemon threads be joined before shutdown""" + thread = threading.Thread( + name=f"{constants.APP_NAME} {task}", + target=task, + daemon=daemon_bool, + args=args, + kwargs=kwargs + ) + self._threads.append(thread) + thread.start() + return thread \ No newline at end of file diff --git a/ou_dedetai/cli.py b/ou_dedetai/cli.py index 9bd88433..dd0fbfd8 100644 --- a/ou_dedetai/cli.py +++ b/ou_dedetai/cli.py @@ -1,61 +1,64 @@ import queue +import shutil import threading +from typing import Optional, Tuple + +from ou_dedetai.app import App +from ou_dedetai.config import EphemeralConfiguration +from ou_dedetai.system import SuperuserCommandNotFound from . import control from . import installer -from . import logos from . import wine from . import utils -class CLI: - def __init__(self): +class CLI(App): + def __init__(self, ephemeral_config: EphemeralConfiguration): + super().__init__(ephemeral_config) self.running: bool = True - self.choice_q = queue.Queue() - self.input_q = queue.Queue() + self.choice_q: queue.Queue[str] = queue.Queue() + self.input_q: queue.Queue[Tuple[str, list[str]] | None] = queue.Queue() self.input_event = threading.Event() self.choice_event = threading.Event() - self.logos = logos.LogosManager(app=self) + self.start_thread(self.user_input_processor) def backup(self): control.backup(app=self) def create_shortcuts(self): - installer.create_launcher_shortcuts() + installer.create_launcher_shortcuts(self) def edit_config(self): - control.edit_config() + control.edit_file(self.conf.config_file_path) def get_winetricks(self): - control.set_winetricks() + control.set_winetricks(self) def install_app(self): - self.thread = utils.start_thread( - installer.ensure_launcher_shortcuts, - app=self - ) - self.user_input_processor() + installer.install(self) + self.exit("Install has finished", intended=True) def install_d3d_compiler(self): - wine.install_d3d_compiler() + wine.install_d3d_compiler(self) def install_dependencies(self): utils.install_dependencies(app=self) def install_fonts(self): - wine.install_fonts() + wine.install_fonts(self) def install_icu(self): - wine.enforce_icu_data_files() + wine.enforce_icu_data_files(self) def remove_index_files(self): - control.remove_all_index_files() + control.remove_all_index_files(self) def remove_install_dir(self): - control.remove_install_dir() + control.remove_install_dir(self) def remove_library_catalog(self): - control.remove_library_catalog() + control.remove_library_catalog(self) def restore(self): control.restore(app=self) @@ -66,34 +69,87 @@ def run_indexing(self): def run_installed_app(self): self.logos.start() + def stop_installed_app(self): + self.logos.stop() + def run_winetricks(self): - wine.run_winetricks() + wine.run_winetricks(self) def set_appimage(self): utils.set_appimage_symlink(app=self) - def stop(self): - self.running = False - def toggle_app_logging(self): self.logos.switch_logging() def update_latest_appimage(self): - utils.update_to_latest_recommended_appimage() + utils.update_to_latest_recommended_appimage(self) def update_self(self): - utils.update_to_latest_lli_release() + utils.update_to_latest_lli_release(self) def winetricks(self): - import config - wine.run_winetricks_cmd(*config.winetricks_args) - - def user_input_processor(self, evt=None): + wine.run_winetricks(self, *(self.conf._overrides.winetricks_args or [])) + + _exit_option: str = "Exit" + + def _ask(self, question: str, options: list[str] | str) -> str: + """Passes the user input to the user_input_processor thread + + The user_input_processor is running on the thread that the user's stdin/stdout + is attached to. This function is being called from another thread so we need to + pass the information between threads using a queue/event + """ + if isinstance(options, str): + options = [options] + self.input_q.put((question, options)) + self.input_event.set() + self.choice_event.wait() + self.choice_event.clear() + output: str = self.choice_q.get() + # NOTE: this response is validated in App's .ask + return output + + def exit(self, reason: str, intended: bool = False): + # Signal CLI.user_input_processor to stop. + self.input_q.put(None) + self.input_event.set() + # Signal CLI itself to stop. + self.running = False + return super().exit(reason, intended) + + def _status(self, message: str, percent: Optional[int] = None): + """Implementation for updating status pre-front end""" + prefix = "" + end = "\n" + # Signifies we want to overwrite the last line + if message.endswith("\r"): + end = "\r" + if message == self._last_status: + # Go back to the beginning of the line to re-write the current line + # Rather than sending a new one. This allows the current line to update + prefix += "\r" + end = "\r" + if percent is not None: + percent_per_char = 5 + chars_of_progress = round(percent / percent_per_char) + chars_remaining = round((100 - percent) / percent_per_char) + progress_str = "[" + "-" * chars_of_progress + " " * chars_remaining + "] " + prefix += progress_str + print(f"{prefix}{message}", end=end) + + @property + def superuser_command(self) -> str: + if shutil.which('sudo'): + return "sudo" + else: + raise SuperuserCommandNotFound("sudo command not found. Please install.") + + def user_input_processor(self, evt=None) -> None: while self.running: prompt = None - question = None + question: Optional[str] = None options = None - choice = None + choice: Optional[str] = None # Wait for next input queue item. self.input_event.wait() self.input_event.clear() @@ -106,100 +162,12 @@ def user_input_processor(self, evt=None): if question is not None and options is not None: # Convert options list to string. default = options[0] - options[0] = f"{options[0]} [default]" - optstr = ', '.join(options) + optstr = f"{options[0]} [default], " + ', '.join(options[1:]) choice = input(f"{question}: {optstr}: ") if len(choice) == 0: choice = default - if choice is not None and choice.lower() == 'exit': + if choice is not None and choice == self._exit_option: self.running = False if choice is not None: self.choice_q.put(choice) self.choice_event.set() - - -# NOTE: These subcommands are outside the CLI class so that the class can be -# instantiated at the moment the subcommand is run. This lets any CLI-specific -# code get executed along with the subcommand. -def backup(): - CLI().backup() - - -def create_shortcuts(): - CLI().create_shortcuts() - - -def edit_config(): - CLI().edit_config() - - -def get_winetricks(): - CLI().get_winetricks() - - -def install_app(): - CLI().install_app() - - -def install_d3d_compiler(): - CLI().install_d3d_compiler() - - -def install_dependencies(): - CLI().install_dependencies() - - -def install_fonts(): - CLI().install_fonts() - - -def install_icu(): - CLI().install_icu() - - -def remove_index_files(): - CLI().remove_index_files() - - -def remove_install_dir(): - CLI().remove_install_dir() - - -def remove_library_catalog(): - CLI().remove_library_catalog() - - -def restore(): - CLI().restore() - - -def run_indexing(): - CLI().run_indexing() - - -def run_installed_app(): - CLI().run_installed_app() - - -def run_winetricks(): - CLI().run_winetricks() - - -def set_appimage(): - CLI().set_appimage() - - -def toggle_app_logging(): - CLI().toggle_app_logging() - - -def update_latest_appimage(): - CLI().update_latest_appimage() - - -def update_self(): - CLI().update_self() - - -def winetricks(): - CLI().winetricks() diff --git a/ou_dedetai/config.py b/ou_dedetai/config.py index 341e19ef..18478a43 100644 --- a/ou_dedetai/config.py +++ b/ou_dedetai/config.py @@ -1,209 +1,1098 @@ -import json -import logging +import copy import os -import tempfile -from datetime import datetime from typing import Optional +from dataclasses import dataclass +import json +import logging +from pathlib import Path +from ou_dedetai import network, utils, constants, wine -# Define app name variables. -name_app = 'Ou Dedetai' -name_binary = 'oudedetai' -name_package = 'ou_dedetai' -repo_link = "https://github.com/FaithLife-Community/LogosLinuxInstaller" - -# Define and set variables that are required in the config file. -core_config_keys = [ - "FLPRODUCT", "TARGETVERSION", "TARGET_RELEASE_VERSION", - "current_logos_version", "curses_colors", - "INSTALLDIR", "WINETRICKSBIN", "WINEBIN_CODE", "WINE_EXE", - "WINECMD_ENCODING", "LOGS", "BACKUPDIR", "LAST_UPDATED", - "RECOMMENDED_WINE64_APPIMAGE_URL", "LLI_LATEST_VERSION", - "logos_release_channel", "lli_release_channel", -] -for k in core_config_keys: - globals()[k] = os.getenv(k) - -# Define and set additional variables that can be set in the env. -extended_config = { - 'APPIMAGE_LINK_SELECTION_NAME': 'selected_wine.AppImage', - 'APPDIR_BINDIR': None, - 'CHECK_UPDATES': False, - 'CONFIG_FILE': None, - 'CUSTOMBINPATH': None, - 'DEBUG': False, - 'DELETE_LOG': None, - 'DIALOG': None, - 'LOGOS_LOG': os.path.expanduser(f"~/.local/state/FaithLife-Community/{name_binary}.log"), # noqa: E501 - 'wine_log': os.path.expanduser("~/.local/state/FaithLife-Community/wine.log"), # noqa: #E501 - 'LOGOS_EXE': None, - 'LOGOS_EXECUTABLE': None, - 'LOGOS_VERSION': None, - 'LOGOS64_MSI': "Logos-x64.msi", - 'LOGOS64_URL': None, - 'REINSTALL_DEPENDENCIES': False, - 'SELECTED_APPIMAGE_FILENAME': None, - 'SKIP_DEPENDENCIES': False, - 'SKIP_FONTS': False, - 'SKIP_WINETRICKS': False, - 'use_python_dialog': None, - 'VERBOSE': False, - 'WINEBIN_CODE': None, - 'WINEDEBUG': "fixme-all,err-all", - 'WINEDLLOVERRIDES': '', - 'WINEPREFIX': None, - 'WINE_EXE': None, - 'WINESERVER_EXE': None, - 'WINETRICKS_UNATTENDED': None, -} -for key, default in extended_config.items(): - globals()[key] = os.getenv(key, default) - -# Set other run-time variables not set in the env. -ACTION: str = 'app' -APPIMAGE_FILE_PATH: Optional[str] = None -BADPACKAGES: Optional[str] = None # This isn't presently used, but could be if needed. -DEFAULT_CONFIG_PATH = os.path.expanduser(f"~/.config/FaithLife-Community/{name_binary}.json") # noqa: E501 -FLPRODUCTi: Optional[str] = None -INSTALL_STEP: int = 0 -INSTALL_STEPS_COUNT: int = 0 -architecture = None -bits = None -ELFPACKAGES = None -L9PACKAGES = None -LEGACY_CONFIG_FILES = [ - os.path.expanduser("~/.config/FaithLife-Community/Logos_on_Linux.json"), # noqa: E501 - os.path.expanduser("~/.config/Logos_on_Linux/Logos_on_Linux.conf") # noqa: E501 -] -LLI_AUTHOR = "Ferion11, John Goodman, T. H. Wright, N. Marti" -LLI_CURRENT_VERSION = "4.0.0-beta.4" -LLI_LATEST_VERSION: Optional[str] = None -LLI_TITLE = name_app -LOG_LEVEL = logging.WARNING -LOGOS_BLUE = '#0082FF' -LOGOS_GRAY = '#E7E7E7' -LOGOS_WHITE = '#FCFCFC' -# LOGOS_WHITE = '#F7F7F7' -LOGOS_DIR = os.path.dirname(LOGOS_EXE) if LOGOS_EXE else None # noqa: F821 -LOGOS_FORCE_ROOT: bool = False -LOGOS_ICON_FILENAME: Optional[str] = None -LOGOS_ICON_URL: Optional[str] = None -LOGOS_LATEST_VERSION_FILENAME = name_binary -LOGOS_LATEST_VERSION_URL: Optional[str] = None -LOGOS9_RELEASES = None # used to save downloaded releases list # FIXME: not set #noqa: E501 -LOGOS9_WINE64_BOTTLE_TARGZ_NAME = "wine64_bottle.tar.gz" -LOGOS9_WINE64_BOTTLE_TARGZ_URL = f"https://github.com/ferion11/wine64_bottle_dotnet/releases/download/v5.11b/{LOGOS9_WINE64_BOTTLE_TARGZ_NAME}" # noqa: E501 -LOGOS10_RELEASES = None # used to save downloaded releases list # FIXME: not set #noqa: E501 -MYDOWNLOADS: Optional[str] = None # FIXME: Should this use ~/.cache? -OS_NAME: Optional[str] = None -OS_RELEASE: Optional[str] = None -PACKAGE_MANAGER_COMMAND_INSTALL: Optional[list[str]] = None -PACKAGE_MANAGER_COMMAND_REMOVE: Optional[list[str]] = None -PACKAGE_MANAGER_COMMAND_QUERY: Optional[list[str]] = None -PACKAGES: Optional[str] = None -PASSIVE: Optional[bool] = None -pid_file = f'/tmp/{name_binary}.pid' -PRESENT_WORKING_DIRECTORY: str = os.getcwd() -QUERY_PREFIX: Optional[str] = None -REBOOT_REQUIRED: Optional[str] = None -RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME: Optional[str] = None -RECOMMENDED_WINE64_APPIMAGE_FULL_VERSION: Optional[str] = None -RECOMMENDED_WINE64_APPIMAGE_FILENAME: Optional[str] = None -RECOMMENDED_WINE64_APPIMAGE_VERSION: Optional[str] = None -RECOMMENDED_WINE64_APPIMAGE_BRANCH: Optional[str] = None -SUPERUSER_COMMAND: Optional[str] = None -VERBUM_PATH: Optional[str] = None -WINETRICKS_URL = "https://raw.githubusercontent.com/Winetricks/winetricks/5904ee355e37dff4a3ab37e1573c56cffe6ce223/src/winetricks" # noqa: E501 -WINETRICKS_VERSION = '20220411' -wine_user = None -WORKDIR = tempfile.mkdtemp(prefix="/tmp/LBS.") -install_finished = False -console_log = [] -margin = 2 -console_log_lines = 1 -current_option = 0 -current_page = 0 -total_pages = 0 -options_per_page = 8 -resizing = False -processes = {} -threads = [] -logos_login_cmd = None -logos_cef_cmd = None -logos_indexer_cmd = None -logos_indexer_exe = None -logos_linux_installer_status = None -logos_linux_installer_status_info = { - 0: "yes", - 1: "uptodate", - 2: "no", - None: "config.LLI_CURRENT_VERSION or config.LLI_LATEST_VERSION is not set.", # noqa: E501 -} -check_if_indexing = None - - -def get_config_file_dict(config_file_path): - config_dict = {} - if config_file_path.endswith('.json'): - try: +from ou_dedetai.constants import PROMPT_OPTION_DIRECTORY + +@dataclass +class LegacyConfiguration: + """Configuration and it's keys from before the user configuration class existed. + + Useful for one directional compatibility""" + # Legacy Core Configuration + FLPRODUCT: Optional[str] = None + TARGETVERSION: Optional[str] = None + TARGET_RELEASE_VERSION: Optional[str] = None + current_logos_version: Optional[str] = None # Unused in new code + curses_colors: Optional[str] = None + INSTALLDIR: Optional[str] = None + WINETRICKSBIN: Optional[str] = None + WINEBIN_CODE: Optional[str] = None + WINE_EXE: Optional[str] = None + WINECMD_ENCODING: Optional[str] = None + LOGS: Optional[str] = None + BACKUPDIR: Optional[str] = None + LAST_UPDATED: Optional[str] = None # Unused in new code + RECOMMENDED_WINE64_APPIMAGE_URL: Optional[str] = None # Unused in new code + LLI_LATEST_VERSION: Optional[str] = None # Unused in new code + logos_release_channel: Optional[str] = None + lli_release_channel: Optional[str] = None + + # Legacy Extended Configuration + APPIMAGE_LINK_SELECTION_NAME: Optional[str] = None + APPDIR_BINDIR: Optional[str] = None + CHECK_UPDATES: Optional[bool] = None + CONFIG_FILE: Optional[str] = None + CUSTOMBINPATH: Optional[str] = None + DEBUG: Optional[bool] = None + DELETE_LOG: Optional[str] = None + DIALOG: Optional[str] = None # Unused in new code + LOGOS_LOG: Optional[str] = None + wine_log: Optional[str] = None + LOGOS_EXE: Optional[str] = None # Unused in new code + # This is the logos installer executable name (NOT path) + LOGOS_EXECUTABLE: Optional[str] = None + LOGOS_VERSION: Optional[str] = None + # This wasn't overridable in the bash version of this installer (at 554c9a6), + # nor was it used in the python version (at 8926435) + # LOGOS64_MSI: Optional[str] + LOGOS64_URL: Optional[str] = None + SELECTED_APPIMAGE_FILENAME: Optional[str] = None + SKIP_DEPENDENCIES: Optional[bool] = None + SKIP_FONTS: Optional[bool] = None + SKIP_WINETRICKS: Optional[bool] = None + use_python_dialog: Optional[str] = None + VERBOSE: Optional[bool] = None + WINEDEBUG: Optional[str] = None + WINEDLLOVERRIDES: Optional[str] = None + WINEPREFIX: Optional[str] = None + WINESERVER_EXE: Optional[str] = None + WINETRICKS_UNATTENDED: Optional[str] = None + + @classmethod + def bool_keys(cls) -> list[str]: + """Returns a list of keys that are of type bool""" + return [ + "VERBOSE", + "SKIP_WINETRICKS", + "SKIP_FONTS", + "SKIP_DEPENDENCIES", + "DEBUG", + "CHECK_UPDATES" + ] + + @classmethod + def config_file_path(cls) -> str: + return os.getenv("CONFIG_FILE") or constants.DEFAULT_CONFIG_PATH + + @classmethod + def load(cls) -> "LegacyConfiguration": + """Find the relevant config file and load it""" + # Update config from CONFIG_FILE. + config_file_path = LegacyConfiguration.config_file_path() + # This moves the first legacy config to the new location + if not utils.file_exists(config_file_path): + for legacy_config in constants.LEGACY_CONFIG_FILES: + if utils.file_exists(legacy_config): + os.rename(legacy_config, config_file_path) + break + # This may be a config that used to be in the legacy location + # Now it's all in the same location + return LegacyConfiguration.load_from_path(config_file_path) + + @classmethod + def load_from_path(cls, config_file_path: str) -> "LegacyConfiguration": + config_dict: dict[str, str] = {} + + if not Path(config_file_path).exists(): + pass + elif config_file_path.endswith('.json'): + try: + with open(config_file_path, 'r') as config_file: + cfg = json.load(config_file) + + for key, value in cfg.items(): + config_dict[key] = value + except TypeError as e: + logging.error("Error opening Config file.") + logging.error(e) + raise e + except FileNotFoundError: + logging.info(f"No config file not found at {config_file_path}") + except json.JSONDecodeError as e: + logging.error("Config file could not be read.") + logging.error(e) + raise e + elif config_file_path.endswith('.conf'): + # Legacy config from bash script. + logging.info("Reading from legacy config file.") with open(config_file_path, 'r') as config_file: - cfg = json.load(config_file) - - for key, value in cfg.items(): - config_dict[key] = value - return config_dict - except TypeError as e: - logging.error("Error opening Config file.") - logging.error(e) + for line in config_file: + line = line.strip() + if len(line) == 0: # skip blank lines + continue + if line[0] == '#': # skip commented lines + continue + parts = line.split('=') + if len(parts) == 2: + value = parts[1].strip('"').strip("'") # remove quotes + vparts = value.split('#') # get rid of potential comment + if len(vparts) > 1: + value = vparts[0].strip().strip('"').strip("'") + config_dict[parts[0]] = value + + # Now restrict the key values pairs to just those found in LegacyConfiguration + output: dict = {} + config_env = LegacyConfiguration.load_from_env().__dict__ + # Now update from ENV + for var, env_var in config_env.items(): + if env_var is not None: + output[var] = env_var + elif var in config_dict: + if var in LegacyConfiguration.bool_keys(): + output[var] = utils.parse_bool(config_dict[var]) + else: + output[var] = config_dict[var] + + # Populate the path this config was loaded from + output["CONFIG_FILE"] = config_file_path + + return LegacyConfiguration(**output) + + @classmethod + def load_from_env(cls) -> "LegacyConfiguration": + output: dict = {} + # Now update from ENV + for var in LegacyConfiguration().__dict__.keys(): + env_var = os.getenv(var) + if env_var is not None: + if var in LegacyConfiguration.bool_keys(): + output[var] = utils.parse_bool(env_var) + else: + output[var] = env_var + return LegacyConfiguration(**output) + + +@dataclass +class EphemeralConfiguration: + """A set of overrides that don't need to be stored. + + Populated from environment/command arguments/etc + + Changes to this are not saved to disk, but remain while the program runs + """ + + # See naming conventions in Config + + # Start user overridable via env or cli arg + installer_binary_dir: Optional[str] + install_dir: Optional[str] + wineserver_binary: Optional[str] + faithlife_product_version: Optional[str] + faithlife_installer_name: Optional[str] + faithlife_installer_download_url: Optional[str] + log_level: Optional[str | int] + app_log_path: Optional[str] + app_wine_log_path: Optional[str] + """Path to log wine's output to""" + app_winetricks_unattended: Optional[bool] + """Whether or not to send -q to winetricks for all winetricks commands. + + Some commands always send -q""" + + winetricks_skip: Optional[bool] + install_dependencies_skip: Optional[bool] + """Whether to skip installing system package dependencies""" + install_fonts_skip: Optional[bool] + """Whether to skip installing fonts in the wineprefix""" + + wine_dll_overrides: Optional[str] + """Corresponds to wine's WINEDLLOVERRIDES""" + wine_debug: Optional[str] + """Corresponds to wine's WINEDEBUG""" + wine_prefix: Optional[str] + """Corresponds to wine's WINEPREFIX""" + wine_output_encoding: Optional[str] + """Override for what encoding wine's output is using""" + + # FIXME: seems like the wine appimage logic can be simplified + wine_appimage_link_file_name: Optional[str] + """Symlink file name to the active wine appimage.""" + + wine_appimage_path: Optional[str] + """Path to the selected appimage""" + + # FIXME: consider using PATH instead? (and storing this legacy env in PATH for this process) # noqa: E501 + custom_binary_path: Optional[str] + """Additional path to look for when searching for binaries.""" + + delete_log: Optional[bool] + """Whether to clear the log on startup""" + + check_updates_now: Optional[bool] + """Whether or not to check updates regardless of if one's due""" + + # Start internal values + config_path: str + """Path this config was loaded from""" + + assume_yes: bool = False + """Whether to assume yes to all prompts or ask the user + + Useful for non-interactive installs""" + + quiet: bool = False + """Whether or not to output any non-error messages""" + + dialog: Optional[str] = None + """Override if we want to select a specific type of front-end + + Accepted values: tk (GUI), curses (TUI), cli (CLI)""" + + winetricks_args: Optional[list[str]] = None + """Arguments to winetricks if the action is running winetricks""" + + terminal_app_prefer_dialog: Optional[bool] = None + + # Start of values just set via cli arg + faithlife_install_passive: bool = False + app_run_as_root_permitted: bool = False + + @classmethod + def from_legacy(cls, legacy: LegacyConfiguration) -> "EphemeralConfiguration": + log_level = None + wine_debug = legacy.WINEDEBUG + if legacy.DEBUG: + log_level = logging.DEBUG + # FIXME: shouldn't this leave it untouched or fall back to default: `fixme-all,err-all`? # noqa: E501 + wine_debug = "" + elif legacy.VERBOSE: + log_level = logging.INFO + wine_debug = "" + app_winetricks_unattended = None + if legacy.WINETRICKS_UNATTENDED is not None: + app_winetricks_unattended = utils.parse_bool(legacy.WINETRICKS_UNATTENDED) + delete_log = None + if legacy.DELETE_LOG is not None: + delete_log = utils.parse_bool(legacy.DELETE_LOG) + config_file = constants.DEFAULT_CONFIG_PATH + if legacy.CONFIG_FILE is not None: + config_file = legacy.CONFIG_FILE + terminal_app_prefer_dialog = None + if legacy.use_python_dialog is not None: + terminal_app_prefer_dialog = utils.parse_bool(legacy.use_python_dialog) + install_dir = None + if legacy.INSTALLDIR is not None: + install_dir = str(Path(os.path.expanduser(legacy.INSTALLDIR)).absolute()) + return EphemeralConfiguration( + installer_binary_dir=legacy.APPDIR_BINDIR, + wineserver_binary=legacy.WINESERVER_EXE, + custom_binary_path=legacy.CUSTOMBINPATH, + faithlife_product_version=legacy.LOGOS_VERSION, + faithlife_installer_name=legacy.LOGOS_EXECUTABLE, + faithlife_installer_download_url=legacy.LOGOS64_URL, + winetricks_skip=legacy.SKIP_WINETRICKS, + log_level=log_level, + wine_debug=wine_debug, + wine_dll_overrides=legacy.WINEDLLOVERRIDES, + wine_prefix=legacy.WINEPREFIX, + app_wine_log_path=legacy.wine_log, + app_log_path=legacy.LOGOS_LOG, + app_winetricks_unattended=app_winetricks_unattended, + config_path=config_file, + check_updates_now=legacy.CHECK_UPDATES, + delete_log=delete_log, + install_dependencies_skip=legacy.SKIP_DEPENDENCIES, + install_fonts_skip=legacy.SKIP_FONTS, + wine_appimage_link_file_name=legacy.APPIMAGE_LINK_SELECTION_NAME, + wine_appimage_path=legacy.SELECTED_APPIMAGE_FILENAME, + wine_output_encoding=legacy.WINECMD_ENCODING, + terminal_app_prefer_dialog=terminal_app_prefer_dialog, + dialog=legacy.DIALOG, + install_dir=install_dir + ) + + @classmethod + def load(cls) -> "EphemeralConfiguration": + return EphemeralConfiguration.from_legacy(LegacyConfiguration.load()) + + @classmethod + def load_from_path(cls, path: str) -> "EphemeralConfiguration": + return EphemeralConfiguration.from_legacy(LegacyConfiguration.load_from_path(path)) # noqa: E501 + + +@dataclass +class PersistentConfiguration: + """This class stores the options the user chose + + Normally shouldn't be used directly, as it's types may be None, + doesn't handle updates. Use through the `App`'s `Config` instead. + + Easy reading to/from JSON and supports legacy keys + + These values should be stored across invocations + + MUST be saved explicitly + """ + + # See naming conventions in Config + + faithlife_product: Optional[str] = None + faithlife_product_version: Optional[str] = None + faithlife_product_release: Optional[str] = None + faithlife_product_logging: Optional[bool] = None + install_dir: Optional[str] = None + winetricks_binary: Optional[str] = None + wine_binary: Optional[str] = None + # This is where to search for wine + wine_binary_code: Optional[str] = None + backup_dir: Optional[str] = None + + # Color to use in curses. Either "Logos", "Light", or "Dark" + curses_colors: str = "Logos" + # Faithlife's release channel. Either "stable" or "beta" + faithlife_product_release_channel: str = "stable" + # The Installer's release channel. Either "stable" or "beta" + app_release_channel: str = "stable" + + _legacy: Optional[LegacyConfiguration] = None + """A Copy of the legacy configuration. + + Merge this when writing. + Kept just in case the user wants to go back to an older installer version + """ + + @classmethod + def load_from_path(cls, config_file_path: str) -> "PersistentConfiguration": + # First read in the legacy configuration + legacy = LegacyConfiguration.load_from_path(config_file_path) + new_config: PersistentConfiguration = PersistentConfiguration.from_legacy(legacy) #noqa: E501 + + new_keys = new_config.__dict__.keys() + + config_dict = new_config.__dict__ + + # Check to see if this config is actually "legacy" + if len([k for k, v in legacy.__dict__.items() if v is not None]) > 1: + config_dict["_legacy"] = legacy + + if Path(config_file_path).exists(): + if config_file_path.endswith('.json'): + with open(config_file_path, 'r') as config_file: + cfg = json.load(config_file) + + for key, value in cfg.items(): + if key in new_keys: + config_dict[key] = value + else: + logging.info("Not reading new values from non-json config") + else: + logging.info("Not reading new values from non-existent config") + + # Now override with values from ENV + config_env = PersistentConfiguration.from_legacy(LegacyConfiguration.load_from_env()) #noqa: E501 + for k, v in config_env.__dict__.items(): + if v is not None: + config_dict[k] = v + + return PersistentConfiguration(**config_dict) + + @classmethod + def from_legacy(cls, legacy: LegacyConfiguration) -> "PersistentConfiguration": + faithlife_product_logging = None + if legacy.LOGS is not None: + faithlife_product_logging = utils.parse_bool(legacy.LOGS) + winetricks_binary = None + if ( + legacy.WINETRICKSBIN is not None + and legacy.WINETRICKSBIN != constants.DOWNLOAD + ): + winetricks_binary = legacy.WINETRICKSBIN + install_dir = None + if legacy.INSTALLDIR is not None: + install_dir = str(Path(os.path.expanduser(legacy.INSTALLDIR)).absolute()) + return PersistentConfiguration( + faithlife_product=legacy.FLPRODUCT, + backup_dir=legacy.BACKUPDIR, + curses_colors=legacy.curses_colors or 'Logos', + faithlife_product_release=legacy.TARGET_RELEASE_VERSION, + faithlife_product_release_channel=legacy.logos_release_channel or 'stable', + faithlife_product_version=legacy.TARGETVERSION, + install_dir=install_dir, + app_release_channel=legacy.lli_release_channel or 'stable', + wine_binary=legacy.WINE_EXE, + wine_binary_code=legacy.WINEBIN_CODE, + winetricks_binary=winetricks_binary, + faithlife_product_logging=faithlife_product_logging, + _legacy=legacy + ) + + def write_config(self) -> None: + config_file_path = LegacyConfiguration.config_file_path() + # Copy the values into a flat structure for easy json dumping + output = copy.deepcopy(self.__dict__) + # Merge the legacy dictionary if present + if self._legacy is not None: + output |= self._legacy.__dict__ + + # Remove all keys starting with _ (to remove legacy from the saved blob) + for k in list(output.keys()): + if ( + k.startswith("_") + or output[k] is None + or k == "CONFIG_FILE" + ): + del output[k] + + logging.info(f"Writing config to {config_file_path}") + os.makedirs(os.path.dirname(config_file_path), exist_ok=True) + + if self.install_dir is not None: + # Ensure all paths stored are relative to install_dir + for k, v in output.items(): + if k in ["install_dir", "INSTALLDIR", "WINETRICKSBIN"]: + if v is not None: + output[k] = str(v) + continue + if (isinstance(v, str) and v.startswith(self.install_dir)): #noqa: E501 + output[k] = utils.get_relative_path(v, self.install_dir) + + try: + with open(config_file_path, 'w') as config_file: + # Write this into a string first to avoid partial writes + # if encoding fails (which it shouldn't) + json_str = json.dumps(output, indent=4, sort_keys=True) + config_file.write(json_str) + config_file.write('\n') + except IOError as e: + logging.error(f"Error writing to config file {config_file_path}: {e}") # noqa: E501 + # Continue, the installer can still operate even if it fails to write. + + +# Needed this logic outside this class too for before when when the app is initialized +def get_wine_prefix_path(install_dir: str) -> str: + return f"{install_dir}/data/wine64_bottle" + +class Config: + """Set of configuration values. + + If the user hasn't selected a particular value yet, they will be prompted in the UI. + """ + + # Naming conventions: + # Use `dir` instead of `directory` + # Use snake_case + # prefix with faithlife_ if it's theirs + # prefix with app_ if it's ours (and otherwise not clear) + # prefix with wine_ if it's theirs + # suffix with _binary if it's a linux binary + # suffix with _exe if it's a windows binary + # suffix with _path if it's a file path + # suffix with _file_name if it's a file's name (with extension) + + # Storage for the keys + _raw: PersistentConfiguration + + # Overriding programmatically generated values from ENV + _overrides: EphemeralConfiguration + + _network: network.NetworkRequests + + # Start Cache of values unlikely to change during operation. + # i.e. filesystem traversals + _logos_exe: Optional[str] = None + _download_dir: Optional[str] = None + _wine_output_encoding: Optional[str] = None + _installed_faithlife_product_release: Optional[str] = None + _wine_binary_files: Optional[list[str]] = None + _wine_appimage_files: Optional[list[str]] = None + + # Start constants + _curses_colors_valid_values = ["Light", "Dark", "Logos"] + + # Singleton logic, this enforces that only one config object exists at a time. + def __new__(cls, *args, **kwargs) -> "Config": + if not hasattr(cls, '_instance'): + cls._instance = super(Config, cls).__new__(cls) + return cls._instance + + def __init__(self, ephemeral_config: EphemeralConfiguration, app) -> None: + from ou_dedetai.app import App + self.app: "App" = app + self._raw = PersistentConfiguration.load_from_path(ephemeral_config.config_path) + self._overrides = ephemeral_config + + self._network = network.NetworkRequests(ephemeral_config.check_updates_now) + + logging.debug("Current persistent config:") + for k, v in self._raw.__dict__.items(): + logging.debug(f"{k}: {v}") + logging.debug("Current ephemeral config:") + for k, v in self._overrides.__dict__.items(): + logging.debug(f"{k}: {v}") + logging.debug("Current network cache:") + for k, v in self._network._cache.__dict__.items(): + logging.debug(f"{k}: {v}") + logging.debug("End config dump") + + def _ask_if_not_found(self, parameter: str, question: str, options: list[str], dependent_parameters: Optional[list[str]] = None) -> str: #noqa: E501 + if not getattr(self._raw, parameter): + if dependent_parameters is not None: + for dependent_config_key in dependent_parameters: + setattr(self._raw, dependent_config_key, None) + answer = self.app.ask(question, options) + # Use the setter on this class if found, otherwise set in self._user + if getattr(Config, parameter) and getattr(Config, parameter).fset is not None: # noqa: E501 + getattr(Config, parameter).fset(self, answer) + else: + setattr(self._raw, parameter, answer) + self._write() + # parameter given should be a string + return str(getattr(self._raw, parameter)) + + def _write(self) -> None: + """Writes configuration to file and lets the app know something changed""" + self._raw.write_config() + self.app._config_updated_event.set() + + def _relative_from_install_dir(self, path: Path | str) -> str: + """Takes in a possibly absolute path under install dir and turns it into an + relative path if it is + + Args: + path - can be absolute or relative to install dir + + Returns: + path - absolute + """ + output = str(path) + if Path(path).is_absolute() and output.startswith(self.install_dir): + output = output[len(self.install_dir):].lstrip("/") + return output + + def _absolute_from_install_dir(self, path: Path | str) -> str: + """Takes in a possibly relative path under install dir and turns it into an + absolute path + + Args: + path - can be absolute or relative to install dir + + Returns: + path - absolute + """ + if not Path(path).is_absolute(): + return str(Path(self.install_dir) / path) + return str(path) + + def reload(self): + """Re-loads the configuration file on disk""" + self._raw = PersistentConfiguration.load_from_path(self._overrides.config_path) + # Also clear out our cached values + self._logos_exe = self._download_dir = self._wine_output_encoding = None + self._installed_faithlife_product_release = self._wine_binary_files = None + self._wine_appimage_files = None + + self.app._config_updated_event.set() + + @property + def config_file_path(self) -> str: + return LegacyConfiguration.config_file_path() + + @property + def faithlife_product(self) -> str: + question = "Choose which FaithLife product the script should install: " # noqa: E501 + options = constants.FAITHLIFE_PRODUCTS + return self._ask_if_not_found("faithlife_product", question, options, ["faithlife_product_version", "faithlife_product_release"]) # noqa: E501 + + @faithlife_product.setter + def faithlife_product(self, value: Optional[str]): + if self._raw.faithlife_product != value: + self._raw.faithlife_product = value + # Reset dependent variables + self.faithlife_product_version = None # type: ignore[assignment] + + self._write() + + @property + def faithlife_product_version(self) -> str: + if self._overrides.faithlife_product_version is not None: + return self._overrides.faithlife_product_version + question = f"Which version of {self.faithlife_product} should the script install?: " # noqa: E501 + options = constants.FAITHLIFE_PRODUCT_VERSIONS + return self._ask_if_not_found("faithlife_product_version", question, options, []) # noqa: E501 + + @faithlife_product_version.setter + def faithlife_product_version(self, value: Optional[str]): + if self._raw.faithlife_product_version != value: + self._raw.faithlife_product_version = value + # Set dependents + self._raw.faithlife_product_release = None + # Install Dir has the name of the product and it's version. Reset it too + if self._overrides.install_dir is None: + self._raw.install_dir = None + # Wine is dependent on the product/version selected + self._raw.wine_binary = None + self._raw.wine_binary_code = None + self._raw.winetricks_binary = None + + self._write() + + @property + def faithlife_product_releases(self) -> list[str]: + return self._network.faithlife_product_releases( + product=self.faithlife_product, + version=self.faithlife_product_version, + channel=self.faithlife_product_release_channel + ) + + @property + def faithlife_product_release(self) -> str: + question = f"Which version of {self.faithlife_product} {self.faithlife_product_version} do you want to install?: " # noqa: E501 + options = self.faithlife_product_releases + return self._ask_if_not_found("faithlife_product_release", question, options) + + @faithlife_product_release.setter + def faithlife_product_release(self, value: str): + if self._raw.faithlife_product_release != value: + self._raw.faithlife_product_release = value + # Reset dependents + self._wine_binary_files = None + self._write() + + @property + def faithlife_product_icon_path(self) -> str: + return str(constants.APP_IMAGE_DIR / f"{self.faithlife_product}-128-icon.png") + + @property + def faithlife_product_logging(self) -> bool: + """Whether or not the installed faithlife product is configured to log""" + if self._raw.faithlife_product_logging is not None: + return self._raw.faithlife_product_logging + return False + + @faithlife_product_logging.setter + def faithlife_product_logging(self, value: bool): + if self._raw.faithlife_product_logging != value: + self._raw.faithlife_product_logging = value + self._write() + + @property + def faithlife_installer_name(self) -> str: + if self._overrides.faithlife_installer_name is not None: + return self._overrides.faithlife_installer_name + return f"{self.faithlife_product}_v{self.faithlife_product_release}-x64.msi" + + @property + def faithlife_installer_download_url(self) -> str: + if self._overrides.faithlife_installer_download_url is not None: + return self._overrides.faithlife_installer_download_url + after_version_url_part = "/Verbum/" if self.faithlife_product == "Verbum" else "/" # noqa: E501 + return f"https://downloads.logoscdn.com/LBS{self.faithlife_product_version}{after_version_url_part}Installer/{self.faithlife_product_release}/{self.faithlife_product}-x64.msi" # noqa: E501 + + @property + def faithlife_product_release_channel(self) -> str: + return self._raw.faithlife_product_release_channel + + @property + def app_release_channel(self) -> str: + return self._raw.app_release_channel + + @property + def winetricks_binary(self) -> str: + """Download winetricks if it doesn't exist""" + from ou_dedetai import system + if ( + self._raw.winetricks_binary is not None and + not Path(self._absolute_from_install_dir(self._raw.winetricks_binary)).exists() #noqa: E501 + ): + logging.info("Given winetricks doesn't exist. Downloading from internet") + self._raw.winetricks_binary = None + + if ( + self._winetricks_binary is None + # Informs mypy of the type without relying on implementation of + # self._winetricks_binary + or self._raw.winetricks_binary is None + ): + self._raw.winetricks_binary = system.install_winetricks( + self.installer_binary_dir, + app=self.app, + status_messages=False + ) + return self._absolute_from_install_dir(self._raw.winetricks_binary) + + @winetricks_binary.setter + def winetricks_binary(self, value: Optional[str | Path]): + if value is not None: + # Legacy had this value be "Download" when the user wanted the default + # Now we encode that in None + if value == constants.DOWNLOAD: + value = None + else: + value = self._relative_from_install_dir(value) + if value is not None: + if not Path(self._absolute_from_install_dir(value)).exists(): + raise ValueError("Winetricks binary must exist") + if self._raw.winetricks_binary != value: + if value is not None: + self._raw.winetricks_binary = self._relative_from_install_dir(value) + else: + self._raw.winetricks_binary = None + self._write() + + @property + def _winetricks_binary(self) -> Optional[str]: + """Get the path to winetricks + + Prompt if the user has any choices besides download + """ + question = f"Should the script use the system's local winetricks or download the latest winetricks from the Internet? The script needs to set some Wine options that {self.faithlife_product} requires on Linux." # noqa: E501 + options = utils.get_winetricks_options() + # Only prompt if the user has other options besides Downloading. + # the legacy WINETRICKSBIN config key is still supported. + if len(options) == 1: + # Use whatever we have stored + output = self._raw.winetricks_binary + else: + if self._raw.winetricks_binary is None: + self.winetricks_binary = self.app.ask(question, options) + output = self._raw.winetricks_binary + + if output is not None: + return self._absolute_from_install_dir(output) + else: return None - except FileNotFoundError: - logging.info(f"No config file not found at {config_file_path}") - return config_dict - except json.JSONDecodeError as e: - logging.error("Config file could not be read.") - logging.error(e) + + @property + def install_dir_default(self) -> str: + return f"{str(Path.home())}/{self.faithlife_product}Bible{self.faithlife_product_version}" # noqa: E501 + + @property + def install_dir(self) -> str: + if self._overrides.install_dir: + return self._overrides.install_dir + default = self.install_dir_default + question = f"Where should {self.faithlife_product} files be installed to?: " # noqa: E501 + options = [default, PROMPT_OPTION_DIRECTORY] + output = self._ask_if_not_found("install_dir", question, options) + return output + + @install_dir.setter + def install_dir(self, value: str | Path): + value = str(Path(value).absolute()) + if self._raw.install_dir != value: + self._raw.install_dir = value + # Reset cache that depends on install_dir + self._wine_appimage_files = None + self._write() + + @property + # This used to be called APPDIR_BINDIR + def installer_binary_dir(self) -> str: + if self._overrides.installer_binary_dir is not None: + return self._overrides.installer_binary_dir + return f"{self.install_dir}/data/bin" + + @property + # This used to be called WINEPREFIX + def wine_prefix(self) -> str: + if self._overrides.wine_prefix is not None: + return self._overrides.wine_prefix + return get_wine_prefix_path(self.install_dir) + + @property + def wine_binary(self) -> str: + """Returns absolute path to the wine binary""" + output = self._raw.wine_binary + if output is None: + question = f"Which Wine AppImage or binary should the script use to install {self.faithlife_product} v{self.faithlife_product_version} in {self.install_dir}?: " # noqa: E501 + options = utils.get_wine_options(self.app) + + choice = self.app.ask(question, options) + + output = choice + self.wine_binary = choice + # Return the full path so we the callee doesn't need to think about it + if self._raw.wine_binary is not None and not Path(self._raw.wine_binary).exists() and (Path(self.install_dir) / self._raw.wine_binary).exists(): # noqa: E501 + return str(Path(self.install_dir) / self._raw.wine_binary) + return output + + @wine_binary.setter + def wine_binary(self, value: str): + """Takes in a path to the wine binary and stores it as relative for storage""" + # Make the path absolute for comparison + relative = self._relative_from_install_dir(value) + # FIXME: consider this, it doesn't work at present as the wine_binary may be an + # appimage that hasn't been downloaded yet + # aboslute = self._absolute_from_install_dir(value) + # if not Path(aboslute).is_file(): + # raise ValueError("Wine Binary path must be a valid file") + + if self._raw.wine_binary != relative: + self._raw.wine_binary = relative + # Reset dependents + self._raw.wine_binary_code = None + self._overrides.wine_appimage_path = None + self._write() + + @property + def wine_binary_files(self) -> list[str]: + if self._wine_binary_files is None: + self._wine_binary_files = utils.find_wine_binary_files( + self.app, + self._raw.faithlife_product_release + ) + return self._wine_binary_files + + @property + def wine_app_image_files(self) -> list[str]: + if self._wine_appimage_files is None: + self._wine_appimage_files = utils.find_appimage_files(self.app) + return self._wine_appimage_files + + @property + def wine_binary_code(self) -> str: + """Wine binary code. + + One of: Recommended, AppImage, System, Proton, PlayOnLinux, Custom""" + if self._raw.wine_binary_code is None: + self._raw.wine_binary_code = utils.get_winebin_code_and_desc(self.app, self.wine_binary)[0] # noqa: E501 + self._write() + return self._raw.wine_binary_code + + @property + def wine64_binary(self) -> str: + return str(Path(self.wine_binary).parent / 'wine64') + + @property + # This used to be called WINESERVER_EXE + def wineserver_binary(self) -> str: + return str(Path(self.wine_binary).parent / 'wineserver') + + # FIXME: seems like the logic around wine appimages can be simplified + # Should this be folded into wine_binary? + @property + def wine_appimage_path(self) -> Optional[Path]: + """Path to the wine appimage + + Returns: + Path if wine is set to use an appimage, otherwise returns None""" + if self._overrides.wine_appimage_path is not None: + return Path(self._absolute_from_install_dir(self._overrides.wine_appimage_path)) #noqa: E501 + if self.wine_binary.lower().endswith("appimage"): + return Path(self._absolute_from_install_dir(self.wine_binary)) + return None + + @wine_appimage_path.setter + def wine_appimage_path(self, value: Optional[str | Path]) -> None: + if isinstance(value, Path): + value = str(value) + if self._overrides.wine_appimage_path != value: + self._overrides.wine_appimage_path = value + # Reset dependents + self._raw.wine_binary_code = None + # NOTE: we don't save this persistently, it's assumed + # it'll be saved under wine_binary if it's used + + @property + def wine_appimage_link_file_name(self) -> str: + if self._overrides.wine_appimage_link_file_name is not None: + return self._overrides.wine_appimage_link_file_name + return 'selected_wine.AppImage' + + @property + def wine_appimage_recommended_url(self) -> str: + """URL to recommended appimage. + + Talks to the network if required""" + return self._network.wine_appimage_recommended_url() + + @property + def wine_appimage_recommended_file_name(self) -> str: + """Returns the file name of the recommended appimage with extension""" + return os.path.basename(self.wine_appimage_recommended_url) + + @property + def wine_appimage_recommended_version(self) -> str: + # Getting version and branch rely on the filename having this format: + # wine-[branch]_[version]-[arch] + return self.wine_appimage_recommended_file_name.split('-')[1].split('_')[1] + + @property + def wine_dll_overrides(self) -> str: + """Used to set WINEDLLOVERRIDES""" + if self._overrides.wine_dll_overrides is not None: + return self._overrides.wine_dll_overrides + # Default is no overrides + return '' + + @property + def wine_debug(self) -> str: + """Used to set WINEDEBUG""" + if self._overrides.wine_debug is not None: + return self._overrides.wine_debug + return constants.DEFAULT_WINEDEBUG + + @property + def wine_output_encoding(self) -> Optional[str]: + """Attempt to guess the encoding of the wine output""" + if self._overrides.wine_output_encoding is not None: + return self._overrides.wine_output_encoding + if self._wine_output_encoding is None: + self._wine_output_encoding = wine.get_winecmd_encoding(self.app) + return self._wine_output_encoding + + @property + def app_wine_log_path(self) -> str: + if self._overrides.app_wine_log_path is not None: + return self._overrides.app_wine_log_path + return constants.DEFAULT_APP_WINE_LOG_PATH + + @property + def app_log_path(self) -> str: + if self._overrides.app_log_path is not None: + return self._overrides.app_log_path + return constants.DEFAULT_APP_LOG_PATH + + @property + def app_winetricks_unattended(self) -> bool: + """If true, pass -q to winetricks""" + if self._overrides.app_winetricks_unattended is not None: + return self._overrides.app_winetricks_unattended + return False + + def toggle_faithlife_product_release_channel(self): + if self._raw.faithlife_product_release_channel == "stable": + new_channel = "beta" + else: + new_channel = "stable" + self._raw.faithlife_product_release_channel = new_channel + self._write() + + def toggle_installer_release_channel(self): + if self._raw.app_release_channel == "stable": + new_channel = "dev" + else: + new_channel = "stable" + self._raw.app_release_channel = new_channel + self._write() + + @property + def backup_dir(self) -> Path: + question = "New or existing folder to store backups in: " + options = [PROMPT_OPTION_DIRECTORY] + output = Path(self._ask_if_not_found("backup_dir", question, options)) + output.mkdir(parents=True) + return output + + @property + def curses_colors(self) -> str: + """Color for the curses dialog + + returns one of: Logos, Light or Dark""" + return self._raw.curses_colors + + @curses_colors.setter + def curses_colors(self, value: str): + if value not in self._curses_colors_valid_values: + raise ValueError(f"Invalid curses theme, expected one of: {", ".join(self._curses_colors_valid_values)} but got: {value}") # noqa: E501 + self._raw.curses_colors = value + self._write() + + def cycle_curses_color_scheme(self): + new_index = self._curses_colors_valid_values.index(self.curses_colors) + 1 + if new_index == len(self._curses_colors_valid_values): + new_index = 0 + self.curses_colors = self._curses_colors_valid_values[new_index] + + @property + def logos_exe(self) -> Optional[str]: + # Cache a successful result + if ( + # Ensure we have all the context we need before attempting + self._logos_exe is None + and self._raw.faithlife_product is not None + and self._raw.install_dir is not None + ): + self._logos_exe = utils.find_installed_product( + self._raw.faithlife_product, + self.wine_prefix + ) + return self._logos_exe + + @property + def wine_user(self) -> Optional[str]: + path: Optional[str] = self.logos_exe + if path is None: return None - elif config_file_path.endswith('.conf'): - # Legacy config from bash script. - logging.info("Reading from legacy config file.") - with open(config_file_path, 'r') as config_file: - for line in config_file: - line = line.strip() - if len(line) == 0: # skip blank lines - continue - if line[0] == '#': # skip commented lines - continue - parts = line.split('=') - if len(parts) == 2: - value = parts[1].strip('"').strip("'") # remove quotes - vparts = value.split('#') # get rid of potential comment - if len(vparts) > 1: - value = vparts[0].strip().strip('"').strip("'") - config_dict[parts[0]] = value - return config_dict - - -def set_config_env(config_file_path): - config_dict = get_config_file_dict(config_file_path) - if config_dict is None: - return - # msg.logos_error(f"Error: Unable to get config at {config_file_path}") - logging.info(f"Setting {len(config_dict)} variables from config file.") - for key, value in config_dict.items(): - globals()[key] = value - installdir = config_dict.get('INSTALLDIR') - if installdir: - global APPDIR_BINDIR - APPDIR_BINDIR = f"{installdir}/data/bin" - - -def get_env_config(): - for var in globals().keys(): - val = os.getenv(var) - if val is not None: - logging.info(f"Setting '{var}' to '{val}'") - globals()[var] = val - - -def get_timestamp(): - return datetime.today().strftime('%Y-%m-%dT%H%M%S') + normalized_path: str = os.path.normpath(path) + path_parts = normalized_path.split(os.sep) + return path_parts[path_parts.index('users') + 1] + + @property + def logos_cef_exe(self) -> Optional[str]: + if self.wine_user is not None: + return f'C:\\users\\{self.wine_user}\\AppData\\Local\\Logos\\System\\LogosCEF.exe' # noqa: E501 + return None + + @property + def logos_indexer_exe(self) -> Optional[str]: + if self.wine_user is not None: + return f'C:\\users\\{self.wine_user}\\AppData\\Local\\Logos\\System\\LogosIndexer.exe' # noqa: E501 + return None + + @property + def logos_login_exe(self) -> Optional[str]: + if self.wine_user is not None: + return f'C:\\users\\{self.wine_user}\\AppData\\Local\\Logos\\System\\Logos.exe' # noqa: E501 + return None + + @property + def log_level(self) -> str | int: + if self._overrides.log_level is not None: + return self._overrides.log_level + return constants.DEFAULT_LOG_LEVEL + + @property + def skip_winetricks(self) -> bool: + return bool(self._overrides.winetricks_skip) + + @property + def skip_install_system_dependencies(self) -> bool: + return bool(self._overrides.install_dependencies_skip) + + @skip_install_system_dependencies.setter + def skip_install_system_dependencies(self, val: bool): + self._overrides.install_dependencies_skip = val + + @property + def skip_install_fonts(self) -> bool: + return bool(self._overrides.install_fonts_skip) + + @skip_install_fonts.setter + def skip_install_fonts(self, val: bool): + self._overrides.install_fonts_skip = val + + @property + def download_dir(self) -> str: + if self._download_dir is None: + self._download_dir = utils.get_user_downloads_dir() + return self._download_dir + + @property + def installed_faithlife_product_release(self) -> Optional[str]: + if self._installed_faithlife_product_release is None: + self._installed_faithlife_product_release = utils.get_current_logos_version(self.install_dir) # noqa: E501 + return self._installed_faithlife_product_release + + @property + def app_latest_version_url(self) -> str: + return self._network.app_latest_version(self.app_release_channel).download_url + + @property + def app_latest_version(self) -> str: + return self._network.app_latest_version(self.app_release_channel).version + + @property + def icu_latest_version(self) -> str: + return self._network.icu_latest_version().version + + @property + def icu_latest_version_url(self) -> str: + return self._network.icu_latest_version().download_url \ No newline at end of file diff --git a/ou_dedetai/constants.py b/ou_dedetai/constants.py new file mode 100644 index 00000000..a36f5c88 --- /dev/null +++ b/ou_dedetai/constants.py @@ -0,0 +1,53 @@ +import logging +import os +from pathlib import Path + +# This is relative to this file itself +APP_IMAGE_DIR = Path(__file__).parent / "img" + +# Define app name variables. +APP_NAME = 'Ou Dedetai' +BINARY_NAME = 'oudedetai' +PACKAGE_NAME = 'ou_dedetai' + +REPOSITORY_LINK = "https://github.com/FaithLife-Community/LogosLinuxInstaller" +WIKI_LINK = f"{REPOSITORY_LINK}/wiki" +TELEGRAM_LINK = "https://t.me/linux_logos" +MATRIX_LINK = "https://matrix.to/#/#logosbible:matrix.org" + +CACHE_LIFETIME_HOURS = 12 +"""How long to wait before considering our version cache invalid""" + +# Set other run-time variables not set in the env. +DEFAULT_CONFIG_PATH = os.path.expanduser(f"~/.config/FaithLife-Community/{BINARY_NAME}.json") # noqa: E501 +DEFAULT_APP_WINE_LOG_PATH= os.path.expanduser("~/.local/state/FaithLife-Community/wine.log") # noqa: E501 +DEFAULT_APP_LOG_PATH= os.path.expanduser(f"~/.local/state/FaithLife-Community/{BINARY_NAME}.log") # noqa: E501 +NETWORK_CACHE_PATH = os.path.expanduser("~/.cache/FaithLife-Community/network.json") # noqa: E501 +DEFAULT_WINEDEBUG = "fixme-all,err-all" +LEGACY_CONFIG_FILES = [ + os.path.expanduser("~/.config/FaithLife-Community/Logos_on_Linux.json"), + os.path.expanduser("~/.config/Logos_on_Linux/Logos_on_Linux.json"), + os.path.expanduser("~/.config/Logos_on_Linux/Logos_on_Linux.conf") +] +LLI_AUTHOR = "Ferion11, John Goodman, T. H. Wright, N. Marti, N. Shaaban" +LLI_CURRENT_VERSION = "4.0.0-beta.5" +DEFAULT_LOG_LEVEL = logging.WARNING +LOGOS_BLUE = '#0082FF' +LOGOS_GRAY = '#E7E7E7' +LOGOS_WHITE = '#FCFCFC' +LOGOS9_WINE64_BOTTLE_TARGZ_NAME = "wine64_bottle.tar.gz" +LOGOS9_WINE64_BOTTLE_TARGZ_URL = f"https://github.com/ferion11/wine64_bottle_dotnet/releases/download/v5.11b/{LOGOS9_WINE64_BOTTLE_TARGZ_NAME}" # noqa: E501 +PID_FILE = f'/tmp/{BINARY_NAME}.pid' +WINETRICKS_VERSION = '20220411' + +FAITHLIFE_PRODUCTS = ["Logos", "Verbum"] +FAITHLIFE_PRODUCT_VERSIONS = ["10", "9"] + +SUPPORT_MESSAGE = f"If you need help, please consult:\n{WIKI_LINK}\nIf that doesn't answer your question, please send the following files {DEFAULT_CONFIG_PATH}, {DEFAULT_APP_WINE_LOG_PATH} and {DEFAULT_APP_LOG_PATH} to one of the following group chats:\nTelegram: {TELEGRAM_LINK}\nMatrix: {MATRIX_LINK}" # noqa: E501 + +# Strings for choosing a follow up file or directory +PROMPT_OPTION_DIRECTORY = "Choose Directory" +PROMPT_OPTION_FILE = "Choose File" + +# String for when a binary is meant to be downloaded later +DOWNLOAD = "Download" diff --git a/ou_dedetai/control.py b/ou_dedetai/control.py index 10e547a4..c476e09a 100644 --- a/ou_dedetai/control.py +++ b/ou_dedetai/control.py @@ -8,153 +8,110 @@ import os import shutil import subprocess -import sys import time from pathlib import Path -from . import config -from . import msg -from . import network +from ou_dedetai.app import App + from . import system -from . import tui_curses from . import utils -def edit_config(): - subprocess.Popen(['xdg-open', config.CONFIG_FILE]) - - -def delete_log_file_contents(): - # Write empty file. - with open(config.LOGOS_LOG, 'w') as f: - f.write('') +def edit_file(config_file: str): + subprocess.Popen(['xdg-open', config_file]) -def backup(app=None): +def backup(app: App): backup_and_restore(mode='backup', app=app) -def restore(app=None): +def restore(app: App): backup_and_restore(mode='restore', app=app) +# FIXME: almost seems like this is long enough to reuse the install_step count in app +# for a more detailed progress bar # FIXME: consider moving this into it's own file/module. -def backup_and_restore(mode='backup', app=None): +def backup_and_restore(mode: str, app: App): + app.status(f"Starting {mode}…") data_dirs = ['Data', 'Documents', 'Users'] - # Ensure BACKUPDIR is defined. - if config.BACKUPDIR is None: - if config.DIALOG == 'tk': - pass # config.BACKUPDIR is already set in GUI - elif config.DIALOG == 'curses': - app.todo_e.wait() # Wait for TUI to resolve config.BACKUPDIR - app.todo_e.clear() - else: - try: - config.BACKUPDIR = input("New or existing folder to store backups in: ") # noqa: E501 - except KeyboardInterrupt: - print() - msg.logos_error("Cancelled with Ctrl+C") - config.BACKUPDIR = Path(config.BACKUPDIR).expanduser().resolve() - utils.update_config_file( - config.CONFIG_FILE, - 'BACKUPDIR', - str(config.BACKUPDIR) - ) + backup_dir = Path(app.conf.backup_dir).expanduser().resolve() - # Confirm BACKUPDIR. - if config.DIALOG == 'tk' or config.DIALOG == 'curses': - pass # user confirms in GUI or TUI - else: - verb = 'Use' if mode == 'backup' else 'Restore backup from' - if not msg.cli_question(f"{verb} existing backups folder \"{config.BACKUPDIR}\"?", ""): # noqa: E501 - answer = None - while answer is None or (mode == 'restore' and not answer.is_dir()): # noqa: E501 - answer = msg.cli_ask_filepath("Please provide a backups folder path:") - answer = Path(answer).expanduser().resolve() - if not answer.is_dir(): - msg.status(f"Not a valid folder path: {answer}", app=app) - config.BACKUPDIR = answer + verb = 'Use' if mode == 'backup' else 'Restore backup from' + if not app.approve(f"{verb} existing backups folder \"{app.conf.backup_dir}\"?"): #noqa: E501 + # Reset backup dir. + # The app will re-prompt next time the backup_dir is accessed + app.conf._raw.backup_dir = None # Set source folders. - backup_dir = Path(config.BACKUPDIR) + backup_dir = Path(app.conf.backup_dir) try: backup_dir.mkdir(exist_ok=True, parents=True) except PermissionError: verb = 'access' if mode == 'backup': verb = 'create' - msg.logos_warning(f"Can't {verb} folder: {backup_dir}") - return + app.exit(f"Can't {verb} folder: {backup_dir}") if mode == 'restore': - config.RESTOREDIR = utils.get_latest_folder(config.BACKUPDIR) - config.RESTOREDIR = Path(config.RESTOREDIR).expanduser().resolve() - if config.DIALOG == 'tk': - pass - elif config.DIALOG == 'curses': - app.screen_q.put(app.stack_confirm(24, app.todo_q, app.todo_e, - f"Restore most-recent backup?: {config.RESTOREDIR}", "", "", - dialog=config.use_python_dialog)) - app.todo_e.wait() # Wait for TUI to confirm RESTOREDIR - app.todo_e.clear() - if app.tmp == "No": - question = "Please choose a different restore folder path:" - app.screen_q.put(app.stack_input(25, app.todo_q, app.todo_e, question, f"{config.RESTOREDIR}", - dialog=config.use_python_dialog)) - app.todo_e.wait() - app.todo_e.clear() - config.RESTOREDIR = Path(app.tmp).expanduser().resolve() - else: - # Offer to restore the most recent backup. - if not msg.cli_question(f"Restore most-recent backup?: {config.RESTOREDIR}", ""): # noqa: E501 - config.RESTOREDIR = msg.cli_ask_filepath("Path to backup set that you want to restore:") # noqa: E501 - source_dir_base = config.RESTOREDIR + restore_dir = utils.get_latest_folder(app.conf.backup_dir) + restore_dir = Path(restore_dir).expanduser().resolve() + # FIXME: Shouldn't this prompt this prompt the list of backups? + # Rather than forcing the latest + # Offer to restore the most recent backup. + if not app.approve(f"Restore most-recent backup?: {restore_dir}", ""): # noqa: E501 + # Reset and re-prompt + app.conf._raw.backup_dir = None + restore_dir = utils.get_latest_folder(app.conf.backup_dir) + restore_dir = Path(restore_dir).expanduser().resolve() + source_dir_base = restore_dir else: - source_dir_base = Path(config.LOGOS_EXE).parent + if not app.conf.logos_exe: + app.exit("Cannot backup, Logos is not installed") + source_dir_base = Path(app.conf.logos_exe).parent src_dirs = [source_dir_base / d for d in data_dirs if Path(source_dir_base / d).is_dir()] # noqa: E501 logging.debug(f"{src_dirs=}") if not src_dirs: - msg.logos_warning(f"No files to {mode}", app=app) - return + app.exit(f"No files to {mode}") - if config.DIALOG == 'curses': - if mode == 'backup': - app.screen_q.put(app.stack_text(8, app.todo_q, app.todo_e, "Backing up data…", wait=True)) - else: - app.screen_q.put(app.stack_text(8, app.todo_q, app.todo_e, "Restoring data…", wait=True)) + if mode == 'backup': + app.status("Backing up data…") + else: + app.status("Restoring data…") # Get source transfer size. - q = queue.Queue() - msg.status("Calculating backup size…", app=app) - t = utils.start_thread(utils.get_folder_group_size, src_dirs, q) + q: queue.Queue[int] = queue.Queue() + message = "Calculating backup size…" + app.status(message) + i = 0 + t = app.start_thread(utils.get_folder_group_size, src_dirs, q) try: while t.is_alive(): - msg.logos_progress() + i += 1 + i = i % 20 + app.status(f"{message}{"." * i}\r") time.sleep(0.5) - print() except KeyboardInterrupt: print() - msg.logos_error("Cancelled with Ctrl+C.", app=app) + app.exit("Cancelled with Ctrl+C.") t.join() - if config.DIALOG == 'tk': - app.root.event_generate('<>') - app.root.event_generate('<>') src_size = q.get() if src_size == 0: - msg.logos_warning(f"Nothing to {mode}!", app=app) - return + app.exit(f"Nothing to {mode}!") # Set destination folder. if mode == 'restore': - dst_dir = Path(config.LOGOS_EXE).parent + if not app.conf.logos_exe: + app.exit("Cannot restore, Logos is not installed") + dst_dir = Path(app.conf.logos_exe).parent # Remove existing data. for d in data_dirs: dst = Path(dst_dir) / d if dst.is_dir(): shutil.rmtree(dst) else: # backup mode - timestamp = config.get_timestamp().replace('-', '') - current_backup_name = f"{config.FLPRODUCT}{config.TARGETVERSION}-{timestamp}" # noqa: E501 + timestamp = utils.get_timestamp().replace('-', '') + current_backup_name = f"{app.conf.faithlife_product}{app.conf.faithlife_product_version}-{timestamp}" # noqa: E501 dst_dir = backup_dir / current_backup_name logging.debug(f"Backup directory path: \"{dst_dir}\".") @@ -162,47 +119,34 @@ def backup_and_restore(mode='backup', app=None): try: dst_dir.mkdir() except FileExistsError: - msg.logos_error(f"Backup already exists: {dst_dir}.") + # This shouldn't happen, there is a timestamp in the backup_dir name + app.exit(f"Backup already exists: {dst_dir}.") # Verify disk space. if not utils.enough_disk_space(dst_dir, src_size): dst_dir.rmdir() - msg.logos_warning(f"Not enough free disk space for {mode}.", app=app) - return + app.exit(f"Not enough free disk space for {mode}.") # Run file transfer. if mode == 'restore': m = f"Restoring backup from {str(source_dir_base)}…" else: m = f"Backing up to {str(dst_dir)}…" - msg.status(m, app=app) - msg.status("Calculating destination directory size", app=app) - dst_dir_size = utils.get_path_size(dst_dir) - msg.status("Starting backup…", app=app) - t = utils.start_thread(copy_data, src_dirs, dst_dir) + app.status(m) + app.status("Starting backup…") + t = app.start_thread(copy_data, src_dirs, dst_dir) try: counter = 0 while t.is_alive(): logging.debug(f"DEV: Still copying… {counter}") counter = counter + 1 - # progress = utils.get_copy_progress( - # dst_dir, - # src_size, - # dest_size_init=dst_dir_size - # ) - # utils.write_progress_bar(progress) - # if config.DIALOG == 'tk': - # app.progress_q.put(progress) - # app.root.event_generate('<>') time.sleep(1) print() except KeyboardInterrupt: print() - msg.logos_error("Cancelled with Ctrl+C.") + app.exit("Cancelled with Ctrl+C.") t.join() - if config.DIALOG == 'tk': - app.root.event_generate('<>') - logging.info(f"Finished. {src_size} bytes copied to {str(dst_dir)}") + app.status(f"Finished {mode}. {src_size} bytes copied to {str(dst_dir)}") def copy_data(src_dirs, dst_dir): @@ -210,20 +154,21 @@ def copy_data(src_dirs, dst_dir): shutil.copytree(src, Path(dst_dir) / src.name) -def remove_install_dir(): - folder = Path(config.INSTALLDIR) - if ( - folder.is_dir() - and msg.cli_question(f"Delete \"{folder}\" and all its contents?") - ): - shutil.rmtree(folder) - logging.warning(f"Deleted folder and all its contents: {folder}") - else: +def remove_install_dir(app: App): + folder = Path(app.conf.install_dir) + question = f"Delete \"{folder}\" and all its contents?" + if not folder.is_dir(): logging.info(f"Folder doesn't exist: {folder}") + return + if app.approve(question): + shutil.rmtree(folder) + logging.info(f"Deleted folder and all its contents: {folder}") -def remove_all_index_files(app=None): - logos_dir = os.path.dirname(config.LOGOS_EXE) +def remove_all_index_files(app: App): + if not app.conf.logos_exe: + app.exit("Cannot remove index files, Logos is not installed") + logos_dir = os.path.dirname(app.conf.logos_exe) index_paths = [ os.path.join(logos_dir, "Data", "*", "BibleIndex"), os.path.join(logos_dir, "Data", "*", "LibraryIndex"), @@ -241,14 +186,13 @@ def remove_all_index_files(app=None): except OSError as e: logging.error(f"Error removing {file_to_remove}: {e}") - msg.status("======= Removing all LogosBible index files done! =======") - if hasattr(app, 'status_evt'): - app.root.event_generate(app.status_evt) - sys.exit(0) + app.status("Removed all LogosBible index files!", 100) -def remove_library_catalog(): - logos_dir = os.path.dirname(config.LOGOS_EXE) +def remove_library_catalog(app: App): + if not app.conf.logos_exe: + app.exit("Cannot remove library catalog, Logos is not installed") + logos_dir = os.path.dirname(app.conf.logos_exe) files_to_remove = glob.glob(f"{logos_dir}/Data/*/LibraryCatalog/*") for file_to_remove in files_to_remove: try: @@ -258,62 +202,28 @@ def remove_library_catalog(): logging.error(f"Error removing {file_to_remove}: {e}") -def set_winetricks(): - msg.status("Preparing winetricks…") - if not config.APPDIR_BINDIR: - config.APPDIR_BINDIR = f"{config.INSTALLDIR}/data/bin" - # Check if local winetricks version available; else, download it - if config.WINETRICKSBIN is None or not os.access(config.WINETRICKSBIN, os.X_OK): # noqa: E501 - local_winetricks_path = shutil.which('winetricks') - if local_winetricks_path is not None: - # Check if local winetricks version is up-to-date; if so, offer to - # use it or to download; else, download it. - local_winetricks_version = subprocess.check_output(["winetricks", "--version"]).split()[0] # noqa: E501 - if str(local_winetricks_version) != config.WINETRICKS_VERSION: # noqa: E501 - if config.DIALOG == 'tk': #FIXME: CLI client not considered - logging.info("Setting winetricks to the local binary…") - config.WINETRICKSBIN = local_winetricks_path - else: - title = "Choose Winetricks" - question_text = "Should the script use the system's local winetricks or download the latest winetricks from the Internet? The script needs to set some Wine options that FLPRODUCT requires on Linux." # noqa: E501 - - options = [ - "1: Use local winetricks.", - "2: Download winetricks from the Internet" - ] - winetricks_choice = tui_curses.menu(options, title, question_text) # noqa: E501 - - logging.debug(f"winetricks_choice: {winetricks_choice}") - if winetricks_choice.startswith("1"): - logging.info("Setting winetricks to the local binary…") - config.WINETRICKSBIN = local_winetricks_path - return 0 - elif winetricks_choice.startswith("2"): - system.install_winetricks(config.APPDIR_BINDIR) - config.WINETRICKSBIN = os.path.join( - config.APPDIR_BINDIR, - "winetricks" - ) - return 0 - else: - # FIXME: Should this call a function on the app object? - msg.status("Installation canceled!") - sys.exit(0) - else: - msg.status("The system's winetricks is too old. Downloading an up-to-date winetricks from the Internet…") # noqa: E501 - system.install_winetricks(config.APPDIR_BINDIR) - config.WINETRICKSBIN = os.path.join( - config.APPDIR_BINDIR, - "winetricks" - ) - return 0 - else: - msg.status("Local winetricks not found. Downloading winetricks from the Internet…") # noqa: E501 - system.install_winetricks(config.APPDIR_BINDIR) - config.WINETRICKSBIN = os.path.join( - config.APPDIR_BINDIR, - "winetricks" - ) +def set_winetricks(app: App): + app.status("Preparing winetricks…") + if app.conf._winetricks_binary is not None: + valid = True + # Double check it's a valid winetricks + if not Path(app.conf._winetricks_binary).exists(): + logging.warning("Winetricks path does not exist, downloading instead…") + valid = False + if not os.access(app.conf._winetricks_binary, os.X_OK): + logging.warning("Winetricks path given is not executable, downloading instead…") #noqa: E501 + valid = False + if not utils.check_winetricks_version(app.conf._winetricks_binary): + logging.warning("Winetricks version mismatch, downloading instead…") + valid = False + if valid: + logging.info(f"Found valid winetricks: {app.conf._winetricks_binary}") return 0 - return 0 + # Continue executing the download if it wasn't valid + system.install_winetricks(app.conf.installer_binary_dir, app) + app.conf.winetricks_binary = os.path.join( + app.conf.installer_binary_dir, + "winetricks" + ) + return 0 diff --git a/ou_dedetai/gui.py b/ou_dedetai/gui.py index a370744c..e8957b3c 100644 --- a/ou_dedetai/gui.py +++ b/ou_dedetai/gui.py @@ -14,31 +14,51 @@ from tkinter.ttk import Radiobutton from tkinter.ttk import Separator -from . import config -from . import utils +from ou_dedetai.app import App + +from . import constants + + +class ChoiceGui(Frame): + _default_prompt: str = "Choose…" + + def __init__(self, root, question: str, options: list[str], **kwargs): + super(ChoiceGui, self).__init__(root, **kwargs) + self.italic = font.Font(slant='italic') + self.config(padding=5) + self.grid(row=0, column=0, sticky='nwes') + + # Label Row + self.question_label = Label(self, text=question) + # drop-down menu + self.answer_var = StringVar(value=self._default_prompt) + self.answer_dropdown = Combobox(self, textvariable=self.answer_var) + self.answer_dropdown['values'] = options + if len(options) > 0: + self.answer_dropdown.set(options[0]) + + # Cancel/Okay buttons row. + self.cancel_button = Button(self, text="Cancel") + self.okay_button = Button(self, text="Confirm") + + # Place widgets. + row = 0 + self.question_label.grid(column=0, row=row, sticky='nws', pady=2) + self.answer_dropdown.grid(column=1, row=row, sticky='w', pady=2) + row += 1 + self.cancel_button.grid(column=3, row=row, sticky='e', pady=2) + self.okay_button.grid(column=4, row=row, sticky='e', pady=2) class InstallerGui(Frame): - def __init__(self, root, **kwargs): + def __init__(self, root, app: App, **kwargs): super(InstallerGui, self).__init__(root, **kwargs) self.italic = font.Font(slant='italic') self.config(padding=5) self.grid(row=0, column=0, sticky='nwes') - # Initialize vars from ENV. - self.flproduct = config.FLPRODUCT - self.targetversion = config.TARGETVERSION - self.logos_release_version = config.TARGET_RELEASE_VERSION - self.default_config_path = config.DEFAULT_CONFIG_PATH - self.wine_exe = utils.get_wine_exe_path() - self.winetricksbin = config.WINETRICKSBIN - self.skip_fonts = config.SKIP_FONTS - if self.skip_fonts is None: - self.skip_fonts = 0 - self.skip_dependencies = config.SKIP_DEPENDENCIES - if self.skip_fonts is None: - self.skip_fonts = 0 + self.app = app # Product/Version row. self.product_label = Label(self, text="Product & Version: ") @@ -47,8 +67,8 @@ def __init__(self, root, **kwargs): self.product_dropdown = Combobox(self, textvariable=self.productvar) self.product_dropdown.state(['readonly']) self.product_dropdown['values'] = ('Logos', 'Verbum') - if self.flproduct in self.product_dropdown['values']: - self.product_dropdown.set(self.flproduct) + if app.conf._raw.faithlife_product in self.product_dropdown['values']: + self.product_dropdown.set(app.conf._raw.faithlife_product) # version drop-down menu self.versionvar = StringVar() self.version_dropdown = Combobox( @@ -59,8 +79,8 @@ def __init__(self, root, **kwargs): self.version_dropdown.state(['readonly']) self.version_dropdown['values'] = ('9', '10') self.versionvar.set(self.version_dropdown['values'][1]) - if self.targetversion in self.version_dropdown['values']: - self.version_dropdown.set(self.targetversion) + if app.conf._raw.faithlife_product_version in self.version_dropdown['values']: + self.version_dropdown.set(app.conf._raw.faithlife_product_version) # Release row. self.release_label = Label(self, text="Release: ") @@ -69,13 +89,9 @@ def __init__(self, root, **kwargs): self.release_dropdown = Combobox(self, textvariable=self.releasevar) self.release_dropdown.state(['readonly']) self.release_dropdown['values'] = [] - if self.logos_release_version: - self.release_dropdown['values'] = [self.logos_release_version] - self.releasevar.set(self.logos_release_version) - - # release check button - self.release_check_button = Button(self, text="Get Release List") - self.release_check_button.state(['disabled']) + if app.conf._raw.faithlife_product_release: + self.release_dropdown['values'] = [app.conf._raw.faithlife_product_release] + self.releasevar.set(app.conf._raw.faithlife_product_release) # Wine row. self.wine_label = Label(self, text="Wine exe: ") @@ -83,11 +99,10 @@ def __init__(self, root, **kwargs): self.wine_dropdown = Combobox(self, textvariable=self.winevar) self.wine_dropdown.state(['readonly']) self.wine_dropdown['values'] = [] - if self.wine_exe: - self.wine_dropdown['values'] = [self.wine_exe] - self.winevar.set(self.wine_exe) - self.wine_check_button = Button(self, text="Get EXE List") - self.wine_check_button.state(['disabled']) + # Conditional only if wine_binary is actually set, don't prompt if it's not + if self.app.conf._raw.wine_binary: + self.wine_dropdown['values'] = [self.app.conf.wine_binary] + self.winevar.set(self.app.conf.wine_binary) # Winetricks row. self.tricks_label = Label(self, text="Winetricks: ") @@ -95,19 +110,19 @@ def __init__(self, root, **kwargs): self.tricks_dropdown = Combobox(self, textvariable=self.tricksvar) self.tricks_dropdown.state(['readonly']) values = ['Download'] - if self.winetricksbin: - values.insert(0, self.winetricksbin) + if app.conf._raw.winetricks_binary: + values.insert(0, app.conf._raw.winetricks_binary) self.tricks_dropdown['values'] = values self.tricksvar.set(self.tricks_dropdown['values'][0]) # Fonts row. self.fonts_label = Label(self, text="Install Fonts: ") - self.fontsvar = BooleanVar(value=1-self.skip_fonts) + self.fontsvar = BooleanVar(value=not self.app.conf.skip_install_fonts) self.fonts_checkbox = Checkbutton(self, variable=self.fontsvar) # Skip Dependencies row. self.skipdeps_label = Label(self, text="Install Dependencies: ") - self.skipdepsvar = BooleanVar(value=1-self.skip_dependencies) + self.skipdepsvar = BooleanVar(value=not self.app.conf.skip_install_system_dependencies) #noqa: E501 self.skipdeps_checkbox = Checkbutton(self, variable=self.skipdepsvar) # Cancel/Okay buttons row. @@ -115,13 +130,6 @@ def __init__(self, root, **kwargs): self.okay_button = Button(self, text="Install") self.okay_button.state(['disabled']) - # Status area. - s1 = Separator(self, orient='horizontal') - self.statusvar = StringVar() - self.status_label = Label(self, textvariable=self.statusvar) - self.progressvar = IntVar() - self.progress = Progressbar(self, variable=self.progressvar) - # Place widgets. row = 0 self.product_label.grid(column=0, row=row, sticky='nws', pady=2) @@ -130,11 +138,9 @@ def __init__(self, root, **kwargs): row += 1 self.release_label.grid(column=0, row=row, sticky='w', pady=2) self.release_dropdown.grid(column=1, row=row, sticky='w', pady=2) - self.release_check_button.grid(column=2, row=row, sticky='w', pady=2) row += 1 self.wine_label.grid(column=0, row=row, sticky='w', pady=2) self.wine_dropdown.grid(column=1, row=row, columnspan=3, sticky='we', pady=2) # noqa: E501 - self.wine_check_button.grid(column=4, row=row, sticky='e', pady=2) row += 1 self.tricks_label.grid(column=0, row=row, sticky='w', pady=2) self.tricks_dropdown.grid(column=1, row=row, sticky='we', pady=2) @@ -147,12 +153,6 @@ def __init__(self, root, **kwargs): self.cancel_button.grid(column=3, row=row, sticky='e', pady=2) self.okay_button.grid(column=4, row=row, sticky='e', pady=2) row += 1 - # Status area - s1.grid(column=0, row=row, columnspan=5, sticky='we') - row += 1 - self.status_label.grid(column=0, row=row, columnspan=5, sticky='w', pady=2) # noqa: E501 - row += 1 - self.progress.grid(column=0, row=row, columnspan=5, sticky='we', pady=2) # noqa: E501 class ControlGui(Frame): @@ -161,20 +161,16 @@ def __init__(self, root, *args, **kwargs): self.config(padding=5) self.grid(row=0, column=0, sticky='nwes') - # Initialize vars from ENV. - self.installdir = config.INSTALLDIR - self.flproduct = config.FLPRODUCT - self.targetversion = config.TARGETVERSION - self.logos_release_version = config.TARGET_RELEASE_VERSION - self.logs = config.LOGS - self.config_file = config.CONFIG_FILE - # Run/install app button self.app_buttonvar = StringVar() self.app_buttonvar.set("Install") self.app_label = Label(self, text="FaithLife app") self.app_button = Button(self, textvariable=self.app_buttonvar) + self.app_install_advancedvar = StringVar() + self.app_install_advancedvar.set("Advanced Install") + self.app_install_advanced = Button(self, textvariable=self.app_install_advancedvar) #noqa: E501 + # Installed app actions # -> Run indexing, Remove library catalog, Remove all index files s1 = Separator(self, orient='horizontal') @@ -218,7 +214,9 @@ def __init__(self, root, *args, **kwargs): self.backups_label = Label(self, text="Backup/restore data") self.backup_button = Button(self, text="Backup") self.restore_button = Button(self, text="Restore") - self.update_lli_label = Label(self, text=f"Update {config.name_app}") # noqa: E501 + # The normal text has three lines. Make this the same + # in order for tkinker to know how large to draw it + self.update_lli_label = Label(self, text=f"Update {constants.APP_NAME}\n\n") # noqa: E501 self.update_lli_button = Button(self, text="Update") # AppImage buttons self.latest_appimage_label = Label( @@ -257,6 +255,7 @@ def __init__(self, root, *args, **kwargs): row = 0 self.app_label.grid(column=0, row=row, sticky='w', pady=2) self.app_button.grid(column=1, row=row, sticky='w', pady=2) + self.show_advanced_install_button() row += 1 s1.grid(column=0, row=1, columnspan=3, sticky='we', pady=2) row += 1 @@ -304,6 +303,8 @@ def __init__(self, root, *args, **kwargs): row += 1 self.progress.grid(column=0, row=row, columnspan=3, sticky='we', pady=2) # noqa: E501 + def show_advanced_install_button(self): + self.app_install_advanced.grid(column=2, row=0, sticky='w', pady=2) class ToolTip: def __init__(self, widget, text): @@ -342,8 +343,9 @@ def show_tooltip(self, event=None): def hide_tooltip(self, event=None): if self.tooltip_visible: - self.tooltip_window.destroy() self.tooltip_visible = False + if self.tooltip_window: + self.tooltip_window.destroy() class PromptGui(Frame): @@ -354,12 +356,14 @@ def __init__(self, root, title="", prompt="", **kwargs): self.options['title'] = title if prompt is not None: self.options['prompt'] = prompt + self.root = root def draw_prompt(self): + text = "Store Password" store_button = Button( self.root, - text="Store Password", - command=lambda: input_prompt(self.root, self.options) + text=text, + command=lambda: input_prompt(self.root, text, self.options) ) store_button.pack(pady=20) diff --git a/ou_dedetai/gui_app.py b/ou_dedetai/gui_app.py index f06f9d73..9cdb9d1a 100644 --- a/ou_dedetai/gui_app.py +++ b/ou_dedetai/gui_app.py @@ -7,22 +7,130 @@ from pathlib import Path from queue import Queue -from tkinter import PhotoImage +import shutil +from threading import Event +import threading +from tkinter import PhotoImage, messagebox from tkinter import Tk from tkinter import Toplevel from tkinter import filedialog as fd from tkinter.ttk import Style +from typing import Callable, Optional -from . import config +from ou_dedetai.app import App +from ou_dedetai.constants import PROMPT_OPTION_DIRECTORY, PROMPT_OPTION_FILE +from ou_dedetai.config import EphemeralConfiguration + +from . import constants from . import control from . import gui from . import installer -from . import logos -from . import network from . import system from . import utils from . import wine +class GuiApp(App): + """Implements the App interface for all windows""" + + _exit_option: Optional[str] = None + + def __init__(self, root: "Root", ephemeral_config: EphemeralConfiguration, **kwargs): #noqa: E501 + super().__init__(ephemeral_config) + self.root = root + # Now spawn a new thread to ensure choices are set to set to defaults so user + # isn't App.ask'ed + def _populate_initial_defaults(): + self.populate_defaults() + self.start_thread(_populate_initial_defaults) + + + def _ask(self, question: str, options: list[str] | str) -> Optional[str]: + # This cannot be run from the main thread as the dialog will never appear + # since the tinker mainloop hasn't started and we block on a response + + if isinstance(options, list): + answer_q: Queue[Optional[str]] = Queue() + answer_event = Event() + ChoicePopUp(question, options, answer_q, answer_event) + + answer_event.wait() + answer: Optional[str] = answer_q.get() + elif isinstance(options, str): + answer = options + + if answer == PROMPT_OPTION_DIRECTORY: + answer = fd.askdirectory( + parent=self.root, + title=question, + initialdir=Path().home(), + ) + elif answer == PROMPT_OPTION_FILE: + answer = fd.askopenfilename( + parent=self.root, + title=question, + initialdir=Path().home(), + ) + return answer + + def approve(self, question: str, context: str | None = None) -> bool: + return messagebox.askquestion(question, context) == 'yes' + + def exit(self, reason: str, intended: bool = False): + # Create a little dialog before we die so the user can see why this happened + if not intended: + gui.show_error(reason, detail=constants.SUPPORT_MESSAGE, fatal=True) + self.root.destroy() + return super().exit(reason, intended) + + @property + def superuser_command(self) -> str: + """Command when root privileges are needed. + + Raises: + SuperuserCommandNotFound - if no command is found + + pkexec if found""" + if shutil.which('pkexec'): + return "pkexec" + else: + raise system.SuperuserCommandNotFound("No superuser command found. Please install pkexec.") # noqa: E501 + + def populate_defaults(self) -> None: + """If any prompt is unset, set it to it's default value + + Useful for startign the UI at an installable state, + the user can change these choices later""" + + # For the GUI, use defaults until user says otherwise. + # XXX: move these to constants + if self.conf._raw.faithlife_product is None: + self.conf.faithlife_product = constants.FAITHLIFE_PRODUCTS[0] + if self.conf._raw.faithlife_product_version is None: + self.conf.faithlife_product_version = constants.FAITHLIFE_PRODUCT_VERSIONS[0] #noqa: E501 + + # Now that we know product and version are set we can download the releases + # And use the first one + if self.conf._raw.faithlife_product_release is None: + if self.conf._network._faithlife_product_releases( + self.conf._raw.faithlife_product, + self.conf._raw.faithlife_product_version, + self.conf._raw.faithlife_product_release_channel, + ) is not None: + self.conf.faithlife_product_release = self.conf.faithlife_product_releases[0] #noqa: E501 + else: + # Spawn a thread that does this, as the download takes a second + def _populate_product_release_default(): + self.conf.faithlife_product_release = self.conf.faithlife_product_releases[0] #noqa: E501 + self.start_thread(_populate_product_release_default) + + # Set the install_dir to default, no option in the GUI to change it + if self.conf._raw.install_dir is None: + self.conf.install_dir = self.conf.install_dir_default + + if self.conf._raw.wine_binary is None: + wine_choices = utils.get_wine_options(self) + if len(wine_choices) > 0: + self.conf.wine_binary = wine_choices[0] class Root(Tk): def __init__(self, *args, **kwargs): @@ -33,23 +141,23 @@ def __init__(self, *args, **kwargs): self.style.theme_use('alt') # Update color scheme. - self.style.configure('TCheckbutton', bordercolor=config.LOGOS_GRAY) - self.style.configure('TCombobox', bordercolor=config.LOGOS_GRAY) - self.style.configure('TCheckbutton', indicatorcolor=config.LOGOS_GRAY) - self.style.configure('TRadiobutton', indicatorcolor=config.LOGOS_GRAY) + self.style.configure('TCheckbutton', bordercolor=constants.LOGOS_GRAY) + self.style.configure('TCombobox', bordercolor=constants.LOGOS_GRAY) + self.style.configure('TCheckbutton', indicatorcolor=constants.LOGOS_GRAY) + self.style.configure('TRadiobutton', indicatorcolor=constants.LOGOS_GRAY) bg_widgets = [ 'TCheckbutton', 'TCombobox', 'TFrame', 'TLabel', 'TRadiobutton' ] fg_widgets = ['TButton', 'TSeparator'] for w in bg_widgets: - self.style.configure(w, background=config.LOGOS_WHITE) + self.style.configure(w, background=constants.LOGOS_WHITE) for w in fg_widgets: - self.style.configure(w, background=config.LOGOS_GRAY) + self.style.configure(w, background=constants.LOGOS_GRAY) self.style.configure( 'Horizontal.TProgressbar', - thickness=10, background=config.LOGOS_BLUE, - bordercolor=config.LOGOS_GRAY, - troughcolor=config.LOGOS_GRAY, + thickness=10, background=constants.LOGOS_BLUE, + bordercolor=constants.LOGOS_GRAY, + troughcolor=constants.LOGOS_GRAY, ) # Justify to the left [('Button.label', {'sticky': 'w'})] @@ -76,30 +184,61 @@ def __init__(self, *args, **kwargs): self.rowconfigure(0, weight=1) # Set panel icon. - app_dir = Path(__file__).parent - self.icon = app_dir / 'img' / 'icon.png' + self.icon = constants.APP_IMAGE_DIR / 'icon.png' self.pi = PhotoImage(file=f'{self.icon}') self.iconphoto(False, self.pi) -class InstallerWindow(): - def __init__(self, new_win, root, **kwargs): +class ChoicePopUp: + """Creates a pop-up with a choice""" + def __init__(self, question: str, options: list[str], answer_q: Queue[Optional[str]], answer_event: Event, **kwargs): #noqa: E501 + self.root = Toplevel() + # Set root parameters. + self.gui = gui.ChoiceGui(self.root, question, options) + self.root.title(f"Quesiton: {question.strip().strip(':')}") + self.root.resizable(False, False) + # Set root widget event bindings. + self.root.bind( + "", + self.on_confirm_choice + ) + self.root.bind( + "", + self.on_cancel_released + ) + self.gui.cancel_button.config(command=self.on_cancel_released) + self.gui.okay_button.config(command=self.on_confirm_choice) + self.answer_q = answer_q + self.answer_event = answer_event + + def on_confirm_choice(self, evt=None): + if self.gui.answer_dropdown.get() == gui.ChoiceGui._default_prompt: + return + answer = self.gui.answer_dropdown.get() + self.answer_q.put(answer) + self.answer_event.set() + self.root.destroy() + + def on_cancel_released(self, evt=None): + self.answer_q.put(None) + self.answer_event.set() + self.root.destroy() + + +class InstallerWindow: + def __init__(self, new_win, root: Root, app: "ControlWindow", **kwargs): # Set root parameters. self.win = new_win self.root = root - self.win.title(f"{config.name_app} Installer") + self.win.title(f"{constants.APP_NAME} Installer") self.win.resizable(False, False) - self.gui = gui.InstallerGui(self.win) + self.gui = gui.InstallerGui(self.win, app) + self.app = app + self.conf = app.conf + self.start_thread = app.start_thread # Initialize variables. - self.flproduct = None # config.FLPRODUCT self.config_thread = None - self.wine_exe = None - self.winetricksbin = None - self.appimages = None - # self.appimage_verified = None - # self.logos_verified = None - # self.tricks_verified = None # Set widget callbacks and event bindings. self.gui.product_dropdown.bind( @@ -114,16 +253,10 @@ def __init__(self, new_win, root, **kwargs): '<>', self.set_release ) - self.gui.release_check_button.config( - command=self.on_release_check_released - ) self.gui.wine_dropdown.bind( '<>', self.set_wine ) - self.gui.wine_check_button.config( - command=self.on_wine_check_released - ) self.gui.tricks_dropdown.bind( '<>', self.set_winetricks @@ -142,55 +275,56 @@ def __init__(self, new_win, root, **kwargs): "", self.on_cancel_released ) - self.root.bind( - '<>', - self.start_indeterminate_progress - ) - self.root.bind( - "<>", - self.update_wine_check_progress - ) - self.get_q = Queue() - self.get_evt = "<>" - self.root.bind(self.get_evt, self.update_download_progress) - self.check_evt = "<>" - self.root.bind(self.check_evt, self.update_file_check_progress) - self.status_q = Queue() - self.status_evt = "<>" - self.root.bind(self.status_evt, self.update_status_text) - self.progress_q = Queue() - self.root.bind( - "<>", - self.update_progress - ) - self.todo_q = Queue() - self.root.bind( - "<>", - self.todo - ) - self.product_q = Queue() - self.version_q = Queue() - self.releases_q = Queue() - self.release_q = Queue() - self.wine_q = Queue() - self.tricksbin_q = Queue() # Run commands. self.get_winetricks_options() - self.start_ensure_config() - def start_ensure_config(self): + self.app.config_updated_hooks += [self._config_updated_hook] + # Start out enforcing this + self._config_updated_hook() + + def _config_updated_hook(self): + """Update the GUI to reflect changes in the configuration/network""" #noqa: E501 + + self.app.populate_defaults() + + # Fill in the UI elements from the config + self.gui.productvar.set(self.conf.faithlife_product) + self.gui.versionvar.set(self.conf.faithlife_product_version) # noqa: E501 + + # Now that we know product and version are set we can download the releases + self.gui.release_dropdown['values'] = self.conf.faithlife_product_releases + self.gui.releasevar.set(self.conf.faithlife_product_release) + + self.gui.skipdepsvar.set(not self.conf.skip_install_system_dependencies) + self.gui.fontsvar.set(not self.conf.skip_install_fonts) + + # In case the product changes + self.root.icon = Path(self.conf.faithlife_product_icon_path) + + self.gui.wine_dropdown['values'] = utils.get_wine_options(self.app) + if not self.gui.winevar.get(): + # If no value selected, default to 1st item in list. + self.gui.winevar.set(self.gui.wine_dropdown['values'][0]) + + self.gui.winevar.set(self.conf._raw.wine_binary or '') + + # At this point all variables are populated, we're ready to install! + self.set_input_widgets_state('enabled', [self.gui.okay_button]) + + def _post_dropdown_change(self): + """Steps to preform after a dropdown has been updated""" # Ensure progress counter is reset. - config.INSTALL_STEP = 1 - config.INSTALL_STEPS_COUNT = 0 - self.config_thread = utils.start_thread( - installer.ensure_installation_config, - app=self, - ) + self.installer_step = 0 + self.installer_step_count = 0 + # Reset install_dir to default based on possible new value + self.conf.install_dir = self.conf.install_dir_default def get_winetricks_options(self): - config.WINETRICKSBIN = None # override config file b/c "Download" accounts for that # noqa: E501 - self.gui.tricks_dropdown['values'] = utils.get_winetricks_options() + # override config file b/c "Download" accounts for that + # Type hinting ignored due to https://github.com/python/mypy/issues/3004 + self.conf.winetricks_binary = None # type: ignore[assignment] + self.gui.tricks_dropdown['values'] = utils.get_winetricks_options() #noqa: E501 self.gui.tricksvar.set(self.gui.tricks_dropdown['values'][0]) def set_input_widgets_state(self, state, widgets='all'): @@ -202,9 +336,7 @@ def set_input_widgets_state(self, state, widgets='all'): self.gui.product_dropdown, self.gui.version_dropdown, self.gui.release_dropdown, - self.gui.release_check_button, self.gui.wine_dropdown, - self.gui.wine_check_button, self.gui.tricks_dropdown, self.gui.okay_button, ] @@ -213,365 +345,90 @@ def set_input_widgets_state(self, state, widgets='all'): for w in widgets: w.state(state) - def todo(self, evt=None, task=None): - logging.debug(f"GUI todo: {task=}") - widgets = [] - if not task: - if not self.todo_q.empty(): - task = self.todo_q.get() - else: - return - self.set_input_widgets_state('enabled') - if task == 'FLPRODUCT': - # Disable all input widgets after Version. - widgets = [ - self.gui.version_dropdown, - self.gui.release_dropdown, - self.gui.release_check_button, - self.gui.wine_dropdown, - self.gui.wine_check_button, - self.gui.okay_button, - ] - self.set_input_widgets_state('disabled', widgets=widgets) - if not self.gui.productvar.get(): - self.gui.productvar.set(self.gui.product_dropdown['values'][0]) - self.set_product() - elif task == 'TARGETVERSION': - # Disable all input widgets after Version. - widgets = [ - self.gui.release_dropdown, - self.gui.release_check_button, - self.gui.wine_dropdown, - self.gui.wine_check_button, - self.gui.okay_button, - ] - self.set_input_widgets_state('disabled', widgets=widgets) - if not self.gui.versionvar.get(): - self.gui.versionvar.set(self.gui.version_dropdown['values'][1]) - self.set_version() - elif task == 'TARGET_RELEASE_VERSION': - # Disable all input widgets after Release. - widgets = [ - self.gui.wine_dropdown, - self.gui.wine_check_button, - self.gui.okay_button, - ] - self.set_input_widgets_state('disabled', widgets=widgets) - self.start_releases_check() - elif task == 'WINE_EXE': - # Disable all input widgets after Wine Exe. - widgets = [ - self.gui.okay_button, - ] - self.set_input_widgets_state('disabled', widgets=widgets) - self.start_wine_versions_check(config.TARGET_RELEASE_VERSION) - elif task == 'WINETRICKSBIN': - # Disable all input widgets after Winetricks. - widgets = [ - self.gui.okay_button, - ] - self.set_input_widgets_state('disabled', widgets=widgets) - self.set_winetricks() - elif task == 'INSTALL': - self.gui.statusvar.set('Ready to install!') - self.gui.progressvar.set(0) - elif task == 'INSTALLING': - self.set_input_widgets_state('disabled') - elif task == 'DONE': - self.update_install_progress() - elif task == 'CONFIG': - logging.info("Updating config file.") - utils.write_config(config.CONFIG_FILE) - def set_product(self, evt=None): - if self.gui.productvar.get().startswith('C'): # ignore default text - return - self.gui.flproduct = self.gui.productvar.get() + self.conf.faithlife_product = self.gui.productvar.get() self.gui.product_dropdown.selection_clear() - if evt: # manual override; reset dependent variables - logging.debug(f"User changed FLPRODUCT to '{self.gui.flproduct}'") - config.FLPRODUCT = None - config.FLPRODUCTi = None - config.VERBUM_PATH = None - - config.TARGETVERSION = None - self.gui.versionvar.set('') - - config.TARGET_RELEASE_VERSION = None - self.gui.releasevar.set('') - - config.INSTALLDIR = None - config.APPDIR_BINDIR = None - - config.WINE_EXE = None - self.gui.winevar.set('') - config.SELECTED_APPIMAGE_FILENAME = None - config.WINEBIN_CODE = None - - self.start_ensure_config() - else: - self.product_q.put(self.gui.flproduct) + self._post_dropdown_change() def set_version(self, evt=None): - self.gui.targetversion = self.gui.versionvar.get() + self.conf.faithlife_product_version = self.gui.versionvar.get() self.gui.version_dropdown.selection_clear() - if evt: # manual override; reset dependent variables - logging.debug(f"User changed TARGETVERSION to '{self.gui.targetversion}'") # noqa: E501 - config.TARGETVERSION = None - self.gui.releasevar.set('') - config.TARGET_RELEASE_VERSION = None - self.gui.releasevar.set('') - - config.INSTALLDIR = None - config.APPDIR_BINDIR = None - - config.WINE_EXE = None - self.gui.winevar.set('') - config.SELECTED_APPIMAGE_FILENAME = None - config.WINEBIN_CODE = None - - self.start_ensure_config() - else: - self.version_q.put(self.gui.targetversion) - - def start_releases_check(self): - # Disable button; clear list. - self.gui.release_check_button.state(['disabled']) - # self.gui.releasevar.set('') - self.gui.release_dropdown['values'] = [] - # Setup queue, signal, thread. - self.release_evt = "<>" - self.root.bind( - self.release_evt, - self.update_release_check_progress - ) - # Start progress. - self.gui.progress.config(mode='indeterminate') - self.gui.progress.start() - self.gui.statusvar.set("Downloading Release list…") - # Start thread. - utils.start_thread(network.get_logos_releases, app=self) + self._post_dropdown_change() def set_release(self, evt=None): - if self.gui.releasevar.get()[0] == 'C': # ignore default text - return - self.gui.logos_release_version = self.gui.releasevar.get() + self.conf.faithlife_product_release = self.gui.releasevar.get() self.gui.release_dropdown.selection_clear() - if evt: # manual override - config.TARGET_RELEASE_VERSION = self.gui.logos_release_version - logging.debug(f"User changed TARGET_RELEASE_VERSION to '{self.gui.logos_release_version}'") # noqa: E501 - - config.INSTALLDIR = None - config.APPDIR_BINDIR = None - - config.WINE_EXE = None - self.gui.winevar.set('') - config.SELECTED_APPIMAGE_FILENAME = None - config.WINEBIN_CODE = None - - self.start_ensure_config() - else: - self.release_q.put(self.gui.logos_release_version) - - def start_find_appimage_files(self, release_version): - # Setup queue, signal, thread. - self.appimage_q = Queue() - self.appimage_evt = "<>" - self.root.bind( - self.appimage_evt, - self.update_find_appimage_progress - ) - # Start progress. - self.gui.progress.config(mode='indeterminate') - self.gui.progress.start() - self.gui.statusvar.set("Finding available wine AppImages…") - # Start thread. - utils.start_thread( - utils.find_appimage_files, - release_version=release_version, - app=self, - ) - - def start_wine_versions_check(self, release_version): - if self.appimages is None: - self.appimages = [] - # self.start_find_appimage_files(release_version) - # return - # Setup queue, signal, thread. - self.wines_q = Queue() - self.wine_evt = "<>" - self.root.bind( - self.wine_evt, - self.update_wine_check_progress - ) - # Start progress. - self.gui.progress.config(mode='indeterminate') - self.gui.progress.start() - self.gui.statusvar.set("Finding available wine binaries…") - # Start thread. - utils.start_thread( - utils.get_wine_options, - self.appimages, - utils.find_wine_binary_files(release_version), - app=self, - ) + self._post_dropdown_change() def set_wine(self, evt=None): - self.gui.wine_exe = self.gui.winevar.get() + self.conf.wine_binary = self.gui.winevar.get() self.gui.wine_dropdown.selection_clear() - if evt: # manual override - logging.debug(f"User changed WINE_EXE to '{self.gui.wine_exe}'") - config.WINE_EXE = None - config.SELECTED_APPIMAGE_FILENAME = None - config.WINEBIN_CODE = None - - self.start_ensure_config() - else: - self.wine_q.put( - utils.get_relative_path( - utils.get_config_var(self.gui.wine_exe), - config.INSTALLDIR - ) - ) + self._post_dropdown_change() def set_winetricks(self, evt=None): - self.gui.winetricksbin = self.gui.tricksvar.get() + self.conf.winetricks_binary = self.gui.tricksvar.get() self.gui.tricks_dropdown.selection_clear() - if evt: # manual override - config.WINETRICKSBIN = None - self.start_ensure_config() - else: - self.tricksbin_q.put(self.gui.winetricksbin) - - def on_release_check_released(self, evt=None): - self.start_releases_check() - - def on_wine_check_released(self, evt=None): - self.gui.wine_check_button.state(['disabled']) - self.start_wine_versions_check(config.TARGET_RELEASE_VERSION) + self._post_dropdown_change() def set_skip_fonts(self, evt=None): - self.gui.skip_fonts = 1 - self.gui.fontsvar.get() # invert True/False - config.SKIP_FONTS = self.gui.skip_fonts - logging.debug(f"> {config.SKIP_FONTS=}") + self.conf.skip_install_fonts = not self.gui.fontsvar.get() # invert True/False + logging.debug(f"> {self.conf.skip_install_fonts=}") def set_skip_dependencies(self, evt=None): - self.gui.skip_dependencies = 1 - self.gui.skipdepsvar.get() # invert True/False # noqa: E501 - config.SKIP_DEPENDENCIES = self.gui.skip_dependencies - logging.debug(f"> {config.SKIP_DEPENDENCIES=}") + self.conf.skip_install_system_dependencies = self.gui.skipdepsvar.get() # invert True/False # noqa: E501 + logging.debug(f"> {self.conf.skip_install_system_dependencies=}") #noqa: E501 def on_okay_released(self, evt=None): # Update desktop panel icon. - self.root.icon = config.LOGOS_ICON_URL self.start_install_thread() - def on_cancel_released(self, evt=None): + def close(self): + self.app.config_updated_hooks.remove(self._config_updated_hook) + # Reset status + self.app.clear_status() self.win.destroy() + + def on_cancel_released(self, evt=None): + self.app.clear_status() + self.close() return 1 def start_install_thread(self, evt=None): - self.gui.progress.config(mode='determinate') - utils.start_thread(installer.ensure_launcher_shortcuts, app=self) + def _install(): + """Function to handle the install""" + # Close the options window and let the install run + self.close() + installer.install(self.app) + # Install complete, cleaning up... + return 0 - def start_indeterminate_progress(self, evt=None): - self.gui.progress.state(['!disabled']) - self.gui.progressvar.set(0) - self.gui.progress.config(mode='indeterminate') - self.gui.progress.start() - - def stop_indeterminate_progress(self, evt=None): - self.gui.progress.stop() - self.gui.progress.state(['disabled']) - self.gui.progress.config(mode='determinate') - self.gui.progressvar.set(0) - self.gui.statusvar.set('') - - def update_release_check_progress(self, evt=None): - self.stop_indeterminate_progress() - self.gui.release_check_button.state(['!disabled']) - if not self.releases_q.empty(): - self.gui.release_dropdown['values'] = self.releases_q.get() - self.gui.releasevar.set(self.gui.release_dropdown['values'][0]) - self.set_release() - else: - self.gui.statusvar.set("Failed to get release list. Check connection and try again.") # noqa: E501 - - def update_find_appimage_progress(self, evt=None): - self.stop_indeterminate_progress() - if not self.appimage_q.empty(): - self.appimages = self.appimage_q.get() - self.start_wine_versions_check(config.TARGET_RELEASE_VERSION) - - def update_wine_check_progress(self, evt=None): - if evt and self.wines_q.empty(): - return - self.gui.wine_dropdown['values'] = self.wines_q.get() - if not self.gui.winevar.get(): - # If no value selected, default to 1st item in list. - self.gui.winevar.set(self.gui.wine_dropdown['values'][0]) - self.set_wine() - self.stop_indeterminate_progress() - self.gui.wine_check_button.state(['!disabled']) + # Setup for the install + self.app.status('Ready to install!', 0) + self.set_input_widgets_state('disabled') - def update_file_check_progress(self, evt=None): - self.gui.progress.stop() - self.gui.statusvar.set('') - self.gui.progress.config(mode='determinate') - self.gui.progressvar.set(0) - - def update_download_progress(self, evt=None): - d = self.get_q.get() - self.gui.progressvar.set(int(d)) + self.start_thread(_install) - def update_progress(self, evt=None): - progress = self.progress_q.get() - if not type(progress) is int: - return - if progress >= 100: - self.gui.progressvar.set(0) - # self.gui.progress.state(['disabled']) - else: - self.gui.progressvar.set(progress) - - def update_status_text(self, evt=None, status=None): - text = '' - if evt: - text = self.status_q.get() - elif status: - text = status - self.gui.statusvar.set(text) - - def update_install_progress(self, evt=None): - self.gui.progress.stop() - self.gui.progress.config(mode='determinate') - self.gui.progressvar.set(0) - self.gui.statusvar.set('') - self.gui.okay_button.config( - text="Exit", - command=self.on_cancel_released, - ) - self.gui.okay_button.state(['!disabled']) - self.root.event_generate('<>') - self.win.destroy() - return 0 +class ControlWindow(GuiApp): + def __init__(self, root, control_gui: gui.ControlGui, + ephemeral_config: EphemeralConfiguration, *args, **kwargs): + super().__init__(root, ephemeral_config) -class ControlWindow(): - def __init__(self, root, *args, **kwargs): # Set root parameters. self.root = root - self.root.title(f"{config.name_app} Control Panel") - self.root.resizable(False, False) - self.gui = gui.ControlGui(self.root) - self.actioncmd = None - self.logos = logos.LogosManager(app=self) - - text = self.gui.update_lli_label.cget('text') - ver = config.LLI_CURRENT_VERSION - new = config.LLI_LATEST_VERSION - text = f"{text}\ncurrent: v{ver}\nlatest: v{new}" + self.gui = control_gui + self.actioncmd: Optional[Callable[[], None]] = None + + ver = constants.LLI_CURRENT_VERSION + text = f"Update {constants.APP_NAME}\ncurrent: v{ver}\nlatest: ..." #noqa: E501 + # Spawn a thread to update the label with the current version + def _update_lli_version(): + text = f"Update {constants.APP_NAME}\ncurrent: v{ver}\nlatest: v{self.conf.app_latest_version}" #noqa: E501 + self.gui.update_lli_label.config(text=text) + self.update_latest_lli_release_button() + self.gui.update_lli_button.state(['disabled']) + self.start_thread(_update_lli_version) self.gui.update_lli_label.config(text=text) - self.configure_app_button() self.gui.run_indexing_radio.config( command=self.on_action_radio_clicked ) @@ -593,7 +450,7 @@ def __init__(self, root, *args, **kwargs): ) self.gui.logging_button.state(['disabled']) - self.gui.config_button.config(command=control.edit_config) + self.gui.config_button.config(command=self.edit_config) self.gui.deps_button.config(command=self.install_deps) self.gui.backup_button.config(command=self.run_backup) self.gui.restore_button.config(command=self.run_restore) @@ -603,85 +460,46 @@ def __init__(self, root, *args, **kwargs): self.gui.latest_appimage_button.config( command=self.update_to_latest_appimage ) - if config.WINEBIN_CODE != "AppImage" and config.WINEBIN_CODE != "Recommended": # noqa: E501 - self.gui.latest_appimage_button.state(['disabled']) - gui.ToolTip( - self.gui.latest_appimage_button, - "This button is disabled. The configured install was not created using an AppImage." # noqa: E501 - ) - self.gui.set_appimage_button.state(['disabled']) - gui.ToolTip( - self.gui.set_appimage_button, - "This button is disabled. The configured install was not created using an AppImage." # noqa: E501 - ) - self.update_latest_lli_release_button() - self.update_latest_appimage_button() self.gui.set_appimage_button.config(command=self.set_appimage) self.gui.get_winetricks_button.config(command=self.get_winetricks) self.gui.run_winetricks_button.config(command=self.launch_winetricks) - self.update_run_winetricks_button() - self.logging_q = Queue() - self.logging_event = '<>' - self.root.bind(self.logging_event, self.update_logging_button) - self.status_q = Queue() - self.status_evt = '<>' - self.root.bind(self.status_evt, self.update_status_text) - self.root.bind('<>', self.clear_status_text) - self.progress_q = Queue() - self.root.bind( - '<>', - self.start_indeterminate_progress - ) - self.root.bind( - '<>', - self.stop_indeterminate_progress - ) - self.root.bind( - '<>', - self.update_progress - ) - self.root.bind( - "<>", - self.update_latest_appimage_button - ) - self.root.bind('<>', self.update_app_button) - self.get_q = Queue() - self.get_evt = "<>" - self.root.bind(self.get_evt, self.update_download_progress) - self.check_evt = "<>" - self.root.bind(self.check_evt, self.update_file_check_progress) - - # Start function to determine app logging state. - if utils.app_is_installed(): - self.gui.statusvar.set('Getting current app logging status…') - self.start_indeterminate_progress() - utils.start_thread(self.logos.get_app_logging_state) - - def configure_app_button(self, evt=None): - if utils.app_is_installed(): - # wine.set_logos_paths() - self.gui.app_buttonvar.set(f"Run {config.FLPRODUCT}") - self.gui.app_button.config(command=self.run_logos) - self.gui.get_winetricks_button.state(['!disabled']) - else: - self.gui.app_button.config(command=self.run_installer) + self._config_update_hook() + # These can be expanded to change the UI based on config changes. + self.config_updated_hooks += [self._config_update_hook] + + def edit_config(self): + control.edit_file(self.conf.config_file_path) + + def run_install(self, evt=None): + """Directly install the product. + + Fallback to defaults if we don't know a response""" + def _install(): + installer.install(self) + # Enable the run button + self.gui.app_button.state(['!disabled']) + # Disable the install buttons + self.gui.app_button.state(['disabled']) + self.gui.app_install_advanced.state(['disabled']) + # Start the install thread. + self.start_thread(_install) def run_installer(self, evt=None): - classname = config.name_binary - self.installer_win = Toplevel() - InstallerWindow(self.installer_win, self.root, class_=classname) - self.root.icon = config.LOGOS_ICON_URL + classname = constants.BINARY_NAME + installer_window_top = Toplevel() + InstallerWindow(installer_window_top, self.root, app=self, class_=classname) #noqa: E501 def run_logos(self, evt=None): - utils.start_thread(self.logos.start) + self.start_thread(self.logos.start) def run_action_cmd(self, evt=None): - self.actioncmd() + if self.actioncmd: + self.actioncmd() def on_action_radio_clicked(self, evt=None): logging.debug("gui_app.ControlPanel.on_action_radio_clicked START") - if utils.app_is_installed(): + if self.is_installed(): self.gui.actions_button.state(['!disabled']) if self.gui.actionsvar.get() == 'run-indexing': self.actioncmd = self.run_indexing @@ -692,46 +510,36 @@ def on_action_radio_clicked(self, evt=None): elif self.gui.actionsvar.get() == 'install-icu': self.actioncmd = self.install_icu - def run_indexing(self, evt=None): - utils.start_thread(self.logos.index) + def run_indexing(self): + self.start_thread(self.logos.index) - def remove_library_catalog(self, evt=None): - control.remove_library_catalog() + def remove_library_catalog(self): + control.remove_library_catalog(self) - def remove_indexes(self, evt=None): + def remove_indexes(self): self.gui.statusvar.set("Removing indexes…") - utils.start_thread(control.remove_all_index_files, app=self) + self.start_thread(control.remove_all_index_files, app=self) - def install_icu(self, evt=None): + def install_icu(self): self.gui.statusvar.set("Installing ICU files…") - utils.start_thread(wine.enforce_icu_data_files, app=self) + self.start_thread(wine.enforce_icu_data_files, app=self) def run_backup(self, evt=None): - # Get backup folder. - if config.BACKUPDIR is None: - config.BACKUPDIR = fd.askdirectory( - parent=self.root, - title=f"Choose folder for {config.FLPRODUCT} backups", - initialdir=Path().home(), - ) - if not config.BACKUPDIR: # user cancelled - return - # Prepare progress bar. self.gui.progress.state(['!disabled']) self.gui.progress.config(mode='determinate') self.gui.progressvar.set(0) # Start backup thread. - utils.start_thread(control.backup, app=self) + self.start_thread(control.backup, app=self) def run_restore(self, evt=None): # FIXME: Allow user to choose restore source? # Start restore thread. - utils.start_thread(control.restore, app=self) + self.start_thread(control.restore, app=self) def install_deps(self, evt=None): - self.start_indeterminate_progress() - utils.start_thread(utils.install_dependencies) + self.status("Installing dependencies…") + self.start_thread(utils.install_dependencies, self) def open_file_dialog(self, filetype_name, filetype_extension): file_path = fd.askopenfilename( @@ -744,176 +552,226 @@ def open_file_dialog(self, filetype_name, filetype_extension): return file_path def update_to_latest_lli_release(self, evt=None): - self.start_indeterminate_progress() - self.gui.statusvar.set(f"Updating to latest {config.name_app} version…") # noqa: E501 - utils.start_thread(utils.update_to_latest_lli_release, app=self) + self.status(f"Updating to latest {constants.APP_NAME} version…") # noqa: E501 + self.start_thread(utils.update_to_latest_lli_release, app=self) + + def set_appimage_symlink(self): + utils.set_appimage_symlink(self) + self.update_latest_appimage_button() def update_to_latest_appimage(self, evt=None): - config.APPIMAGE_FILE_PATH = config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME # noqa: E501 - self.start_indeterminate_progress() - self.gui.statusvar.set("Updating to latest AppImage…") - utils.start_thread(utils.set_appimage_symlink, app=self) + self.status("Updating to latest AppImage…") + self.start_thread(self.set_appimage_symlink) def set_appimage(self, evt=None): # TODO: Separate as advanced feature. appimage_filename = self.open_file_dialog("AppImage", "AppImage") if not appimage_filename: return - # config.SELECTED_APPIMAGE_FILENAME = appimage_filename - config.APPIMAGE_FILE_PATH = appimage_filename - utils.start_thread(utils.set_appimage_symlink, app=self) + self.conf.wine_appimage_path = appimage_filename + self.start_thread(self.set_appimage_symlink) def get_winetricks(self, evt=None): # TODO: Separate as advanced feature. self.gui.statusvar.set("Installing Winetricks…") - utils.start_thread( + self.start_thread( system.install_winetricks, - config.APPDIR_BINDIR, + self.conf.installer_binary_dir, app=self ) - self.update_run_winetricks_button() def launch_winetricks(self, evt=None): - self.gui.statusvar.set("Launching Winetricks…") + self.status("Launching Winetricks…") # Start winetricks in thread. - utils.start_thread(wine.run_winetricks) + self.start_thread(self.run_winetricks) # Start thread to clear status after delay. - args = [12000, self.root.event_generate, '<>'] - utils.start_thread(self.root.after, *args) + args = [12000, self.clear_status] + self.start_thread(self.root.after, *args) + + def run_winetricks(self): + wine.run_winetricks(self) def switch_logging(self, evt=None): desired_state = self.gui.loggingstatevar.get() - self.gui.statusvar.set(f"Switching app logging to '{desired_state}d'…") - self.start_indeterminate_progress() - self.gui.progress.state(['!disabled']) - self.gui.progress.start() + self._status(f"Switching app logging to '{desired_state}d'…") self.gui.logging_button.state(['disabled']) - utils.start_thread( + self.start_thread( self.logos.switch_logging, action=desired_state.lower() ) - def initialize_logging_button(self, evt=None): - self.gui.statusvar.set('') - self.gui.progress.stop() - self.gui.progress.state(['disabled']) - state = self.reverse_logging_state_value(self.logging_q.get()) - self.gui.loggingstatevar.set(state[:-1].title()) - self.gui.logging_button.state(['!disabled']) + def _status(self, message: str, percent: int | None = None): + message = message.lstrip("\r") + if percent is not None: + self.gui.progress.stop() + self.gui.progress.state(['disabled']) + self.gui.progress.config(mode='determinate') + self.gui.progressvar.set(percent) + else: + self.gui.progress.state(['!disabled']) + self.gui.progressvar.set(0) + self.gui.progress.config(mode='indeterminate') + self.gui.progress.start() + self.gui.statusvar.set(message) + if message: + super()._status(message, percent) def update_logging_button(self, evt=None): - self.gui.statusvar.set('') - self.gui.progress.stop() - self.gui.progress.state(['disabled']) - new_state = self.reverse_logging_state_value(self.logging_q.get()) - new_text = new_state[:-1].title() - logging.debug(f"Updating app logging button text to: {new_text}") - self.gui.loggingstatevar.set(new_text) + state = self.reverse_logging_state_value(self.current_logging_state_value()) + self.gui.loggingstatevar.set(state[:-1].title()) self.gui.logging_button.state(['!disabled']) def update_app_button(self, evt=None): self.gui.app_button.state(['!disabled']) - self.gui.app_buttonvar.set(f"Run {config.FLPRODUCT}") - self.configure_app_button() - self.update_run_winetricks_button() - self.gui.logging_button.state(['!disabled']) + if self.is_installed(): + self.gui.app_buttonvar.set(f"Run {self.conf.faithlife_product}") + self.gui.app_button.config(command=self.run_logos) + self.gui.get_winetricks_button.state(['!disabled']) + self.gui.logging_button.state(['!disabled']) + self.gui.app_install_advanced.grid_forget() + else: + self.gui.app_button.config(command=self.run_install) + self.gui.app_install_advanced.config(command=self.run_installer) + self.gui.show_advanced_install_button() + # This function checks to make sure the product/version/channel is non-None + if self.conf._network._faithlife_product_releases( + self.conf._raw.faithlife_product, + self.conf._raw.faithlife_product_version, + self.conf._raw.faithlife_product_release_channel + ): + # Everything is ready, we can install + self.gui.app_button.state(['!disabled']) + self.gui.app_install_advanced.state(['!disabled']) + else: + # Disable Both install buttons + self.gui.app_button.state(['disabled']) + self.gui.app_install_advanced.state(['disabled']) def update_latest_lli_release_button(self, evt=None): msg = None + result = utils.compare_logos_linux_installer_version(self) if system.get_runmode() != 'binary': state = 'disabled' msg = "This button is disabled. Can't run self-update from script." - elif config.logos_linux_installer_status == 0: + elif result == utils.VersionComparison.OUT_OF_DATE: state = '!disabled' - elif config.logos_linux_installer_status == 1: + elif result == utils.VersionComparison.UP_TO_DATE: state = 'disabled' - msg = f"This button is disabled. {config.name_app} is up-to-date." # noqa: E501 - elif config.logos_linux_installer_status == 2: + msg = f"This button is disabled. {constants.APP_NAME} is up-to-date." # noqa: E501 + elif result == utils.VersionComparison.DEVELOPMENT: state = 'disabled' - msg = f"This button is disabled. {config.name_app} is newer than the latest release." # noqa: E501 + msg = f"This button is disabled. {constants.APP_NAME} is newer than the latest release." # noqa: E501 if msg: gui.ToolTip(self.gui.update_lli_button, msg) - self.clear_status_text() - self.stop_indeterminate_progress() + self.clear_status() self.gui.update_lli_button.state([state]) def update_latest_appimage_button(self, evt=None): - status, reason = utils.compare_recommended_appimage_version() + state = None msg = None - if status == 0: - state = '!disabled' - elif status == 1: - state = 'disabled' - msg = "This button is disabled. The AppImage is already set to the latest recommended." # noqa: E501 - elif status == 2: + if not self.is_installed(): + state = "disabled" + msg = "Please install first" + elif self.conf._raw.wine_binary_code not in ["Recommended", "AppImage", None]: # noqa: E501 state = 'disabled' - msg = "This button is disabled. The AppImage version is newer than the latest recommended." # noqa: E501 + msg = "This button is disabled. The configured install was not created using an AppImage." # noqa: E501 + self.gui.set_appimage_button.state(['disabled']) + gui.ToolTip( + self.gui.set_appimage_button, + "This button is disabled. The configured install was not created using an AppImage." # noqa: E501 + ) + elif self.conf._raw.wine_binary is not None: + status, _ = utils.compare_recommended_appimage_version(self) + if status == 0: + state = '!disabled' + elif status == 1: + state = 'disabled' + msg = "This button is disabled. The AppImage is already set to the latest recommended." # noqa: E501 + elif status == 2: + state = 'disabled' + msg = "This button is disabled. The AppImage version is newer than the latest recommended." # noqa: E501 + else: + # Failed to check + state = '!disabled' + else: + # Not enough context to figure out if this should be enabled or not + state = '!disabled' if msg: gui.ToolTip(self.gui.latest_appimage_button, msg) - self.clear_status_text() - self.stop_indeterminate_progress() + self.clear_status() self.gui.latest_appimage_button.state([state]) def update_run_winetricks_button(self, evt=None): - if utils.file_exists(config.WINETRICKSBIN): - state = '!disabled' + if self.conf._raw.winetricks_binary is not None: + # Path may be stored as relative + if Path(self.conf._raw.winetricks_binary).is_absolute(): + winetricks_binary = self.conf._raw.winetricks_binary + elif self.conf._raw.install_dir is not None: + winetricks_binary = str(Path(self.conf._raw.install_dir) / self.conf._raw.winetricks_binary) #noqa: E501 + else: + winetricks_binary = None + + if winetricks_binary is not None and utils.file_exists(winetricks_binary): + state = '!disabled' + else: + state = 'disabled' else: state = 'disabled' self.gui.run_winetricks_button.state([state]) - def reverse_logging_state_value(self, state): - if state == 'DISABLED': + def _config_update_hook(self, evt=None): + self.update_logging_button() + self.update_app_button() + self.update_run_winetricks_button() + try: + self.update_latest_lli_release_button() + except Exception: + logging.exception("Failed to update release button") + try: + self.update_latest_appimage_button() + except Exception: + logging.exception("Failed to update appimage button") + + + def current_logging_state_value(self) -> str: + if self.conf.faithlife_product_logging: return 'ENABLED' else: return 'DISABLED' - def clear_status_text(self, evt=None): - self.gui.statusvar.set('') - - def update_file_check_progress(self, evt=None): - self.gui.progress.stop() - self.gui.statusvar.set('') - self.gui.progress.config(mode='determinate') - self.gui.progressvar.set(0) - - def update_download_progress(self, evt=None): - d = self.get_q.get() - self.gui.progressvar.set(int(d)) - - def update_progress(self, evt=None): - progress = self.progress_q.get() - if not type(progress) is int: - return - if progress >= 100: - self.gui.progressvar.set(0) - # self.gui.progress.state(['disabled']) + def reverse_logging_state_value(self, state) ->str: + if state == 'DISABLED': + return 'ENABLED' else: - self.gui.progressvar.set(progress) + return 'DISABLED' - def update_status_text(self, evt=None): - if evt: - self.gui.statusvar.set(self.status_q.get()) - self.root.after(3000, self.update_status_text) - else: # clear status text if called manually and no progress shown - if self.gui.progressvar.get() == 0: - self.gui.statusvar.set('') + def clear_status(self): + self._status('', 0) - def start_indeterminate_progress(self, evt=None): - self.gui.progress.state(['!disabled']) - self.gui.progressvar.set(0) - self.gui.progress.config(mode='indeterminate') - self.gui.progress.start() - def stop_indeterminate_progress(self, evt=None): - self.gui.progress.stop() - self.gui.progress.state(['disabled']) - self.gui.progress.config(mode='determinate') - self.gui.progressvar.set(0) -def control_panel_app(): - utils.set_debug() - classname = config.name_binary +def control_panel_app(ephemeral_config: EphemeralConfiguration): + classname = constants.BINARY_NAME root = Root(className=classname) - ControlWindow(root, class_=classname) + + # Need to title/resize and create the initial gui + # BEFORE mainloop is started to get sizing correct + # other things in the ControlWindow constructor are run after mainloop is running + # To allow them to ask questions while the mainloop is running + root.title(f"{constants.APP_NAME} Control Panel") + root.resizable(False, False) + control_gui = gui.ControlGui(root) + + def _start_control_panel(): + ControlWindow(root, control_gui, ephemeral_config, class_=classname) + + # Start the control panel on a new thread so it can open dialogs + # as a part of it's constructor + threading.Thread( + name=f"{constants.APP_NAME} GUI main loop", + target=_start_control_panel, + daemon=True + ).start() + root.mainloop() diff --git a/ou_dedetai/img/logos4-128-icon.png b/ou_dedetai/img/Logos-128-icon.png similarity index 100% rename from ou_dedetai/img/logos4-128-icon.png rename to ou_dedetai/img/Logos-128-icon.png diff --git a/ou_dedetai/img/verbum-128-icon.png b/ou_dedetai/img/Verbum-128-icon.png similarity index 100% rename from ou_dedetai/img/verbum-128-icon.png rename to ou_dedetai/img/Verbum-128-icon.png diff --git a/ou_dedetai/installer.py b/ou_dedetai/installer.py index 1edfc134..59f5ef3d 100644 --- a/ou_dedetai/installer.py +++ b/ou_dedetai/installer.py @@ -4,526 +4,216 @@ import sys from pathlib import Path -from . import config -from . import msg +from ou_dedetai.app import App + +from . import constants from . import network from . import system from . import utils from . import wine -def ensure_product_choice(app=None): - config.INSTALL_STEPS_COUNT += 1 - update_install_feedback("Choose product…", app=app) - logging.debug('- config.FLPRODUCT') - logging.debug('- config.FLPRODUCTi') - logging.debug('- config.VERBUM_PATH') - - if not config.FLPRODUCT: - if config.DIALOG == 'cli': - app.input_q.put( - ( - "Choose which FaithLife product the script should install: ", # noqa: E501 - ["Logos", "Verbum", "Exit"] - ) - ) - app.input_event.set() - app.choice_event.wait() - app.choice_event.clear() - config.FLPRODUCT = app.choice_q.get() - else: - utils.send_task(app, 'FLPRODUCT') - if config.DIALOG == 'curses': - app.product_e.wait() - config.FLPRODUCT = app.product_q.get() - else: - if config.DIALOG == 'curses' and app: - app.set_product(config.FLPRODUCT) - - config.FLPRODUCTi = get_flproducti_name(config.FLPRODUCT) - if config.FLPRODUCT == 'Logos': - config.VERBUM_PATH = "/" - elif config.FLPRODUCT == 'Verbum': - config.VERBUM_PATH = "/Verbum/" - - logging.debug(f"> {config.FLPRODUCT=}") - logging.debug(f"> {config.FLPRODUCTi=}") - logging.debug(f"> {config.VERBUM_PATH=}") - - -def ensure_version_choice(app=None): - config.INSTALL_STEPS_COUNT += 1 - ensure_product_choice(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Choose version…", app=app) - logging.debug('- config.TARGETVERSION') - if not config.TARGETVERSION: - if config.DIALOG == 'cli': - app.input_q.put( - ( - f"Which version of {config.FLPRODUCT} should the script install?: ", # noqa: E501 - ["10", "9", "Exit"] - ) - ) - app.input_event.set() - app.choice_event.wait() - app.choice_event.clear() - config.TARGETVERSION = app.choice_q.get() - else: - utils.send_task(app, 'TARGETVERSION') - if config.DIALOG == 'curses': - app.version_e.wait() - config.TARGETVERSION = app.version_q.get() - else: - if config.DIALOG == 'curses' and app: - app.set_version(config.TARGETVERSION) - - logging.debug(f"> {config.TARGETVERSION=}") - - -def ensure_release_choice(app=None): - config.INSTALL_STEPS_COUNT += 1 - ensure_version_choice(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Choose product release…", app=app) - logging.debug('- config.TARGET_RELEASE_VERSION') - - if not config.TARGET_RELEASE_VERSION: - if config.DIALOG == 'cli': - utils.start_thread( - network.get_logos_releases, - daemon_bool=True, - app=app - ) - app.input_event.set() - app.choice_event.wait() - app.choice_event.clear() - config.TARGET_RELEASE_VERSION = app.choice_q.get() - else: - utils.send_task(app, 'TARGET_RELEASE_VERSION') - if config.DIALOG == 'curses': - app.release_e.wait() - config.TARGET_RELEASE_VERSION = app.release_q.get() - logging.debug(f"{config.TARGET_RELEASE_VERSION=}") - else: - if config.DIALOG == 'curses' and app: - app.set_release(config.TARGET_RELEASE_VERSION) - - logging.debug(f"> {config.TARGET_RELEASE_VERSION=}") - - -def ensure_install_dir_choice(app=None): - config.INSTALL_STEPS_COUNT += 1 - ensure_release_choice(app=app) - config.INSTALL_STEP += 1 - update_install_feedback( - "Choose installation folder…", - app=app - ) - logging.debug('- config.INSTALLDIR') - - default = f"{str(Path.home())}/{config.FLPRODUCT}Bible{config.TARGETVERSION}" # noqa: E501 - if not config.INSTALLDIR: - if config.DIALOG == 'cli': - default = f"{str(Path.home())}/{config.FLPRODUCT}Bible{config.TARGETVERSION}" # noqa: E501 - question = f"Where should {config.FLPRODUCT} files be installed to?: " # noqa: E501 - app.input_q.put( - ( - question, - [default, "Type your own custom path", "Exit"] - ) - ) - app.input_event.set() - app.choice_event.wait() - app.choice_event.clear() - config.INSTALLDIR = app.choice_q.get() - elif config.DIALOG == 'tk': - config.INSTALLDIR = default - elif config.DIALOG == 'curses': - utils.send_task(app, 'INSTALLDIR') - app.installdir_e.wait() - config.INSTALLDIR = app.installdir_q.get() - config.APPDIR_BINDIR = f"{config.INSTALLDIR}/data/bin" - else: - if config.DIALOG == 'curses' and app: - app.set_installdir(config.INSTALLDIR) - - logging.debug(f"> {config.INSTALLDIR=}") - logging.debug(f"> {config.APPDIR_BINDIR=}") - - -def ensure_wine_choice(app=None): - config.INSTALL_STEPS_COUNT += 1 - ensure_install_dir_choice(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Choose wine binary…", app=app) - logging.debug('- config.SELECTED_APPIMAGE_FILENAME') - logging.debug('- config.RECOMMENDED_WINE64_APPIMAGE_URL') - logging.debug('- config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME') - logging.debug('- config.RECOMMENDED_WINE64_APPIMAGE_FILENAME') - logging.debug('- config.WINE_EXE') - logging.debug('- config.WINEBIN_CODE') - - if utils.get_wine_exe_path() is None: - network.set_recommended_appimage_config() - if config.DIALOG == 'cli': - options = utils.get_wine_options( - utils.find_appimage_files(config.TARGET_RELEASE_VERSION), - utils.find_wine_binary_files(config.TARGET_RELEASE_VERSION) - ) - app.input_q.put( - ( - f"Which Wine AppImage or binary should the script use to install {config.FLPRODUCT} v{config.TARGET_RELEASE_VERSION} in {config.INSTALLDIR}?: ", # noqa: E501 - options - ) - ) - app.input_event.set() - app.choice_event.wait() - app.choice_event.clear() - config.WINE_EXE = utils.get_relative_path( - utils.get_config_var(app.choice_q.get()), - config.INSTALLDIR - ) - else: - utils.send_task(app, 'WINE_EXE') - if config.DIALOG == 'curses': - app.wine_e.wait() - config.WINE_EXE = app.wines_q.get() - # GUI uses app.wines_q for list of available, then app.wine_q - # for the user's choice of specific binary. - elif config.DIALOG == 'tk': - config.WINE_EXE = app.wine_q.get() - - else: - if config.DIALOG == 'curses' and app: - app.set_wine(utils.get_wine_exe_path()) - - # Set WINEBIN_CODE and SELECTED_APPIMAGE_FILENAME. - m = f"Preparing to process WINE_EXE. Currently set to: {utils.get_wine_exe_path()}." # noqa: E501 - logging.debug(m) - if str(utils.get_wine_exe_path()).lower().endswith('.appimage'): - config.SELECTED_APPIMAGE_FILENAME = str(utils.get_wine_exe_path()) - if not config.WINEBIN_CODE: - config.WINEBIN_CODE = utils.get_winebin_code_and_desc(utils.get_wine_exe_path())[0] # noqa: E501 - - logging.debug(f"> {config.SELECTED_APPIMAGE_FILENAME=}") - logging.debug(f"> {config.RECOMMENDED_WINE64_APPIMAGE_URL=}") - logging.debug(f"> {config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME=}") - logging.debug(f"> {config.RECOMMENDED_WINE64_APPIMAGE_FILENAME=}") - logging.debug(f"> {config.WINEBIN_CODE=}") - logging.debug(f"> {utils.get_wine_exe_path()=}") - - -def ensure_winetricks_choice(app=None): - config.INSTALL_STEPS_COUNT += 1 - ensure_wine_choice(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Choose winetricks binary…", app=app) - logging.debug('- config.WINETRICKSBIN') - - if config.WINETRICKSBIN is None: - # Check if local winetricks version available; else, download it. - config.WINETRICKSBIN = f"{config.APPDIR_BINDIR}/winetricks" - - winetricks_options = utils.get_winetricks_options() - - if config.DIALOG == 'cli': - app.input_q.put( - ( - f"Should the script use the system's local winetricks or download the latest winetricks from the Internet? The script needs to set some Wine options that {config.FLPRODUCT} requires on Linux.", # noqa: E501 - winetricks_options - ) - ) - app.input_event.set() - app.choice_event.wait() - app.choice_event.clear() - winetricksbin = app.choice_q.get() - else: - utils.send_task(app, 'WINETRICKSBIN') - if config.DIALOG == 'curses': - app.tricksbin_e.wait() - winetricksbin = app.tricksbin_q.get() - - if not winetricksbin.startswith('Download'): - config.WINETRICKSBIN = winetricksbin - else: - config.WINETRICKSBIN = winetricks_options[0] - - logging.debug(f"> {config.WINETRICKSBIN=}") - - -def ensure_install_fonts_choice(app=None): - config.INSTALL_STEPS_COUNT += 1 - ensure_winetricks_choice(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Ensuring install fonts choice…", app=app) - logging.debug('- config.SKIP_FONTS') - - logging.debug(f"> {config.SKIP_FONTS=}") - - -def ensure_check_sys_deps_choice(app=None): - config.INSTALL_STEPS_COUNT += 1 - ensure_install_fonts_choice(app=app) - config.INSTALL_STEP += 1 - update_install_feedback( - "Ensuring check system dependencies choice…", - app=app - ) - logging.debug('- config.SKIP_DEPENDENCIES') - - logging.debug(f"> {config.SKIP_DEPENDENCIES=}") - - -def ensure_installation_config(app=None): - config.INSTALL_STEPS_COUNT += 1 - ensure_check_sys_deps_choice(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Ensuring installation config is set…", app=app) - logging.debug('- config.LOGOS_ICON_URL') - logging.debug('- config.LOGOS_ICON_FILENAME') - logging.debug('- config.LOGOS_VERSION') - logging.debug('- config.LOGOS64_MSI') - logging.debug('- config.LOGOS64_URL') - - # Set icon variables. - app_dir = Path(__file__).parent - flproducti = get_flproducti_name(config.FLPRODUCT) - logos_icon_url = app_dir / 'img' / f"{flproducti}-128-icon.png" - config.LOGOS_ICON_URL = str(logos_icon_url) - config.LOGOS_ICON_FILENAME = logos_icon_url.name - config.LOGOS64_URL = f"https://downloads.logoscdn.com/LBS{config.TARGETVERSION}{config.VERBUM_PATH}Installer/{config.TARGET_RELEASE_VERSION}/{config.FLPRODUCT}-x64.msi" # noqa: E501 - - config.LOGOS_VERSION = config.TARGET_RELEASE_VERSION - config.LOGOS64_MSI = Path(config.LOGOS64_URL).name - - logging.debug(f"> {config.LOGOS_ICON_URL=}") - logging.debug(f"> {config.LOGOS_ICON_FILENAME=}") - logging.debug(f"> {config.LOGOS_VERSION=}") - logging.debug(f"> {config.LOGOS64_MSI=}") - logging.debug(f"> {config.LOGOS64_URL=}") - - if config.DIALOG in ['curses', 'dialog', 'tk']: - utils.send_task(app, 'INSTALL') - else: - msg.logos_msg("Install is running…") - - -def ensure_install_dirs(app=None): - config.INSTALL_STEPS_COUNT += 1 - ensure_installation_config(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Ensuring installation directories…", app=app) - logging.debug('- config.INSTALLDIR') - logging.debug('- config.WINEPREFIX') - logging.debug('- data/bin') - logging.debug('- data/wine64_bottle') +# This step doesn't do anything per-say, but "collects" all the choices in one step +# The app would continue to work without this function +def ensure_choices(app: App): + app.installer_step_count += 1 + + app.status("Asking questions if needed…") + + # Prompts (by nature of access and debug prints a number of choices the user has + logging.debug(f"> {app.conf.faithlife_product=}") + logging.debug(f"> {app.conf.faithlife_product_version=}") + logging.debug(f"> {app.conf.faithlife_product_release=}") + logging.debug(f"> {app.conf.install_dir=}") + logging.debug(f"> {app.conf.installer_binary_dir=}") + logging.debug(f"> {app.conf.wine_appimage_path=}") + logging.debug(f"> {app.conf.wine_appimage_recommended_url=}") + logging.debug(f"> {app.conf.wine_appimage_recommended_file_name=}") + logging.debug(f"> {app.conf.wine_binary_code=}") + logging.debug(f"> {app.conf.wine_binary=}") + logging.debug(f"> {app.conf._raw.winetricks_binary=}") + logging.debug(f"> {app.conf.skip_install_fonts=}") + logging.debug(f"> {app.conf._overrides.winetricks_skip=}") + logging.debug(f"> {app.conf.faithlife_product_icon_path}") + logging.debug(f"> {app.conf.faithlife_installer_download_url}") + # Debug print the entire config + logging.debug(f"> Config={app.conf.__dict__}") + + app.status("Install is running…") + + + +def ensure_install_dirs(app: App): + app.installer_step_count += 1 + ensure_choices(app=app) + app.installer_step += 1 + app.status("Ensuring installation directories…") wine_dir = Path("") - if config.INSTALLDIR is None: - config.INSTALLDIR = f"{os.getenv('HOME')}/{config.FLPRODUCT}Bible{config.TARGETVERSION}" # noqa: E501 - - config.APPDIR_BINDIR = f"{config.INSTALLDIR}/data/bin" - bin_dir = Path(config.APPDIR_BINDIR) + bin_dir = Path(app.conf.installer_binary_dir) bin_dir.mkdir(parents=True, exist_ok=True) logging.debug(f"> {bin_dir} exists?: {bin_dir.is_dir()}") - logging.debug(f"> {config.INSTALLDIR=}") - logging.debug(f"> {config.APPDIR_BINDIR=}") + logging.debug(f"> {app.conf.install_dir=}") + logging.debug(f"> {app.conf.installer_binary_dir=}") - config.WINEPREFIX = f"{config.INSTALLDIR}/data/wine64_bottle" - wine_dir = Path(f"{config.WINEPREFIX}") + wine_dir = Path(f"{app.conf.wine_prefix}") wine_dir.mkdir(parents=True, exist_ok=True) logging.debug(f"> {wine_dir} exists: {wine_dir.is_dir()}") - logging.debug(f"> {config.WINEPREFIX=}") - - if config.DIALOG in ['curses', 'dialog', 'tk']: - utils.send_task(app, 'INSTALLING') + logging.debug(f"> {app.conf.wine_prefix=}") -def ensure_sys_deps(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_sys_deps(app: App): + app.installer_step_count += 1 ensure_install_dirs(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Ensuring system dependencies are met…", app=app) + app.installer_step += 1 + app.status("Ensuring system dependencies are met…") - if not config.SKIP_DEPENDENCIES: + if not app.conf.skip_install_system_dependencies: utils.install_dependencies(app) - if config.DIALOG == "curses": - app.installdeps_e.wait() logging.debug("> Done.") else: logging.debug("> Skipped.") -def ensure_appimage_download(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_appimage_download(app: App): + app.installer_step_count += 1 ensure_sys_deps(app=app) - config.INSTALL_STEP += 1 - if config.TARGETVERSION != '9' and not str(utils.get_wine_exe_path()).lower().endswith('appimage'): # noqa: E501 + app.installer_step += 1 + if app.conf.faithlife_product_version != '9' and not str(app.conf.wine_binary).lower().endswith('appimage'): # noqa: E501 return - update_install_feedback( - "Ensuring wine AppImage is downloaded…", - app=app - ) + app.status("Ensuring wine AppImage is downloaded…") downloaded_file = None - filename = Path(config.SELECTED_APPIMAGE_FILENAME).name - downloaded_file = utils.get_downloaded_file_path(filename) + appimage_path = app.conf.wine_appimage_path or app.conf.wine_appimage_recommended_file_name #noqa: E501 + filename = Path(appimage_path).name + downloaded_file = utils.get_downloaded_file_path(app.conf.download_dir, filename) if not downloaded_file: - downloaded_file = Path(f"{config.MYDOWNLOADS}/{filename}") + downloaded_file = Path(f"{app.conf.download_dir}/{filename}") network.logos_reuse_download( - config.RECOMMENDED_WINE64_APPIMAGE_URL, + app.conf.wine_appimage_recommended_url, filename, - config.MYDOWNLOADS, + app.conf.download_dir, app=app, ) if downloaded_file: logging.debug(f"> File exists?: {downloaded_file}: {Path(downloaded_file).is_file()}") # noqa: E501 -def ensure_wine_executables(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_wine_executables(app: App): + app.installer_step_count += 1 ensure_appimage_download(app=app) - config.INSTALL_STEP += 1 - update_install_feedback( - "Ensuring wine executables are available…", - app=app - ) - logging.debug('- config.WINESERVER_EXE') - logging.debug('- wine') - logging.debug('- wine64') - logging.debug('- wineserver') - - # Add APPDIR_BINDIR to PATH. - if not os.access(utils.get_wine_exe_path(), os.X_OK): - msg.status("Creating wine appimage symlinks…", app=app) - create_wine_appimage_symlinks(app=app) + app.installer_step += 1 + app.status("Ensuring wine executables are available…") - # Set WINESERVER_EXE. - config.WINESERVER_EXE = f"{config.APPDIR_BINDIR}/wineserver" + create_wine_appimage_symlinks(app=app) # PATH is modified if wine appimage isn't found, but it's not modified # during a restarted installation, so shutil.which doesn't find the # executables in that case. - logging.debug(f"> {config.WINESERVER_EXE=}") - logging.debug(f"> wine path: {config.APPDIR_BINDIR}/wine") - logging.debug(f"> wine64 path: {config.APPDIR_BINDIR}/wine64") - logging.debug(f"> wineserver path: {config.APPDIR_BINDIR}/wineserver") - logging.debug(f"> winetricks path: {config.APPDIR_BINDIR}/winetricks") + logging.debug(f"> {app.conf.wine_binary=}") + logging.debug(f"> {app.conf.wine64_binary=}") + logging.debug(f"> {app.conf.wineserver_binary=}") + logging.debug(f"> {app.conf._raw.winetricks_binary=}") -def ensure_winetricks_executable(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_winetricks_executable(app: App): + app.installer_step_count += 1 ensure_wine_executables(app=app) - config.INSTALL_STEP += 1 - update_install_feedback( - "Ensuring winetricks executable is available…", - app=app - ) + app.installer_step += 1 + app.status("Ensuring winetricks executable is available…") - if config.WINETRICKSBIN is None or config.WINETRICKSBIN.startswith('Download'): # noqa: E501 - config.WINETRICKSBIN = f"{config.APPDIR_BINDIR}/winetricks" # default - if not os.access(config.WINETRICKSBIN, os.X_OK): - # Either previous system winetricks is no longer accessible, or the - # or the user has chosen to download it. - msg.status("Downloading winetricks from the Internet…", app=app) - system.install_winetricks(config.APPDIR_BINDIR, app=app) + if app.conf._winetricks_binary is None: + app.status("Downloading winetricks from the Internet…") + system.install_winetricks(app.conf.installer_binary_dir, app=app) - logging.debug(f"> {config.WINETRICKSBIN} is executable?: {os.access(config.WINETRICKSBIN, os.X_OK)}") # noqa: E501 + logging.debug(f"> {app.conf.winetricks_binary} is executable?: {os.access(app.conf.winetricks_binary, os.X_OK)}") # noqa: E501 return 0 -def ensure_premade_winebottle_download(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_premade_winebottle_download(app: App): + app.installer_step_count += 1 ensure_winetricks_executable(app=app) - config.INSTALL_STEP += 1 - if config.TARGETVERSION != '9': + app.installer_step += 1 + if app.conf.faithlife_product_version != '9': return - update_install_feedback( - f"Ensuring {config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME} bottle is downloaded…", # noqa: E501 - app=app - ) + app.status(f"Ensuring {constants.LOGOS9_WINE64_BOTTLE_TARGZ_NAME} bottle is downloaded…") # noqa: E501 - downloaded_file = utils.get_downloaded_file_path(config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME) # noqa: E501 + downloaded_file = utils.get_downloaded_file_path(app.conf.download_dir, constants.LOGOS9_WINE64_BOTTLE_TARGZ_NAME) # noqa: E501 if not downloaded_file: - downloaded_file = Path(config.MYDOWNLOADS) / config.LOGOS_EXECUTABLE + downloaded_file = Path(app.conf.download_dir) / app.conf.faithlife_installer_name #noqa: E501 network.logos_reuse_download( - config.LOGOS9_WINE64_BOTTLE_TARGZ_URL, - config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME, - config.MYDOWNLOADS, + constants.LOGOS9_WINE64_BOTTLE_TARGZ_URL, + constants.LOGOS9_WINE64_BOTTLE_TARGZ_NAME, + app.conf.download_dir, app=app, ) # Install bottle. - bottle = Path(f"{config.INSTALLDIR}/data/wine64_bottle") + bottle = Path(app.conf.wine_prefix) if not bottle.is_dir(): + # FIXME: this code seems to be logos 9 specific, why is it here? utils.install_premade_wine_bottle( - config.MYDOWNLOADS, - f"{config.INSTALLDIR}/data" + app.conf.download_dir, + f"{app.conf.install_dir}/data" ) logging.debug(f"> '{downloaded_file}' exists?: {Path(downloaded_file).is_file()}") # noqa: E501 -def ensure_product_installer_download(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_product_installer_download(app: App): + app.installer_step_count += 1 ensure_premade_winebottle_download(app=app) - config.INSTALL_STEP += 1 - update_install_feedback( - f"Ensuring {config.FLPRODUCT} installer is downloaded…", - app=app - ) + app.installer_step += 1 + app.status(f"Ensuring {app.conf.faithlife_product} installer is downloaded…") - config.LOGOS_EXECUTABLE = f"{config.FLPRODUCT}_v{config.LOGOS_VERSION}-x64.msi" # noqa: E501 - downloaded_file = utils.get_downloaded_file_path(config.LOGOS_EXECUTABLE) + downloaded_file = utils.get_downloaded_file_path(app.conf.download_dir, app.conf.faithlife_installer_name) #noqa: E501 if not downloaded_file: - downloaded_file = Path(config.MYDOWNLOADS) / config.LOGOS_EXECUTABLE + downloaded_file = Path(app.conf.download_dir) / app.conf.faithlife_installer_name #noqa: E501 network.logos_reuse_download( - config.LOGOS64_URL, - config.LOGOS_EXECUTABLE, - config.MYDOWNLOADS, + app.conf.faithlife_installer_download_url, + app.conf.faithlife_installer_name, + app.conf.download_dir, app=app, ) - # Copy file into INSTALLDIR. - installer = Path(f"{config.INSTALLDIR}/data/{config.LOGOS_EXECUTABLE}") + # Copy file into install dir. + installer = Path(f"{app.conf.install_dir}/data/{app.conf.faithlife_installer_name}") if not installer.is_file(): shutil.copy(downloaded_file, installer.parent) logging.debug(f"> '{downloaded_file}' exists?: {Path(downloaded_file).is_file()}") # noqa: E501 -def ensure_wineprefix_init(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_wineprefix_init(app: App): + app.installer_step_count += 1 ensure_product_installer_download(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Ensuring wineprefix is initialized…", app=app) + app.installer_step += 1 + app.status("Ensuring wineprefix is initialized…") - init_file = Path(f"{config.WINEPREFIX}/system.reg") + init_file = Path(f"{app.conf.wine_prefix}/system.reg") logging.debug(f"{init_file=}") if not init_file.is_file(): logging.debug(f"{init_file} does not exist") - if config.TARGETVERSION == '9': + if app.conf.faithlife_product_version == '9': utils.install_premade_wine_bottle( - config.MYDOWNLOADS, - f"{config.INSTALLDIR}/data", + app.conf.download_dir, + f"{app.conf.install_dir}/data", ) else: logging.debug("Initializing wineprefix.") - process = wine.initializeWineBottle() - wine.wait_pid(process) + process = wine.initializeWineBottle(app.conf.wine64_binary, app) + system.wait_pid(process) # wine.light_wineserver_wait() - wine.wineserver_wait() + wine.wineserver_wait(app) logging.debug("Wine init complete.") logging.debug(f"> {init_file} exists?: {init_file.is_file()}") -def ensure_winetricks_applied(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_winetricks_applied(app: App): + app.installer_step_count += 1 ensure_wineprefix_init(app=app) - config.INSTALL_STEP += 1 - status = "Ensuring winetricks & other settings are applied…" - update_install_feedback(status, app=app) + app.installer_step += 1 + app.status("Ensuring winetricks & other settings are applied…") logging.debug('- disable winemenubuilder') logging.debug('- settings renderer=gdi') logging.debug('- corefonts') @@ -531,132 +221,96 @@ def ensure_winetricks_applied(app=None): logging.debug('- settings fontsmooth=rgb') logging.debug('- d3dcompiler_47') - if not config.SKIP_WINETRICKS: + if not app.conf.skip_winetricks: usr_reg = None sys_reg = None - workdir = Path(f"{config.WORKDIR}") - workdir.mkdir(parents=True, exist_ok=True) - usr_reg = Path(f"{config.WINEPREFIX}/user.reg") - sys_reg = Path(f"{config.WINEPREFIX}/system.reg") + usr_reg = Path(f"{app.conf.wine_prefix}/user.reg") + sys_reg = Path(f"{app.conf.wine_prefix}/system.reg") + + # FIXME: consider supplying progresses to these sub-steps if not utils.grep(r'"winemenubuilder.exe"=""', usr_reg): - msg.status("Disabling winemenubuilder…", app) - wine.disable_winemenubuilder() + app.status("Disabling winemenubuilder…") + wine.disable_winemenubuilder(app, app.conf.wine64_binary) if not utils.grep(r'"renderer"="gdi"', usr_reg): - msg.status("Setting Renderer to GDI…", app) - wine.set_renderer("gdi") + app.status("Setting Renderer to GDI…") + wine.set_renderer(app, "gdi") if not utils.grep(r'"FontSmoothingType"=dword:00000002', usr_reg): - msg.status("Setting Font Smooting to RGB…", app) - wine.install_font_smoothing() + app.status("Setting Font Smoothing to RGB…") + wine.install_font_smoothing(app) - if not config.SKIP_FONTS and not utils.grep(r'"Tahoma \(TrueType\)"="tahoma.ttf"', sys_reg): # noqa: E501 - msg.status("Installing fonts…", app) - wine.install_fonts() + if not app.conf.skip_install_fonts and not utils.grep(r'"Tahoma \(TrueType\)"="tahoma.ttf"', sys_reg): # noqa: E501 + app.status("Installing fonts…") + wine.install_fonts(app) if not utils.grep(r'"\*d3dcompiler_47"="native"', usr_reg): - msg.status("Installing D3D…", app) - wine.install_d3d_compiler() + app.status("Installing D3D…") + wine.install_d3d_compiler(app) if not utils.grep(r'"ProductName"="Microsoft Windows 10"', sys_reg): - msg.status(f"Setting {config.FLPRODUCT} to Win10 Mode…", app) - wine.set_win_version("logos", "win10") + app.status(f"Setting {app.conf.faithlife_product} to Win10 Mode…") + wine.set_win_version(app, "logos", "win10") # NOTE: Can't use utils.grep check here because the string # "Version"="win10" might appear elsewhere in the registry. - msg.logos_msg(f"Setting {config.FLPRODUCT} Bible Indexing to Win10 Mode…") # noqa: E501 - wine.set_win_version("indexer", "win10") + app.status(f"Setting {app.conf.faithlife_product} Bible Indexing to Win10 Mode…") # noqa: E501 + wine.set_win_version(app, "indexer", "win10") # wine.light_wineserver_wait() - wine.wineserver_wait() + wine.wineserver_wait(app) logging.debug("> Done.") -def ensure_icu_data_files(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_icu_data_files(app: App): + app.installer_step_count += 1 ensure_winetricks_applied(app=app) - config.INSTALL_STEP += 1 - status = "Ensuring ICU data files are installed…" - update_install_feedback(status, app=app) + app.installer_step += 1 + app.status("Ensuring ICU data files are installed…") logging.debug('- ICU data files') wine.enforce_icu_data_files(app=app) - if config.DIALOG == "curses": - app.install_icu_e.wait() - logging.debug('> ICU data files installed') -def ensure_product_installed(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_product_installed(app: App): + app.installer_step_count += 1 ensure_icu_data_files(app=app) - config.INSTALL_STEP += 1 - update_install_feedback( - f"Ensuring {config.FLPRODUCT} is installed…", - app=app - ) - - if not utils.find_installed_product(): - process = wine.install_msi() - wine.wait_pid(process) - config.LOGOS_EXE = utils.find_installed_product() - config.current_logos_version = config.TARGET_RELEASE_VERSION + app.installer_step += 1 + app.status(f"Ensuring {app.conf.faithlife_product} is installed…") - wine.set_logos_paths() + if not app.is_installed(): + # FIXME: Should we try to cleanup on a failed msi? + # Like terminating msiexec if already running for Logos + process = wine.install_msi(app) + system.wait_pid(process) # Clean up temp files, etc. utils.clean_all() - logging.debug(f"> Product path: {config.LOGOS_EXE}") + logging.debug(f"> {app.conf.logos_exe=}") -def ensure_config_file(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_config_file(app: App): + app.installer_step_count += 1 ensure_product_installed(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Ensuring config file is up-to-date…", app=app) - - if not Path(config.CONFIG_FILE).is_file(): - logging.info(f"No config file at {config.CONFIG_FILE}") - create_config_file() - else: - logging.info(f"Config file exists at {config.CONFIG_FILE}.") - if config_has_changed(): - if config.DIALOG == 'cli': - if msg.logos_acknowledge_question( - f"Update config file at {config.CONFIG_FILE}?", - "The existing config file was not overwritten.", - "" - ): - logging.info("Updating config file.") - utils.write_config(config.CONFIG_FILE) - else: - utils.send_task(app, 'CONFIG') - if config.DIALOG == 'curses': - app.config_e.wait() - - if config.DIALOG == 'cli': - msg.logos_msg("Install has finished.") - else: - utils.send_task(app, 'DONE') + app.installer_step += 1 + app.status("Ensuring config file is up-to-date…") - logging.debug(f"> File exists?: {config.CONFIG_FILE}: {Path(config.CONFIG_FILE).is_file()}") # noqa: E501 + app.status("Install has finished.", 100) -def ensure_launcher_executable(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_launcher_executable(app: App): + app.installer_step_count += 1 ensure_config_file(app=app) - config.INSTALL_STEP += 1 + app.installer_step += 1 runmode = system.get_runmode() if runmode == 'binary': - update_install_feedback( - f"Copying launcher to {config.INSTALLDIR}…", - app=app - ) + app.status(f"Copying launcher to {app.conf.install_dir}…") - # Copy executable to config.INSTALLDIR. - launcher_exe = Path(f"{config.INSTALLDIR}/{config.name_binary}") + # Copy executable into install dir. + launcher_exe = Path(f"{app.conf.install_dir}/{constants.BINARY_NAME}") if launcher_exe.is_file(): logging.debug("Removing existing launcher binary.") launcher_exe.unlink() @@ -664,111 +318,75 @@ def ensure_launcher_executable(app=None): shutil.copy(sys.executable, launcher_exe) logging.debug(f"> File exists?: {launcher_exe}: {launcher_exe.is_file()}") # noqa: E501 else: - update_install_feedback( - "Running from source. Skipping launcher creation.", - app=app + app.status( + "Running from source. Skipping launcher copy." ) -def ensure_launcher_shortcuts(app=None): - config.INSTALL_STEPS_COUNT += 1 +def ensure_launcher_shortcuts(app: App): + app.installer_step_count += 1 ensure_launcher_executable(app=app) - config.INSTALL_STEP += 1 - update_install_feedback("Creating launcher shortcuts…", app=app) + app.installer_step += 1 + app.status("Creating launcher shortcuts…") runmode = system.get_runmode() if runmode == 'binary': - update_install_feedback("Creating launcher shortcuts…", app=app) - create_launcher_shortcuts() + app.status("Creating launcher shortcuts…") + create_launcher_shortcuts(app) else: - update_install_feedback( - "Running from source. Skipping launcher creation.", - app=app + # This is done so devs can run this without it clobbering their install + app.status( + "Running from source. Won't clobber your desktop shortcuts", ) - if config.DIALOG == 'cli': - # Signal CLI.user_input_processor to stop. - app.input_q.put(None) - app.input_event.set() - # Signal CLI itself to stop. - app.stop() - - -def update_install_feedback(text, app=None): - percent = get_progress_pct(config.INSTALL_STEP, config.INSTALL_STEPS_COUNT) - logging.debug(f"Install step {config.INSTALL_STEP} of {config.INSTALL_STEPS_COUNT}") # noqa: E501 - msg.progress(percent, app=app) - msg.status(text, app=app) +def install(app: App): + """Entrypoint for installing""" + app.status('Installing…') + ensure_launcher_shortcuts(app) + app.status("Install Complete!", 100) + # Trigger a config update event to refresh the UIs + app._config_updated_event.set() def get_progress_pct(current, total): return round(current * 100 / total) -def create_wine_appimage_symlinks(app=None): - appdir_bindir = Path(config.APPDIR_BINDIR) - os.environ['PATH'] = f"{config.APPDIR_BINDIR}:{os.getenv('PATH')}" +def create_wine_appimage_symlinks(app: App): + app.status("Creating wine appimage symlinks…") + appdir_bindir = Path(app.conf.installer_binary_dir) + os.environ['PATH'] = f"{app.conf.installer_binary_dir}:{os.getenv('PATH')}" # Ensure AppImage symlink. - appimage_link = appdir_bindir / config.APPIMAGE_LINK_SELECTION_NAME - appimage_file = Path(config.SELECTED_APPIMAGE_FILENAME) - appimage_filename = Path(config.SELECTED_APPIMAGE_FILENAME).name - if config.WINEBIN_CODE in ['AppImage', 'Recommended']: - # Ensure appimage is copied to appdir_bindir. - downloaded_file = utils.get_downloaded_file_path(appimage_filename) - if not appimage_file.is_file(): - msg.status( - f"Copying: {downloaded_file} into: {appdir_bindir}", - app=app - ) - shutil.copy(downloaded_file, str(appdir_bindir)) - os.chmod(appimage_file, 0o755) - appimage_filename = appimage_file.name - elif config.WINEBIN_CODE in ["System", "Proton", "PlayOnLinux", "Custom"]: - appimage_filename = "none.AppImage" - else: - msg.logos_error( - f"WINEBIN_CODE error. WINEBIN_CODE is {config.WINEBIN_CODE}. Installation canceled!", # noqa: E501 - app=app - ) + appimage_link = appdir_bindir / app.conf.wine_appimage_link_file_name + if app.conf.wine_binary_code not in ['AppImage', 'Recommended'] or app.conf.wine_appimage_path is None: #noqa: E501 + logging.debug("No need to symlink non-appimages") + return + + appimage_file = appdir_bindir / app.conf.wine_appimage_path.name + appimage_filename = Path(app.conf.wine_appimage_path).name + # Ensure appimage is copied to appdir_bindir. + downloaded_file = utils.get_downloaded_file_path(app.conf.download_dir, appimage_filename) #noqa: E501 + if downloaded_file is None: + logging.critical("Failed to get a valid wine appimage") + return + if not appimage_file.exists(): + app.status(f"Copying: {downloaded_file} into: {appdir_bindir}") + shutil.copy(downloaded_file, appdir_bindir) + os.chmod(appimage_file, 0o755) + app.conf.wine_appimage_path = appimage_file + app.conf.wine_binary = str(appimage_file) appimage_link.unlink(missing_ok=True) # remove & replace appimage_link.symlink_to(f"./{appimage_filename}") + # NOTE: if we symlink "winetricks" then the log is polluted with: + # "Executing: cd /tmp/.mount_winet.../bin" + (appdir_bindir / "winetricks").unlink(missing_ok=True) + # Ensure wine executables symlinks. - for name in ["wine", "wine64", "wineserver", "winetricks"]: + for name in ["wine", "wine64", "wineserver"]: p = appdir_bindir / name p.unlink(missing_ok=True) - p.symlink_to(f"./{config.APPIMAGE_LINK_SELECTION_NAME}") - - -def get_flproducti_name(product_name) -> str: - lname = product_name.lower() - if lname == 'logos': - return 'logos4' - elif lname == 'verbum': - return lname - - -def create_config_file(): - config_dir = Path(config.DEFAULT_CONFIG_PATH).parent - config_dir.mkdir(exist_ok=True, parents=True) - if config_dir.is_dir(): - utils.write_config(config.CONFIG_FILE) - logging.info(f"A config file was created at {config.CONFIG_FILE}.") - else: - msg.logos_warn(f"{config_dir} does not exist. Failed to create config file.") # noqa: E501 - - -def config_has_changed(): - # Compare existing config file contents with installer config. - logging.info("Comparing its contents with current config.") - current_config_file_dict = config.get_config_file_dict(config.CONFIG_FILE) - changed = False - - for key in config.core_config_keys: - if current_config_file_dict.get(key) != config.__dict__.get(key): - changed = True - break - return changed + p.symlink_to(f"./{app.conf.wine_appimage_link_file_name}") def create_desktop_file(name, contents): @@ -785,34 +403,21 @@ def create_desktop_file(name, contents): os.chmod(launcher_path, 0o755) -def create_launcher_shortcuts(): +def create_launcher_shortcuts(app: App): # Set variables for use in launcher files. - flproduct = config.FLPRODUCT - installdir = Path(config.INSTALLDIR) - m = "Can't create launchers" - if flproduct is None: - reason = "because the FaithLife product is not defined." - msg.logos_warning(f"{m} {reason}") # noqa: E501 - return - flproducti = get_flproducti_name(flproduct) - src_dir = Path(__file__).parent - logos_icon_src = src_dir / 'img' / f"{flproducti}-128-icon.png" - app_icon_src = src_dir / 'img' / 'icon.png' - - if installdir is None: - reason = "because the installation folder is not defined." - msg.logos_warning(f"{m} {reason}") - return + flproduct = app.conf.faithlife_product + installdir = Path(app.conf.install_dir) + logos_icon_src = constants.APP_IMAGE_DIR / f"{flproduct}-128-icon.png" + app_icon_src = constants.APP_IMAGE_DIR / 'icon.png' + if not installdir.is_dir(): - reason = "because the installation folder does not exist." - msg.logos_warning(f"{m} {reason}") - return + app.exit("Can't create launchers because the installation folder does not exist.") #noqa: E501 app_dir = Path(installdir) / 'data' logos_icon_path = app_dir / logos_icon_src.name app_icon_path = app_dir / app_icon_src.name if system.get_runmode() == 'binary': - lli_executable = f"{installdir}/{config.name_binary}" + lli_executable = f"{installdir}/{constants.BINARY_NAME}" else: script = Path(sys.argv[0]).expanduser().resolve() repo_dir = None @@ -821,11 +426,12 @@ def create_launcher_shortcuts(): if c.name == '.git': repo_dir = p break + if repo_dir is None: + app.exit("Could not find .git directory from arg 0") # noqa: E501 # Find python in virtual environment. py_bin = next(repo_dir.glob('*/bin/python')) if not py_bin.is_file(): - msg.logos_warning("Could not locate python binary in virtual environment.") # noqa: E501 - return + app.exit("Could not locate python binary in virtual environment.") # noqa: E501 lli_executable = f"env DIALOG=tk {py_bin} {script}" for (src, path) in [(app_icon_src, app_icon_path), (logos_icon_src, logos_icon_path)]: # noqa: E501 @@ -852,16 +458,16 @@ def create_launcher_shortcuts(): """ ), ( - f"{config.name_binary}.desktop", + f"{constants.BINARY_NAME}.desktop", f"""[Desktop Entry] -Name={config.name_app} +Name={constants.APP_NAME} GenericName=FaithLife Wine App Installer Comment=Manages FaithLife Bible Software via Wine Exec={lli_executable} Icon={app_icon_path} Terminal=false Type=Application -StartupWMClass={config.name_binary} +StartupWMClass={constants.BINARY_NAME} Categories=Education; Keywords={flproduct};Logos;Bible;Control; """ diff --git a/ou_dedetai/logos.py b/ou_dedetai/logos.py index 6c6c9fe0..4803ce28 100644 --- a/ou_dedetai/logos.py +++ b/ou_dedetai/logos.py @@ -1,12 +1,14 @@ +import os +import signal +import subprocess import time from enum import Enum import logging import psutil import threading -from . import config -from . import main -from . import msg +from ou_dedetai.app import App + from . import system from . import utils from . import wine @@ -20,23 +22,33 @@ class State(Enum): class LogosManager: - def __init__(self, app=None): + def __init__(self, app: App): self.logos_state = State.STOPPED self.indexing_state = State.STOPPED self.app = app + self.processes: dict[str, subprocess.Popen] = {} + """These are sub-processes we started""" + self.existing_processes: dict[str, list[psutil.Process]] = {} + """These are processes we discovered already running""" def monitor_indexing(self): - if config.logos_indexer_cmd in config.processes: - indexer = config.processes.get(config.logos_indexer_cmd) + if self.app.conf.logos_indexer_exe in self.existing_processes: + indexer = self.existing_processes.get(self.app.conf.logos_indexer_exe) if indexer and isinstance(indexer[0], psutil.Process) and indexer[0].is_running(): # noqa: E501 self.indexing_state = State.RUNNING else: self.indexing_state = State.STOPPED def monitor_logos(self): - splash = config.processes.get(config.LOGOS_EXE, []) - login = config.processes.get(config.logos_login_cmd, []) - cef = config.processes.get(config.logos_cef_cmd, []) + splash = [] + login = [] + cef = [] + if self.app.conf.logos_exe: + splash = self.existing_processes.get(self.app.conf.logos_exe, []) + if self.app.conf.logos_login_exe: + login = self.existing_processes.get(self.app.conf.logos_login_exe, []) + if self.app.conf.logos_cef_exe: + cef = self.existing_processes.get(self.app.conf.logos_cef_exe, []) splash_running = splash[0].is_running() if splash else False login_running = login[0].is_running() if login else False @@ -60,9 +72,24 @@ def monitor_logos(self): if cef_running: self.logos_state = State.RUNNING + def get_logos_pids(self): + app = self.app + # FIXME: consider refactoring to make one call to get a system pids + # Currently this gets all system pids 4 times + if app.conf.logos_exe: + self.existing_processes[app.conf.logos_exe] = system.get_pids(app.conf.logos_exe) # noqa: E501 + if app.conf.wine_user: + # Also look for the system's Logos.exe (this may be the login window) + logos_system_exe = f"C:\\users\\{app.conf.wine_user}\\AppData\\Local\\Logos\\System\\Logos.exe" #noqa: E501 + self.existing_processes[logos_system_exe] = system.get_pids(logos_system_exe) # noqa: E501 + if app.conf.logos_indexer_exe: + self.existing_processes[app.conf.logos_indexer_exe] = system.get_pids(app.conf.logos_indexer_exe) # noqa: E501 + if app.conf.logos_cef_exe: + self.existing_processes[app.conf.logos_cef_exe] = system.get_pids(app.conf.logos_cef_exe) # noqa: E501 + def monitor(self): - if utils.app_is_installed(): - system.get_logos_pids() + if self.app.is_installed(): + self.get_logos_pids() try: self.monitor_indexing() self.monitor_logos() @@ -72,85 +99,107 @@ def monitor(self): def start(self): self.logos_state = State.STARTING - wine_release, _ = wine.get_wine_release(str(utils.get_wine_exe_path())) + wine_release, _ = wine.get_wine_release(self.app.conf.wine_binary) def run_logos(): - wine.run_wine_proc( - str(utils.get_wine_exe_path()), - exe=config.LOGOS_EXE + if not self.app.conf.logos_exe: + raise ValueError("Could not find installed Logos EXE to run") + process = wine.run_wine_proc( + self.app.conf.wine_binary, + self.app, + exe=self.app.conf.logos_exe ) + if process is not None: + self.processes[self.app.conf.logos_exe] = process # Ensure wine version is compatible with Logos release version. good_wine, reason = wine.check_wine_rules( wine_release, - config.current_logos_version + self.app.conf.installed_faithlife_product_release, + self.app.conf.faithlife_product_version ) if not good_wine: - msg.logos_error(reason, app=self) + self.app.exit(reason) else: if reason is not None: logging.debug(f"Warning: Wine Check: {reason}") - wine.wineserver_kill() + wine.wineserver_kill(self.app) app = self.app - if config.DIALOG == 'tk': + from ou_dedetai.gui_app import GuiApp + if not isinstance(self.app, GuiApp): # Don't send "Running" message to GUI b/c it never clears. - app = None - msg.status(f"Running {config.FLPRODUCT}…", app=app) - utils.start_thread(run_logos, daemon_bool=False) + app.status(f"Running {self.app.conf.faithlife_product}…") + self.app.start_thread(run_logos, daemon_bool=False) # NOTE: The following code would keep the CLI open while running # Logos, but since wine logging is sent directly to wine.log, # there's no terminal output to see. A user can see that output by: # tail -f ~/.local/state/FaithLife-Community/wine.log - # if config.DIALOG == 'cli': + # from ou_dedetai.cli import CLI + # if isinstance(self.app, CLI): # run_logos() # self.monitor() - # while config.processes.get(config.LOGOS_EXE) is None: + # while self.processes.get(app.conf.logos_exe) is None: # time.sleep(0.1) # while self.logos_state != State.STOPPED: # time.sleep(0.1) # self.monitor() - # else: - # utils.start_thread(run_logos, daemon_bool=False) def stop(self): logging.debug("Stopping LogosManager.") self.logos_state = State.STOPPING - if self.app: - pids = [] - for process_name in [config.LOGOS_EXE, config.logos_login_cmd, config.logos_cef_cmd]: # noqa: E501 - process_list = config.processes.get(process_name) - if process_list: - pids.extend([str(process.pid) for process in process_list]) - else: - logging.debug(f"No Logos processes found for {process_name}.") # noqa: E501 + if len(self.existing_processes) == 0: + self.get_logos_pids() - if pids: - try: - system.run_command(['kill', '-9'] + pids) - self.logos_state = State.STOPPED - msg.status(f"Stopped Logos processes at PIDs {', '.join(pids)}.", self.app) # noqa: E501 - except Exception as e: - logging.debug(f"Error while stopping Logos processes: {e}.") # noqa: E501 - else: - logging.debug("No Logos processes to stop.") + pids: list[str] = [] + for processes in self.processes.values(): + pids.append(str(processes.pid)) + + for existing_processes in self.existing_processes.values(): + pids.extend(str(proc.pid) for proc in existing_processes) + + if pids: + try: + system.run_command(['kill', '-9'] + pids) self.logos_state = State.STOPPED - wine.wineserver_wait() + logging.debug(f"Stopped Logos processes at PIDs {', '.join(pids)}.") # noqa: E501 + except Exception as e: + logging.debug(f"Error while stopping Logos processes: {e}.") # noqa: E501 + else: + logging.debug("No Logos processes to stop.") + self.logos_state = State.STOPPED + wine.wineserver_wait(self.app) + + def end_processes(self): + for process_name, process in self.processes.items(): + if isinstance(process, subprocess.Popen): + logging.debug(f"Found {process_name} in Processes. Attempting to close {process}.") # noqa: E501 + try: + process.terminate() + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + system.wait_pid(process) def index(self): self.indexing_state = State.STARTING index_finished = threading.Event() def run_indexing(): - wine.run_wine_proc( - str(utils.get_wine_exe_path()), - exe=config.logos_indexer_exe + if not self.app.conf.logos_indexer_exe: + raise ValueError("Cannot find installed indexer") + process = wine.run_wine_proc( + self.app.conf.wine_binary, + app=self.app, + exe=self.app.conf.logos_indexer_exe ) + if process is not None: + self.processes[self.app.conf.logos_indexer_exe] = process - def check_if_indexing(process): + def check_if_indexing(process: threading.Thread): start_time = time.time() last_time = start_time update_send = 0 - while process.poll() is None: + while process.is_alive(): update, last_time = utils.stopwatch(last_time, 3) if update: update_send = update_send + 1 @@ -159,45 +208,37 @@ def check_if_indexing(process): elapsed_min = int(total_elapsed_time // 60) elapsed_sec = int(total_elapsed_time % 60) formatted_time = f"{elapsed_min}m {elapsed_sec}s" - msg.status(f"Indexing is running… (Elapsed Time: {formatted_time})", self.app) # noqa: E501 + self.app.status(f"Indexing is running… (Elapsed Time: {formatted_time})") # noqa: E501 update_send = 0 index_finished.set() def wait_on_indexing(): index_finished.wait() self.indexing_state = State.STOPPED - msg.status("Indexing has finished.", self.app) - wine.wineserver_wait() + self.app.status("Indexing has finished.", percent=100) + wine.wineserver_wait(app=self.app) - wine.wineserver_kill() - msg.status("Indexing has begun…", self.app) - index_thread = utils.start_thread(run_indexing, daemon_bool=False) + wine.wineserver_kill(self.app) + self.app.status("Indexing has begun…", 0) + index_thread = self.app.start_thread(run_indexing, daemon_bool=False) self.indexing_state = State.RUNNING - # If we don't wait the process won't yet be launched when we try to - # pull it from config.processes. - while config.processes.get(config.logos_indexer_exe) is None: - time.sleep(0.1) - logging.debug(f"{config.processes=}") - process = config.processes[config.logos_indexer_exe] - check_thread = utils.start_thread( + self.app.start_thread( check_if_indexing, - process, + index_thread, daemon_bool=False ) - wait_thread = utils.start_thread(wait_on_indexing, daemon_bool=False) - main.threads.extend([index_thread, check_thread, wait_thread]) - config.processes[config.logos_indexer_exe] = index_thread - config.processes[config.check_if_indexing] = check_thread - config.processes[wait_on_indexing] = wait_thread + self.app.start_thread(wait_on_indexing, daemon_bool=False) def stop_indexing(self): self.indexing_state = State.STOPPING if self.app: pids = [] - for process_name in [config.logos_indexer_exe]: - process_list = config.processes.get(process_name) - if process_list: - pids.extend([str(process.pid) for process in process_list]) + for process_name in [self.app.conf.logos_indexer_exe]: + if process_name is None: + continue + process = self.processes.get(process_name) + if process: + pids.append(str(process.pid)) else: logging.debug(f"No LogosIndexer processes found for {process_name}.") # noqa: E501 @@ -205,28 +246,27 @@ def stop_indexing(self): try: system.run_command(['kill', '-9'] + pids) self.indexing_state = State.STOPPED - msg.status(f"Stopped LogosIndexer processes at PIDs {', '.join(pids)}.", self.app) # noqa: E501 + self.app.status(f"Stopped LogosIndexer processes at PIDs {', '.join(pids)}.") # noqa: E501 except Exception as e: logging.debug(f"Error while stopping LogosIndexer processes: {e}.") # noqa: E501 else: logging.debug("No LogosIndexer processes to stop.") self.indexing_state = State.STOPPED - wine.wineserver_wait() + wine.wineserver_wait(app=self.app) def get_app_logging_state(self, init=False): state = 'DISABLED' - current_value = wine.get_registry_value( - 'HKCU\\Software\\Logos4\\Logging', - 'Enabled' - ) + try: + current_value = wine.get_registry_value( + 'HKCU\\Software\\Logos4\\Logging', + 'Enabled', + self.app + ) + except Exception as e: + logging.warning(f"Failed to determine if logging was enabled, assuming no: {e}") #noqa: E501 + current_value = None if current_value == '0x1': state = 'ENABLED' - if config.DIALOG in ['curses', 'dialog', 'tk']: - self.app.logging_q.put(state) - if init: - self.app.root.event_generate('<>') - else: - self.app.root.event_generate('<>') return state def switch_logging(self, action=None): @@ -256,13 +296,12 @@ def switch_logging(self, action=None): '/t', 'REG_DWORD', '/d', value, '/f' ] process = wine.run_wine_proc( - str(utils.get_wine_exe_path()), + self.app.conf.wine_binary, + app=self.app, exe='reg', exe_args=exe_args ) - wine.wait_pid(process) - wine.wineserver_wait() - config.LOGS = state - if config.DIALOG in ['curses', 'dialog', 'tk']: - self.app.logging_q.put(state) - self.app.root.event_generate(self.app.logging_event) + if process: + system.wait_pid(process) + wine.wineserver_wait(self.app) + self.app.conf.faithlife_product_logging = state == state_enabled diff --git a/ou_dedetai/main.py b/ou_dedetai/main.py index f4acbc2c..427d3765 100755 --- a/ou_dedetai/main.py +++ b/ou_dedetai/main.py @@ -1,27 +1,24 @@ #!/usr/bin/env python3 import argparse import curses -try: - import dialog # noqa: F401 -except ImportError: - pass +import logging.handlers +from typing import Callable, Tuple + +from ou_dedetai.config import ( + EphemeralConfiguration, PersistentConfiguration, get_wine_prefix_path +) + import logging import os -import shutil import sys from . import cli -from . import config -from . import control +from . import constants from . import gui_app from . import msg -from . import network from . import system from . import tui_app from . import utils -from . import wine - -from .config import processes, threads def get_parser(): @@ -30,8 +27,8 @@ def get_parser(): parser.add_argument( '-v', '--version', action='version', version=( - f"{config.LLI_TITLE}, " - f"{config.LLI_CURRENT_VERSION} by {config.LLI_AUTHOR}" + f"{constants.APP_NAME}, " + f"{constants.LLI_CURRENT_VERSION} by {constants.LLI_AUTHOR}" ), ) @@ -65,14 +62,14 @@ def get_parser(): '-c', '--config', metavar='CONFIG_FILE', help=( "use a custom config file during installation " - f"[default: {config.DEFAULT_CONFIG_PATH}]" + f"[default: {constants.DEFAULT_CONFIG_PATH}]" ), ) cfg.add_argument( '-f', '--force-root', action='store_true', help=( - "set LOGOS_FORCE_ROOT to true, which permits " - "the root user to use the script" + "Running Wine/winetricks as root is highly discouraged. " + "Set this to do allow it anyways" ), ) cfg.add_argument( @@ -87,6 +84,15 @@ def get_parser(): '-P', '--passive', action='store_true', help='run product installer non-interactively', ) + cfg.add_argument( + '-y', '--assume-yes', action='store_true', + help='Assumes yes (or default) to all prompts. ' + 'Useful for entirely non-interactive installs', + ) + cfg.add_argument( + '-q', '--quiet', action='store_true', + help='Suppress all non-error output', + ) # Define runtime actions (mutually exclusive). grp = parser.add_argument_group( @@ -105,6 +111,11 @@ def get_parser(): '--run-installed-app', '-C', action='store_true', help='run installed FaithLife app', ) + # NOTE to reviewers: this function was added mostly for tests + cmd.add_argument( + '--stop-installed-app', action='store_true', + help='stop the installed FaithLife app if running', + ) cmd.add_argument( '--run-indexing', action='store_true', help='perform indexing', @@ -138,7 +149,7 @@ def get_parser(): ) cmd.add_argument( '--update-self', '-u', action='store_true', - help=f'Update {config.name_app} to the latest release.', + help=f'Update {constants.APP_NAME} to the latest release.', ) cmd.add_argument( '--update-latest-appimage', '-U', action='store_true', @@ -197,117 +208,129 @@ def get_parser(): return parser -def parse_args(args, parser): +def parse_args(args, parser) -> Tuple[EphemeralConfiguration, Callable[[EphemeralConfiguration], None]]: #noqa: E501 if args.config: - config.CONFIG_FILE = args.config - config.set_config_env(config.CONFIG_FILE) + ephemeral_config = EphemeralConfiguration.load_from_path(args.config) + else: + ephemeral_config = EphemeralConfiguration.load() + + if args.quiet: + msg.update_log_level(logging.WARNING) + ephemeral_config.quiet = True if args.verbose: - utils.set_verbose() + msg.update_log_level(logging.INFO) if args.debug: - utils.set_debug() + msg.update_log_level(logging.DEBUG) if args.delete_log: - config.DELETE_LOG = True + ephemeral_config.delete_log = True if args.set_appimage: - config.APPIMAGE_FILE_PATH = args.set_appimage[0] + ephemeral_config.wine_appimage_path = args.set_appimage[0] if args.skip_fonts: - config.SKIP_FONTS = True + ephemeral_config.install_fonts_skip = True if args.skip_winetricks: - config.SKIP_WINETRICKS = True + ephemeral_config.winetricks_skip = True - if network.check_for_updates: - config.CHECK_UPDATES = True + # FIXME: Should this have been args.check_for_updates? + # Should this even be an option? + # if network.check_for_updates: + # ephemeral_config.check_updates_now = True if args.skip_dependencies: - config.SKIP_DEPENDENCIES = True + ephemeral_config.install_dependencies_skip = True if args.force_root: - config.LOGOS_FORCE_ROOT = True - - if args.debug: - utils.set_debug() + ephemeral_config.app_run_as_root_permitted = True if args.custom_binary_path: if os.path.isdir(args.custom_binary_path): - config.CUSTOMBINPATH = args.custom_binary_path + # Set legacy environment variable for config to pick up + os.environ["CUSTOMBINPATH"] = args.custom_binary_path else: message = f"Custom binary path does not exist: \"{args.custom_binary_path}\"\n" # noqa: E501 parser.exit(status=1, message=message) - if args.passive: - config.PASSIVE = True - - # Set ACTION function. - actions = { - 'backup': cli.backup, - 'create_shortcuts': cli.create_shortcuts, - 'edit_config': cli.edit_config, - 'get_winetricks': cli.get_winetricks, - 'install_app': cli.install_app, - 'install_d3d_compiler': cli.install_d3d_compiler, - 'install_dependencies': cli.install_dependencies, - 'install_fonts': cli.install_fonts, - 'install_icu': cli.install_icu, - 'remove_index_files': cli.remove_index_files, - 'remove_install_dir': cli.remove_install_dir, - 'remove_library_catalog': cli.remove_library_catalog, - 'restore': cli.restore, - 'run_indexing': cli.run_indexing, - 'run_installed_app': cli.run_installed_app, - 'run_winetricks': cli.run_winetricks, - 'set_appimage': cli.set_appimage, - 'toggle_app_logging': cli.toggle_app_logging, - 'update_self': cli.update_self, - 'update_latest_appimage': cli.update_latest_appimage, - 'winetricks': cli.winetricks, - } - - config.ACTION = None - for arg, action in actions.items(): + if args.assume_yes: + ephemeral_config.assume_yes = True + + if args.passive or args.assume_yes: + ephemeral_config.faithlife_install_passive = True + + + def cli_operation(action: str) -> Callable[[EphemeralConfiguration], None]: + """Wrapper for a function pointer to a given function under CLI + + Lazilay instantiates CLI at call-time""" + def _run(config: EphemeralConfiguration): + getattr(cli.CLI(config), action)() + output = _run + output.__name__ = action + return output + + # Set action return function. + actions = [ + 'backup', + 'create_shortcuts', + 'edit_config', + 'get_winetricks', + 'install_app', + 'install_d3d_compiler', + 'install_dependencies', + 'install_fonts', + 'install_icu', + 'remove_index_files', + 'remove_install_dir', + 'remove_library_catalog', + 'restore', + 'run_indexing', + 'run_installed_app', + 'stop_installed_app', + 'run_winetricks', + 'set_appimage', + 'toggle_app_logging', + 'update_self', + 'update_latest_appimage', + 'winetricks', + ] + + run_action = None + for arg in actions: if getattr(args, arg): - if arg == "update_latest_appimage" or arg == "set_appimage": - logging.debug("Running an AppImage command.") - if config.WINEBIN_CODE != "AppImage" and config.WINEBIN_CODE != "Recommended": # noqa: E501 - config.ACTION = "disabled" - logging.debug("AppImage commands not added since WINEBIN_CODE != (AppImage|Recommended)") # noqa: E501 - break if arg == "set_appimage": - config.APPIMAGE_FILE_PATH = getattr(args, arg)[0] - if not utils.file_exists(config.APPIMAGE_FILE_PATH): - e = f"Invalid file path: '{config.APPIMAGE_FILE_PATH}'. File does not exist." # noqa: E501 + ephemeral_config.wine_appimage_path = getattr(args, arg)[0] + if not utils.file_exists(ephemeral_config.wine_appimage_path): + e = f"Invalid file path: '{ephemeral_config.wine_appimage_path}'. File does not exist." # noqa: E501 raise argparse.ArgumentTypeError(e) - if not utils.check_appimage(config.APPIMAGE_FILE_PATH): - e = f"{config.APPIMAGE_FILE_PATH} is not an AppImage." + if not utils.check_appimage(ephemeral_config.wine_appimage_path): + e = f"{ephemeral_config.wine_appimage_path} is not an AppImage." raise argparse.ArgumentTypeError(e) if arg == 'winetricks': - config.winetricks_args = getattr(args, 'winetricks') - config.ACTION = action + ephemeral_config.winetricks_args = getattr(args, 'winetricks') + run_action = cli_operation(arg) break - if config.ACTION is None: - config.ACTION = run_control_panel - logging.debug(f"{config.ACTION=}") + if run_action is None: + run_action = run_control_panel + logging.debug(f"{run_action=}") + return ephemeral_config, run_action -def run_control_panel(): - logging.info(f"Using DIALOG: {config.DIALOG}") - if config.DIALOG is None or config.DIALOG == 'tk': - gui_app.control_panel_app() +def run_control_panel(ephemeral_config: EphemeralConfiguration): + dialog = ephemeral_config.dialog or system.get_dialog() + logging.info(f"Using DIALOG: {dialog}") + if dialog == 'tk': + gui_app.control_panel_app(ephemeral_config) else: try: - curses.wrapper(tui_app.control_panel_app) + curses.wrapper(tui_app.control_panel_app, ephemeral_config) except KeyboardInterrupt: raise except SystemExit: - logging.info("Caught SystemExit, exiting gracefully...") - try: - close() - except Exception as e: - raise e + logging.info("Caught SystemExit, exiting gracefully…") raise except curses.error as e: logging.error(f"Curses error in run_control_panel(): {e}") @@ -317,102 +340,48 @@ def run_control_panel(): raise e -def set_config(): +def setup_config() -> Tuple[EphemeralConfiguration, Callable[[EphemeralConfiguration], None]]: #noqa: E501 parser = get_parser() cli_args = parser.parse_args() # parsing early lets 'help' run immediately + # Get config based on env and configuration file temporarily just to load a couple + # values out. We'll load this fully later. + temp = EphemeralConfiguration.load() + log_level = temp.log_level or constants.DEFAULT_LOG_LEVEL + app_log_path = temp.app_log_path or constants.DEFAULT_APP_LOG_PATH + del temp + # Set runtime config. - # Initialize logging. - msg.initialize_logging(config.LOG_LEVEL) - current_log_level = config.LOG_LEVEL - - # Set default config; incl. defining CONFIG_FILE. - utils.set_default_config() - - # Update config from CONFIG_FILE. - if not utils.file_exists(config.CONFIG_FILE): # noqa: E501 - for legacy_config in config.LEGACY_CONFIG_FILES: - if utils.file_exists(legacy_config): - config.set_config_env(legacy_config) - utils.write_config(config.CONFIG_FILE) - os.remove(legacy_config) - break - else: - config.set_config_env(config.CONFIG_FILE) + # Update log configuration. + msg.update_log_level(log_level) + msg.update_log_path(app_log_path) + # test = logging.getLogger().handlers # Parse CLI args and update affected config vars. - parse_args(cli_args, parser) - # Update terminal log level if set in CLI and changed from current level. - if config.LOG_LEVEL != current_log_level: - msg.update_log_level(config.LOG_LEVEL) - current_log_level = config.LOG_LEVEL - - # Update config based on environment variables. - config.get_env_config() - utils.set_runtime_config() - # Update terminal log level if set in environment and changed from current - # level. - if config.VERBOSE: - config.LOG_LEVEL = logging.VERBOSE - if config.DEBUG: - config.LOG_LEVEL = logging.DEBUG - if config.LOG_LEVEL != current_log_level: - msg.update_log_level(config.LOG_LEVEL) - - -def set_dialog(): - # Set DIALOG and GUI variables. - if config.DIALOG is None: - system.get_dialog() - else: - config.DIALOG = config.DIALOG.lower() + return parse_args(cli_args, parser) - if config.DIALOG == 'curses' and "dialog" in sys.modules and config.use_python_dialog is None: # noqa: E501 - config.use_python_dialog = system.test_dialog_version() - if config.use_python_dialog is None: - logging.debug("The 'dialog' package was not found. Falling back to Python Curses.") # noqa: E501 - config.use_python_dialog = False - elif config.use_python_dialog: - logging.debug("Dialog version is up-to-date.") - config.use_python_dialog = True - else: - logging.error("Dialog version is outdated. The program will fall back to Curses.") # noqa: E501 - config.use_python_dialog = False - logging.debug(f"Use Python Dialog?: {config.use_python_dialog}") - # Set Architecture +def is_app_installed(ephemeral_config: EphemeralConfiguration): + persistent_config = PersistentConfiguration.load_from_path(ephemeral_config.config_path) #noqa: E501 + if persistent_config.faithlife_product is None or persistent_config.install_dir is None: #noqa: E501 + # Not enough information stored to find the product + return False + wine_prefix = ephemeral_config.wine_prefix or get_wine_prefix_path(str(persistent_config.install_dir)) #noqa: E501 + return utils.find_installed_product(persistent_config.faithlife_product, wine_prefix) #noqa: E501 - config.architecture, config.bits = system.get_architecture() - logging.debug(f"Current Architecture: {config.architecture}, {config.bits}bit.") - system.check_architecture() - -def check_incompatibilities(): - # Check for AppImageLauncher - if shutil.which('AppImageLauncher'): - question_text = "Remove AppImageLauncher? A reboot will be required." - secondary = ( - "Your system currently has AppImageLauncher installed.\n" - f"{config.name_app} is not compatible with AppImageLauncher.\n" - f"For more information, see: {config.repo_link}/issues/114" - ) - no_text = "User declined to remove AppImageLauncher." - msg.logos_continue_question(question_text, no_text, secondary) - system.remove_appimagelauncher() - - -def run(): +def run(ephemeral_config: EphemeralConfiguration, action: Callable[[EphemeralConfiguration], None]): #noqa: E501 # Run desired action (requested function, defaults to control_panel) - if config.ACTION == "disabled": - msg.logos_error("That option is disabled.", "info") - if config.ACTION.__name__ == 'run_control_panel': + if action == "disabled": + print("That option is disabled.", file=sys.stderr) + sys.exit(1) + if action.__name__ == 'run_control_panel': # if utils.app_is_installed(): # wine.set_logos_paths() - config.ACTION() # run control_panel right away + action(ephemeral_config) # run control_panel right away return - - # Only control_panel ACTION uses TUI/GUI interface; all others are CLI. - config.DIALOG = 'cli' + + # Proceeding with the CLI interface install_required = [ 'backup', @@ -425,35 +394,38 @@ def run(): 'restore', 'run_indexing', 'run_installed_app', + 'stop_installed_app', 'run_winetricks', 'set_appimage', 'toggle_app_logging', 'winetricks', ] - if config.ACTION.__name__ not in install_required: - logging.info(f"Running function: {config.ACTION.__name__}") - config.ACTION() - elif utils.app_is_installed(): # install_required; checking for app + if action.__name__ not in install_required: + logging.info(f"Running function: {action.__name__}") + action(ephemeral_config) + elif is_app_installed(ephemeral_config): # install_required; checking for app # wine.set_logos_paths() # Run the desired Logos action. - logging.info(f"Running function for {config.FLPRODUCT}: {config.ACTION.__name__}") # noqa: E501 - config.ACTION() + logging.info(f"Running function: {action.__name__}") # noqa: E501 + action(ephemeral_config) else: # install_required, but app not installed - msg.logos_error("App not installed…") + print("App is not installed, but required for this operation. Consider installing first.", file=sys.stderr) #noqa: E501 + sys.exit(1) def main(): - set_config() - set_dialog() - - # Log persistent config. - utils.log_current_persistent_config() + msg.initialize_logging() + ephemeral_config, action = setup_config() + system.check_architecture() # NOTE: DELETE_LOG is an outlier here. It's an action, but it's one that # can be run in conjunction with other actions, so it gets special # treatment here once config is set. - if config.DELETE_LOG and os.path.isfile(config.LOGOS_LOG): - control.delete_log_file_contents() + app_log_path = ephemeral_config.app_log_path or constants.DEFAULT_APP_LOG_PATH + if ephemeral_config.delete_log and os.path.isfile(app_log_path): + # Write empty file. + with open(app_log_path, 'w') as f: + f.write('') # Run safety checks. # FIXME: Fix utils.die_if_running() for GUI; as it is, it breaks GUI @@ -461,38 +433,18 @@ def main(): # Disabled until it can be fixed. Avoid running multiple instances of the # program. # utils.die_if_running() - utils.die_if_root() + if os.getuid() == 0 and not ephemeral_config.app_run_as_root_permitted: + print("Running Wine/winetricks as root is highly discouraged. Use -f|--force-root if you must run as root. See https://wiki.winehq.org/FAQ#Should_I_run_Wine_as_root.3F", file=sys.stderr) # noqa: E501 + sys.exit(1) # Print terminal banner - logging.info(f"{config.LLI_TITLE}, {config.LLI_CURRENT_VERSION} by {config.LLI_AUTHOR}.") # noqa: E501 - logging.debug(f"Installer log file: {config.LOGOS_LOG}") + logging.info(f"{constants.APP_NAME}, {constants.LLI_CURRENT_VERSION} by {constants.LLI_AUTHOR}.") # noqa: E501 - check_incompatibilities() - - network.check_for_updates() - - run() - - -def close(): - logging.debug(f"Closing {config.name_app}.") - for thread in threads: - # Only wait on non-daemon threads. - if not thread.daemon: - thread.join() - # Only kill wine processes if closing the Control Panel. Otherwise, some - # CLI commands get killed as soon as they're started. - if config.ACTION.__name__ == 'run_control_panel' and len(processes) > 0: - wine.end_wine_processes() - else: - logging.debug("No extra processes found.") - logging.debug(f"Closing {config.name_app} finished.") + run(ephemeral_config, action) if __name__ == '__main__': try: main() except KeyboardInterrupt: - close() - - close() + pass diff --git a/ou_dedetai/msg.py b/ou_dedetai/msg.py index 034a5dfc..3bd72d56 100644 --- a/ou_dedetai/msg.py +++ b/ou_dedetai/msg.py @@ -2,15 +2,12 @@ import logging from logging.handlers import RotatingFileHandler import os -import signal import shutil import sys from pathlib import Path -from . import config -from .gui import ask_question -from .gui import show_error +from ou_dedetai import constants class GzippedRotatingFileHandler(RotatingFileHandler): @@ -65,7 +62,7 @@ def get_log_level_name(level): return name -def initialize_logging(stderr_log_level): +def initialize_logging(): ''' Log levels: Level Value Description @@ -77,14 +74,18 @@ def initialize_logging(stderr_log_level): NOTSET 0 all events are handled ''' + app_log_path = constants.DEFAULT_APP_LOG_PATH + # Ensure the application log's directory exists + os.makedirs(os.path.dirname(app_log_path), exist_ok=True) + # Ensure log file parent folders exist. - log_parent = Path(config.LOGOS_LOG).parent + log_parent = Path(app_log_path).parent if not log_parent.is_dir(): log_parent.mkdir(parents=True) # Define logging handlers. file_h = GzippedRotatingFileHandler( - config.LOGOS_LOG, + app_log_path, maxBytes=10*1024*1024, backupCount=5, encoding='UTF8' @@ -92,13 +93,15 @@ def initialize_logging(stderr_log_level): file_h.name = "logfile" file_h.setLevel(logging.DEBUG) file_h.addFilter(DeduplicateFilter()) + # FIXME: Consider adding stdout that displays INFO/DEBUG (if configured) + # and edit stderr to only display WARN/ERROR/CRITICAL # stdout_h = logging.StreamHandler(sys.stdout) # stdout_h.setLevel(stdout_log_level) stderr_h = logging.StreamHandler(sys.stderr) stderr_h.name = "terminal" - stderr_h.setLevel(stderr_log_level) + stderr_h.setLevel(logging.WARN) stderr_h.addFilter(DeduplicateFilter()) - handlers = [ + handlers: list[logging.Handler] = [ file_h, # stdout_h, stderr_h, @@ -111,6 +114,7 @@ def initialize_logging(stderr_log_level): datefmt='%Y-%m-%d %H:%M:%S', handlers=handlers, ) + logging.debug(f"Installer log file: {app_log_path}") def initialize_tui_logging(): @@ -121,7 +125,7 @@ def initialize_tui_logging(): break -def update_log_level(new_level): +def update_log_level(new_level: int | str): # Update logging level from config. for h in logging.getLogger().handlers: if type(h) is logging.StreamHandler: @@ -129,225 +133,11 @@ def update_log_level(new_level): logging.info(f"Terminal log level set to {get_log_level_name(new_level)}") -def cli_msg(message, end='\n'): - '''Prints message to stdout regardless of log level.''' - print(message, end=end) - - -def logos_msg(message, end='\n'): - if config.DIALOG == 'curses': - pass - else: - cli_msg(message, end) - - -def logos_progress(): - if config.DIALOG == 'curses': - pass - else: - sys.stdout.write('.') - sys.stdout.flush() - # i = 0 - # spinner = "|/-\\" - # sys.stdout.write(f"\r{text} {spinner[i]}") - # sys.stdout.flush() - # i = (i + 1) % len(spinner) - # time.sleep(0.1) - - -def logos_warn(message): - if config.DIALOG == 'curses': - logging.warning(message) - else: - logos_msg(message) - - -def ui_message(message, secondary=None, detail=None, app=None, parent=None, fatal=False): # noqa: E501 - if detail is None: - detail = '' - WIKI_LINK = f"{config.repo_link}/wiki" - TELEGRAM_LINK = "https://t.me/linux_logos" - MATRIX_LINK = "https://matrix.to/#/#logosbible:matrix.org" - help_message = f"If you need help, please consult:\n{WIKI_LINK}\n{TELEGRAM_LINK}\n{MATRIX_LINK}" # noqa: E501 - if config.DIALOG == 'tk': - show_error( - message, - detail=f"{detail}\n\n{help_message}", - app=app, - fatal=fatal, - parent=parent - ) - elif config.DIALOG == 'curses': - if secondary != "info": - status(message) - status(help_message) - else: - logos_msg(message) - else: - logos_msg(message) - - -# TODO: I think detail is doing the same thing as secondary. -def logos_error(message, secondary=None, detail=None, app=None, parent=None): - # if detail is None: - # detail = '' - # WIKI_LINK = f"{config.repo_link}/wiki" - # TELEGRAM_LINK = "https://t.me/linux_logos" - # MATRIX_LINK = "https://matrix.to/#/#logosbible:matrix.org" - # help_message = f"If you need help, please consult:\n{WIKI_LINK}\n{TELEGRAM_LINK}\n{MATRIX_LINK}" # noqa: E501 - # if config.DIALOG == 'tk': - # show_error( - # message, - # detail=f"{detail}\n\n{help_message}", - # app=app, - # parent=parent - # ) - # elif config.DIALOG == 'curses': - # if secondary != "info": - # status(message) - # status(help_message) - # else: - # logos_msg(message) - # else: - # logos_msg(message) - ui_message(message, secondary=secondary, detail=detail, app=app, parent=parent, fatal=True) # noqa: E501 - - logging.critical(message) - if secondary is None or secondary == "": - try: - os.remove(config.pid_file) - except FileNotFoundError: # no pid file when testing functions - pass - os.kill(os.getpgid(os.getpid()), signal.SIGKILL) - - if hasattr(app, 'destroy'): - app.destroy() - sys.exit(1) - - -def logos_warning(message, secondary=None, detail=None, app=None, parent=None): - ui_message(message, secondary=secondary, detail=detail, app=app, parent=parent) # noqa: E501 - logging.error(message) - - -def cli_question(question_text, secondary=""): - while True: - try: - cli_msg(secondary) - yn = input(f"{question_text} [Y/n]: ") - except KeyboardInterrupt: - print() - logos_error("Cancelled with Ctrl+C") - - if yn.lower() == 'y' or yn == '': # defaults to "Yes" - return True - elif yn.lower() == 'n': - return False - else: - logos_msg("Type Y[es] or N[o].") - - -def cli_continue_question(question_text, no_text, secondary): - if not cli_question(question_text, secondary): - logos_error(no_text) - - -def gui_continue_question(question_text, no_text, secondary): - if ask_question(question_text, secondary) == 'no': - logos_error(no_text) - - -def cli_acknowledge_question(question_text, no_text, secondary): - if not cli_question(question_text, secondary): - logos_msg(no_text) - return False - else: - return True - - -def cli_ask_filepath(question_text): - try: - answer = input(f"{question_text} ") - return answer.strip('"').strip("'") - except KeyboardInterrupt: - print() - logos_error("Cancelled with Ctrl+C") - - -def logos_continue_question(question_text, no_text, secondary, app=None): - if config.DIALOG == 'tk': - gui_continue_question(question_text, no_text, secondary) - elif config.DIALOG == 'cli': - cli_continue_question(question_text, no_text, secondary) - elif config.DIALOG == 'curses': - app.screen_q.put( - app.stack_confirm( - 16, - app.confirm_q, - app.confirm_e, - question_text, - no_text, - secondary, - dialog=config.use_python_dialog - ) - ) - else: - logos_error(f"Unhandled question: {question_text}") - - -def logos_acknowledge_question(question_text, no_text, secondary): - if config.DIALOG == 'curses': - pass - else: - return cli_acknowledge_question(question_text, no_text, secondary) - - -def get_progress_str(percent): - length = 40 - part_done = round(percent * length / 100) - part_left = length - part_done - return f"[{'*' * part_done}{'-' * part_left}]" - - -def progress(percent, app=None): - """Updates progressbar values for TUI and GUI.""" - if config.DIALOG == 'tk' and app: - app.progress_q.put(percent) - app.root.event_generate('<>') - logging.info(f"Progress: {percent}%") - elif config.DIALOG == 'curses': - if app: - status(f"Progress: {percent}%", app) - else: - status(f"Progress: {get_progress_str(percent)}", app) - else: - logos_msg(get_progress_str(percent)) # provisional - - -def status(text, app=None, end='\n'): - def strip_timestamp(msg, timestamp_length=20): - return msg[timestamp_length:] - - timestamp = config.get_timestamp() - """Handles status messages for both TUI and GUI.""" - if app is not None: - if config.DIALOG == 'tk': - app.status_q.put(text) - app.root.event_generate(app.status_evt) - logging.info(f"{text}") - elif config.DIALOG == 'curses': - if len(config.console_log) > 0: - last_msg = strip_timestamp(config.console_log[-1]) - if last_msg != text: - app.status_q.put(f"{timestamp} {text}") - app.report_waiting(f"{app.status_q.get()}", dialog=config.use_python_dialog) # noqa: E501 - logging.info(f"{text}") - else: - app.status_q.put(f"{timestamp} {text}") - app.report_waiting(f"{app.status_q.get()}", dialog=config.use_python_dialog) # noqa: E501 - logging.info(f"{text}") - else: - logging.info(f"{text}") - else: - # Prints message to stdout regardless of log level. - logos_msg(text, end=end) +def update_log_path(app_log_path: str | Path): + for h in logging.getLogger().handlers: + if type(h) is GzippedRotatingFileHandler and h.name == "logfile": + new_base_filename = os.path.abspath(os.fspath(app_log_path)) + if new_base_filename != h.baseFilename: + # One last message on the old log to let them know it moved + logging.debug(f"Installer log file changed to: {app_log_path}") + h.baseFilename = new_base_filename diff --git a/ou_dedetai/network.py b/ou_dedetai/network.py index 1b5cd068..b72c4188 100644 --- a/ou_dedetai/network.py +++ b/ou_dedetai/network.py @@ -1,110 +1,115 @@ +import abc +from dataclasses import dataclass, field import hashlib import json import logging import os -import queue +import time from typing import Optional import requests import shutil import sys from base64 import b64encode -from datetime import datetime, timedelta from pathlib import Path -from time import sleep from urllib.parse import urlparse from xml.etree import ElementTree as ET -from ou_dedetai import wine +import requests.structures -from . import config -from . import msg +from ou_dedetai.app import App + +from . import constants from . import utils +class Props(abc.ABC): + def __init__(self) -> None: + self._md5: Optional[str] = None + self._size: Optional[int] = None + + @property + def size(self) -> Optional[int]: + if self._size is None: + self._size = self._get_size() + return self._size + + @property + def md5(self) -> Optional[str]: + if self._md5 is None: + self._md5 = self._get_md5() + return self._md5 + + @abc.abstractmethod + def _get_size(self) -> Optional[int]: + """Get the size""" + + @abc.abstractmethod + def _get_md5(self) -> Optional[str]: + """Calculate the md5 sum""" -class Props(): - def __init__(self, uri=None): +class FileProps(Props): + def __init__(self, path: str | Path | None): + super(FileProps, self).__init__() self.path = None - self.size = None - self.md5 = None - if uri is not None: - self.path = uri - + if path is not None: + self.path = Path(path) -class FileProps(Props): - def __init__(self, f=None): - super().__init__(f) - if f is not None: - self.path = Path(self.path) - if self.path.is_file(): - self.get_size() - # self.get_md5() - - def get_size(self): + def _get_size(self): if self.path is None: return - self.size = self.path.stat().st_size - return self.size + if Path(self.path).is_file(): + return self.path.stat().st_size - def get_md5(self): + def _get_md5(self) -> Optional[str]: if self.path is None: - return + return None md5 = hashlib.md5() with self.path.open('rb') as f: for chunk in iter(lambda: f.read(524288), b''): md5.update(chunk) - self.md5 = b64encode(md5.digest()).decode('utf-8') - logging.debug(f"{str(self.path)} MD5: {self.md5}") - return self.md5 + return b64encode(md5.digest()).decode('utf-8') + +@dataclass +class SoftwareReleaseInfo: + version: str + download_url: str class UrlProps(Props): - def __init__(self, url=None): - super().__init__(url) - self.headers = None - if url is not None: - self.get_headers() - self.get_size() - self.get_md5() - - def get_headers(self): - if self.path is None: - self.headers = None + def __init__(self, url: str): + super(UrlProps, self).__init__() + self.path = url + self._headers: Optional[requests.structures.CaseInsensitiveDict] = None + + @property + def headers(self) -> requests.structures.CaseInsensitiveDict: + if self._headers is None: + self._headers = self._get_headers() + return self._headers + + def _get_headers(self) -> requests.structures.CaseInsensitiveDict: logging.debug(f"Getting headers from {self.path}.") try: h = {'Accept-Encoding': 'identity'} # force non-compressed txfr r = requests.head(self.path, allow_redirects=True, headers=h) except requests.exceptions.ConnectionError: logging.critical("Failed to connect to the server.") - return None + raise except Exception as e: logging.error(e) - return None - except KeyboardInterrupt: - print() - msg.logos_error("Interrupted by Ctrl+C") - return None - self.headers = r.headers - return self.headers - - def get_size(self): - if self.headers is None: - r = self.get_headers() - if r is None: - return + raise + return r.headers + + def _get_size(self): content_length = self.headers.get('Content-Length') content_encoding = self.headers.get('Content-Encoding') if content_encoding is not None: logging.critical(f"The server requires receiving the file compressed as '{content_encoding}'.") # noqa: E501 logging.debug(f"{content_length=}") if content_length is not None: - self.size = int(content_length) - return self.size - - def get_md5(self): - if self.headers is None: - r = self.get_headers() - if r is None: - return + self._size = int(content_length) + return self._size + + def _get_md5(self): if self.headers.get('server') == 'AmazonS3': content_md5 = self.headers.get('etag') if content_md5 is not None: @@ -117,53 +122,192 @@ def get_md5(self): content_md5 = content_md5.strip('"').strip("'") logging.debug(f"{content_md5=}") if content_md5 is not None: - self.md5 = content_md5 - return self.md5 + self._md5 = content_md5 + return self._md5 + + +@dataclass +class CachedRequests: + """This struct all network requests and saves to a cache""" + # Some of these values are cached to avoid github api rate-limits + + faithlife_product_releases: dict[str, dict[str, dict[str, list[str]]]] = field(default_factory=dict) # noqa: E501 + """Cache of faithlife releases. + + Since this depends on the user's selection we need to scope the cache based on that + The cache key is the product, version, and release channel + """ + repository_latest_version: dict[str, str] = field(default_factory=dict) + """Cache of the latest versions keyed by repository slug + + Keyed by repository slug Owner/Repo + """ + repository_latest_url: dict[str, str] = field(default_factory=dict) + """Cache of the latest download url keyed by repository slug + + Keyed by repository slug Owner/Repo + """ + + + url_size_and_hash: dict[str, tuple[Optional[int], Optional[str]]] = field(default_factory=dict) # noqa: E501 + + last_updated: Optional[float] = None + + @classmethod + def load(cls) -> "CachedRequests": + """Load the cache from file if exists""" + path = Path(constants.NETWORK_CACHE_PATH) + if path.exists(): + with open(path, "r") as f: + try: + output: dict = json.load(f) + # Drop any unknown keys + known_keys = CachedRequests().__dict__.keys() + cache_keys = list(output.keys()) + for k in cache_keys: + if k not in known_keys: + del output[k] + return CachedRequests(**output) + except json.JSONDecodeError: + logging.warning("Failed to read cache JSON. Clearing…") + return CachedRequests( + last_updated=time.time() + ) + + def _write(self) -> None: + """Writes the cache to disk. Done internally when there are changes""" + path = Path(constants.NETWORK_CACHE_PATH) + path.parent.mkdir(exist_ok=True) + with open(path, "w") as f: + json.dump(self.__dict__, f, indent=4, sort_keys=True, default=vars) + f.write("\n") + + + def _is_fresh(self) -> bool: + """Returns whether or not this cache is valid""" + if self.last_updated is None: + return False + valid_until = self.last_updated + constants.CACHE_LIFETIME_HOURS * 60 * 60 + if valid_until <= time.time(): + return False + return True + def clean_if_stale(self, force: bool = False): + if force or not self._is_fresh(): + logging.debug("Cleaning out cache…") + self = CachedRequests(last_updated=time.time()) + self._write() + else: + logging.debug("Cache is valid") -def cli_download(uri, destination, app=None): - message = f"Downloading '{uri}' to '{destination}'" - msg.status(message) - # Set target. - if destination != destination.rstrip('/'): - target = os.path.join(destination, os.path.basename(uri)) - if not os.path.isdir(destination): - os.makedirs(destination) - elif os.path.isdir(destination): - target = os.path.join(destination, os.path.basename(uri)) - else: - target = destination - dirname = os.path.dirname(destination) - if not os.path.isdir(dirname): - os.makedirs(dirname) - - # Download from uri in thread while showing progress bar. - cli_queue = queue.Queue() - kwargs = {'q': cli_queue, 'target': target} - t = utils.start_thread(net_get, uri, **kwargs) - try: - while t.is_alive(): - sleep(0.1) - if cli_queue.empty(): - continue - utils.write_progress_bar(cli_queue.get()) - print() - except KeyboardInterrupt: - print() - msg.logos_error('Interrupted with Ctrl+C') +class NetworkRequests: + """Uses the cache if found, otherwise retrieves the value from the network.""" + + # This struct uses functions to call due to some of the values requiring parameters + + def __init__(self, force_clean: Optional[bool] = None) -> None: + self._cache = CachedRequests.load() + self._cache.clean_if_stale(force=force_clean or False) + + def _faithlife_product_releases( + self, + product: Optional[str], + version: Optional[str], + channel: Optional[str] + ) -> Optional[list[str]]: + if product is None or version is None or channel is None: + return None + releases = self._cache.faithlife_product_releases + if product not in releases: + releases[product] = {} + if version not in releases[product]: + releases[product][version] = {} + if ( + channel + not in releases[product][version] + ): + return None + return releases[product][version][channel] + + def faithlife_product_releases( + self, + product: str, + version: str, + channel: str + ) -> list[str]: + output = self._faithlife_product_releases(product, version, channel) + if output is not None: + return output + output = _get_faithlife_product_releases( + faithlife_product=product, + faithlife_product_version=version, + faithlife_product_release_channel=channel + ) + self._cache.faithlife_product_releases[product][version][channel] = output + self._cache._write() + return output + + def wine_appimage_recommended_url(self) -> str: + repo = "FaithLife-Community/wine-appimages" + return self._repo_latest_version(repo).download_url + + def _url_size_and_hash(self, url: str) -> tuple[Optional[int], Optional[str]]: + """Attempts to get the size and hash from a URL. + Uses cache if it exists + + Returns: + bytes - from the Content-Length leader + md5_hash - from the Content-MD5 header or S3's etag + """ + if url not in self._cache.url_size_and_hash: + props = UrlProps(url) + self._cache.url_size_and_hash[url] = props.size, props.md5 + self._cache._write() + return self._cache.url_size_and_hash[url] + + def url_size(self, url: str) -> Optional[int]: + return self._url_size_and_hash(url)[0] + + def url_md5(self, url: str) -> Optional[str]: + return self._url_size_and_hash(url)[1] + + def _repo_latest_version(self, repository: str) -> SoftwareReleaseInfo: + if ( + repository not in self._cache.repository_latest_version + or repository not in self._cache.repository_latest_url + ): + result = _get_latest_release_data(repository) + self._cache.repository_latest_version[repository] = result.version + self._cache.repository_latest_url[repository] = result.download_url + self._cache._write() + return SoftwareReleaseInfo( + version=self._cache.repository_latest_version[repository], + download_url=self._cache.repository_latest_url[repository] + ) + + def app_latest_version(self, channel: str) -> SoftwareReleaseInfo: + if channel == "stable": + repo = "FaithLife-Community/LogosLinuxInstaller" + else: + repo = "FaithLife-Community/test-builds" + return self._repo_latest_version(repo) + + def icu_latest_version(self) -> SoftwareReleaseInfo: + return self._repo_latest_version("FaithLife-Community/icu") def logos_reuse_download( - sourceurl, - file, - targetdir, - app=None, + sourceurl: str, + file: str, + targetdir: str, + app: App, + status_messages: bool = True ): dirs = [ - config.INSTALLDIR, + app.conf.install_dir, os.getcwd(), - config.MYDOWNLOADS, + app.conf.download_dir, ] found = 1 for i in dirs: @@ -172,13 +316,14 @@ def logos_reuse_download( file_path = Path(i) / file if os.path.isfile(file_path): logging.info(f"{file} exists in {i}. Verifying properties.") - if verify_downloaded_file( + if _verify_downloaded_file( sourceurl, file_path, app=app, + status_messages=status_messages ): logging.info(f"{file} properties match. Using it…") - msg.status(f"Copying {file} into {targetdir}") + logging.debug(f"Copying {file} into {targetdir}") try: shutil.copy(os.path.join(i, file), targetdir) except shutil.SameFileError: @@ -188,52 +333,44 @@ def logos_reuse_download( else: logging.info(f"Incomplete file: {file_path}.") if found == 1: - file_path = os.path.join(config.MYDOWNLOADS, file) - if config.DIALOG == 'tk' and app: - # Ensure progress bar. - app.stop_indeterminate_progress() - # Start download. - net_get( - sourceurl, - target=file_path, - app=app, - ) - else: - cli_download(sourceurl, file_path, app=app) - if verify_downloaded_file( + file_path = Path(os.path.join(app.conf.download_dir, file)) + # Start download. + _net_get( + sourceurl, + target=file_path, + app=app, + ) + if _verify_downloaded_file( sourceurl, file_path, app=app, + status_messages=status_messages ): - msg.status(f"Copying: {file} into: {targetdir}") + logging.debug(f"Copying: {file} into: {targetdir}") try: - shutil.copy(os.path.join(config.MYDOWNLOADS, file), targetdir) + shutil.copy(os.path.join(app.conf.download_dir, file), targetdir) except shutil.SameFileError: pass else: - msg.logos_error(f"Bad file size or checksum: {file_path}") - + app.exit(f"Bad file size or checksum: {file_path}") -def net_get(url, target=None, app=None, evt=None, q=None): +# FIXME: refactor to raise rather than return None +def _net_get(url: str, target: Optional[Path]=None, app: Optional[App] = None): # TODO: # - Check available disk space before starting download logging.debug(f"Download source: {url}") logging.debug(f"Download destination: {target}") - target = FileProps(target) # sets path and size attribs - if app and target.path: - app.status_q.put(f"Downloading {target.path.name}…") # noqa: E501 - app.root.event_generate('<>') + target_props = FileProps(target) # sets path and size attribs + if app and target_props.path: + app.status(f"Downloading {target_props.path.name}…") parsed_url = urlparse(url) domain = parsed_url.netloc # Gets the requested domain - url = UrlProps(url) # uses requests to set headers, size, md5 attribs - if url.headers is None: - logging.critical("Could not get headers.") - return None + url_props = UrlProps(url) # uses requests to set headers, size, md5 attribs # Initialize variables. local_size = 0 - total_size = url.size # None or int + total_size = url_props.size # None or int logging.debug(f"File size on server: {total_size}") percent = None chunk_size = 100 * 1024 # 100 KB default @@ -245,14 +382,14 @@ def net_get(url, target=None, app=None, evt=None, q=None): file_mode = 'wb' # If file exists and URL is resumable, set download Range. - if target.path is not None and target.path.is_file(): - logging.debug(f"File exists: {str(target.path)}") - local_size = target.get_size() + if target_props.size: + logging.debug(f"File exists: {str(target_props.path)}") + local_size = target_props.size logging.info(f"Current downloaded size in bytes: {local_size}") - if url.headers.get('Accept-Ranges') == 'bytes': + if url_props.headers.get('Accept-Ranges') == 'bytes': logging.debug("Server accepts byte range; attempting to resume download.") # noqa: E501 file_mode = 'ab' - if type(url.size) is int: + if type(url_props.size) is int: headers['Range'] = f'bytes={local_size}-{total_size}' else: headers['Range'] = f'bytes={local_size}-' @@ -261,15 +398,18 @@ def net_get(url, target=None, app=None, evt=None, q=None): # Log download type. if 'Range' in headers.keys(): - message = f"Continuing download for {url.path}." + message = f"Continuing download for {url_props.path}." else: - message = f"Starting new download for {url.path}." + message = f"Starting new download for {url_props.path}." logging.info(message) # Initiate download request. try: - if target.path is None: # return url content as text - with requests.get(url.path, headers=headers) as r: + # FIXME: consider splitting this into two functions with a common base. + # One that writes into a file, and one that returns a str, + # that share most of the internal logic + if target_props.path is None: # return url content as text + with requests.get(url_props.path, headers=headers) as r: if callable(r): logging.error("Failed to retrieve data from the URL.") return None @@ -289,236 +429,126 @@ def net_get(url, target=None, app=None, evt=None, q=None): return r._content # raw bytes else: # download url to target.path - with requests.get(url.path, stream=True, headers=headers) as r: - with target.path.open(mode=file_mode) as f: + with requests.get(url_props.path, stream=True, headers=headers) as r: + with target_props.path.open(mode=file_mode) as f: if file_mode == 'wb': mode_text = 'Writing' else: mode_text = 'Appending' - logging.debug(f"{mode_text} data to file {target.path}.") + logging.debug(f"{mode_text} data to file {target_props.path}.") for chunk in r.iter_content(chunk_size=chunk_size): f.write(chunk) - local_size = target.get_size() + local_size = os.fstat(f.fileno()).st_size if type(total_size) is int: - percent = round(local_size / total_size * 100) + percent = round(local_size / total_size * 10) # if None not in [app, evt]: if app: - # Send progress value to tk window. - app.get_q.put(percent) - if not evt: - evt = app.get_evt - app.root.event_generate(evt) - elif q is not None: - # Send progress value to queue param. - q.put(percent) + # Show dots corresponding to show download progress + # While we could use percent, it's likely to interfere + # With whatever install step we are on + message = "Downloading" + "." * percent + "\r" + app.status(message) except requests.exceptions.RequestException as e: logging.error(f"Error occurred during HTTP request: {e}") return None # Return None values to indicate an error condition - except Exception as e: - msg.logos_error(e) - except KeyboardInterrupt: - print() - msg.logos_error("Killed with Ctrl+C") - - -def verify_downloaded_file(url, file_path, app=None, evt=None): - if app: - if config.DIALOG == "tk": - app.root.event_generate('<>') - msg.status(f"Verifying {file_path}…", app) - # if config.DIALOG == "tk": - # app.root.event_generate('<>') - res = False - txt = f"{file_path} is the wrong size." - right_size = same_size(url, file_path) - if right_size: - txt = f"{file_path} has the wrong MD5 sum." - right_md5 = same_md5(url, file_path) - if right_md5: - txt = f"{file_path} is verified." - res = True - logging.info(txt) - if app: - if config.DIALOG == "tk": - if not evt: - evt = app.check_evt - app.root.event_generate(evt) - return res - - -def same_md5(url, file_path): - logging.debug(f"Comparing MD5 of {url} and {file_path}.") - url_md5 = UrlProps(url).get_md5() - logging.debug(f"{url_md5=}") - if url_md5 is None: # skip MD5 check if not provided with URL - res = True - else: - file_md5 = FileProps(file_path).get_md5() - logging.debug(f"{file_md5=}") - res = url_md5 == file_md5 - return res -def same_size(url, file_path): - logging.debug(f"Comparing size of {url} and {file_path}.") - url_size = UrlProps(url).size - if not url_size: - return True - file_size = FileProps(file_path).size - logging.debug(f"{url_size=} B; {file_size=} B") - res = url_size == file_size - return res - - -def get_latest_release_data(repository): - release_url = f"https://api.github.com/repos/{repository}/releases/latest" - data = net_get(release_url) - if data: - try: - json_data = json.loads(data.decode()) - except json.JSONDecodeError as e: - logging.error(f"Error decoding JSON response: {e}") - return None - - return json_data - else: - logging.critical("Could not get latest release URL.") - return None - - -def get_first_asset_url(json_data) -> Optional[str]: - release_url = None - if json_data: - # FIXME: Portential KeyError - release_url = json_data.get('assets')[0].get('browser_download_url') - logging.info(f"Release URL: {release_url}") - return release_url - - -def get_tag_name(json_data) -> Optional[str]: - tag_name = None - if json_data: - tag_name = json_data.get('tag_name') - logging.info(f"Release URL Tag Name: {tag_name}") +def _verify_downloaded_file(url: str, file_path: Path | str, app: App, status_messages: bool = True): #noqa: E501 + if status_messages: + app.status(f"Verifying {file_path}…", 0) + file_props = FileProps(file_path) + url_size = app.conf._network.url_size(url) + if url_size is not None and file_props.size != url_size: + logging.warning(f"{file_path} is the wrong size.") + return False + url_md5 = app.conf._network.url_md5(url) + if url_md5 is not None and file_props.md5 != url_md5: + logging.warning(f"{file_path} has the wrong MD5 sum.") + return False + logging.debug(f"{file_path} is verified.") + return True + + +def _get_first_asset_url(json_data: dict) -> str: + """Parses the github api response to find the first asset's download url + """ + assets = json_data.get('assets') or [] + if len(assets) == 0: + raise Exception("Failed to find the first asset in the repository data: " + f"{json_data}") + first_asset = assets[0] + download_url: Optional[str] = first_asset.get('browser_download_url') + if download_url is None: + raise Exception("Failed to find the download URL in the repository data: " + f"{json_data}") + return download_url + + +def _get_version_name(json_data: dict) -> str: + """Gets tag name from json data, strips leading v if exists""" + tag_name: Optional[str] = json_data.get('tag_name') + if tag_name is None: + raise Exception("Failed to find the tag_name in the repository data: " + f"{json_data}") + # Trim a leading v to normalize the version + tag_name = tag_name.lstrip("v") return tag_name -def set_logoslinuxinstaller_latest_release_config(): - if config.lli_release_channel is None or config.lli_release_channel == "stable": # noqa: E501 - repo = "FaithLife-Community/LogosLinuxInstaller" - else: - repo = "FaithLife-Community/test-builds" - json_data = get_latest_release_data(repo) - logoslinuxinstaller_url = get_first_asset_url(json_data) - if logoslinuxinstaller_url is None: - logging.critical(f"Unable to set {config.name_app} release without URL.") # noqa: E501 - return - config.LOGOS_LATEST_VERSION_URL = logoslinuxinstaller_url - config.LOGOS_LATEST_VERSION_FILENAME = os.path.basename(logoslinuxinstaller_url) # noqa: #501 - # Getting version relies on the the tag_name field in the JSON data. This - # is already parsed down to vX.X.X. Therefore we must strip the v. - config.LLI_LATEST_VERSION = get_tag_name(json_data).lstrip('v') - logging.info(f"{config.LLI_LATEST_VERSION=}") - - -def set_recommended_appimage_config(): - repo = "FaithLife-Community/wine-appimages" - if not config.RECOMMENDED_WINE64_APPIMAGE_URL: - json_data = get_latest_release_data(repo) - appimage_url = get_first_asset_url(json_data) - if appimage_url is None: - logging.critical("Unable to set recommended appimage config without URL.") # noqa: E501 - return - config.RECOMMENDED_WINE64_APPIMAGE_URL = appimage_url - config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME = os.path.basename(config.RECOMMENDED_WINE64_APPIMAGE_URL) # noqa: E501 - config.RECOMMENDED_WINE64_APPIMAGE_FILENAME = config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME.split(".AppImage")[0] # noqa: E501 - # Getting version and branch rely on the filename having this format: - # wine-[branch]_[version]-[arch] - parts = config.RECOMMENDED_WINE64_APPIMAGE_FILENAME.split('-') - branch_version = parts[1] - branch, version = branch_version.split('_') - config.RECOMMENDED_WINE64_APPIMAGE_FULL_VERSION = f"v{version}-{branch}" - config.RECOMMENDED_WINE64_APPIMAGE_VERSION = f"{version}" - config.RECOMMENDED_WINE64_APPIMAGE_BRANCH = f"{branch}" - - -def check_for_updates(): - # We limit the number of times set_recommended_appimage_config is run in - # order to avoid GitHub API limits. This sets the check to once every 12 - # hours. - - config.current_logos_version = utils.get_current_logos_version() - utils.write_config(config.CONFIG_FILE) - - # TODO: Check for New Logos Versions. See #116. - - now = datetime.now().replace(microsecond=0) - if config.CHECK_UPDATES: - check_again = now - elif config.LAST_UPDATED is not None: - check_again = datetime.strptime( - config.LAST_UPDATED.strip(), - '%Y-%m-%dT%H:%M:%S' - ) - check_again += timedelta(hours=12) - else: - check_again = now - - if now >= check_again: - logging.debug("Running self-update.") - - set_logoslinuxinstaller_latest_release_config() - utils.compare_logos_linux_installer_version() - set_recommended_appimage_config() - wine.enforce_icu_data_files() - - config.LAST_UPDATED = now.isoformat() - utils.write_config(config.CONFIG_FILE) - else: - logging.debug("Skipping self-update.") - +def _get_latest_release_data(repository) -> SoftwareReleaseInfo: + """Gets latest release information + + Raises: + Exception - on failure to make network operation or parse github API + + Returns: + SoftwareReleaseInfo + """ + release_url = f"https://api.github.com/repos/{repository}/releases/latest" + data = _net_get(release_url) + if data is None: + raise Exception("Could not get latest release URL.") + try: + json_data: dict = json.loads(data.decode()) + except json.JSONDecodeError as e: + logging.error(f"Error decoding Github's JSON response: {e}") + raise + + download_url = _get_first_asset_url(json_data) + version = _get_version_name(json_data) + return SoftwareReleaseInfo( + version=version, + download_url=download_url + ) -def get_recommended_appimage(): - wine64_appimage_full_filename = Path(config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME) # noqa: E501 - dest_path = Path(config.APPDIR_BINDIR) / wine64_appimage_full_filename +def dwonload_recommended_appimage(app: App): + wine64_appimage_full_filename = Path(app.conf.wine_appimage_recommended_file_name) # noqa: E501 + dest_path = Path(app.conf.installer_binary_dir) / wine64_appimage_full_filename if dest_path.is_file(): return else: logos_reuse_download( - config.RECOMMENDED_WINE64_APPIMAGE_URL, - config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME, - config.APPDIR_BINDIR) - - -def get_logos_releases(app=None): - # Use already-downloaded list if requested again. - downloaded_releases = None - if config.TARGETVERSION == '9' and config.LOGOS9_RELEASES: - downloaded_releases = config.LOGOS9_RELEASES - elif config.TARGETVERSION == '10' and config.LOGOS10_RELEASES: - downloaded_releases = config.LOGOS10_RELEASES - if downloaded_releases: - logging.debug(f"Using already-downloaded list of v{config.TARGETVERSION} releases") # noqa: E501 - if app: - app.releases_q.put(downloaded_releases) - app.root.event_generate(app.release_evt) - return downloaded_releases - - msg.status(f"Downloading release list for {config.FLPRODUCT} {config.TARGETVERSION}…") # noqa: E501 + app.conf.wine_appimage_recommended_url, + app.conf.wine_appimage_recommended_file_name, + app.conf.installer_binary_dir, + app=app + ) + +def _get_faithlife_product_releases( + faithlife_product: str, + faithlife_product_version: str, + faithlife_product_release_channel: str +) -> list[str]: + logging.debug(f"Downloading release list for {faithlife_product} {faithlife_product_version}…") # noqa: E501 # NOTE: This assumes that Verbum release numbers continue to mirror Logos. - if config.logos_release_channel is None or config.logos_release_channel == "stable": # noqa: E501 - url = f"https://clientservices.logos.com/update/v1/feed/logos{config.TARGETVERSION}/stable.xml" # noqa: E501 - elif config.logos_release_channel == "beta": + if faithlife_product_release_channel == "beta": url = "https://clientservices.logos.com/update/v1/feed/logos10/beta.xml" # noqa: E501 - - response_xml_bytes = net_get(url) - # if response_xml is None and None not in [q, app]: + else: + url = f"https://clientservices.logos.com/update/v1/feed/logos{faithlife_product_version}/stable.xml" # noqa: E501 + + response_xml_bytes = _net_get(url) if response_xml_bytes is None: - if app: - app.releases_q.put(None) - if config.DIALOG == 'tk': - app.root.event_generate(app.release_evt) - return None + raise Exception("Failed to get logos releases") # Parse XML root = ET.fromstring(response_xml_bytes.decode('utf-8-sig')) @@ -533,8 +563,8 @@ def get_logos_releases(app=None): releases = [] # Obtain all listed releases. for entry in root.findall('.//ns1:version', namespaces): - release = entry.text - releases.append(release) + if entry.text: + releases.append(entry.text) # if len(releases) == 5: # break @@ -545,44 +575,29 @@ def get_logos_releases(app=None): # logging.debug(f"Filtered releases: {', '.join(filtered_releases)}") filtered_releases = releases - if app: - if config.DIALOG == 'tk': - app.releases_q.put(filtered_releases) - app.root.event_generate(app.release_evt) - elif config.DIALOG == 'curses': - app.releases_q.put(filtered_releases) - app.releases_e.set() - elif config.DIALOG == 'cli': - app.input_q.put( - ( - f"Which version of {config.FLPRODUCT} {config.TARGETVERSION} do you want to install?: ", # noqa: E501 - filtered_releases - ) - ) - app.input_event.set() return filtered_releases -def update_lli_binary(app=None): +def update_lli_binary(app: App): lli_file_path = os.path.realpath(sys.argv[0]) - lli_download_path = Path(config.MYDOWNLOADS) / config.name_binary - temp_path = Path(config.MYDOWNLOADS) / f"{config.name_binary}.tmp" + lli_download_path = Path(app.conf.download_dir) / constants.BINARY_NAME + temp_path = Path(app.conf.download_dir) / f"{constants.BINARY_NAME}.tmp" logging.debug( - f"Updating {config.name_app} to latest version by overwriting: {lli_file_path}") # noqa: E501 + f"Updating {constants.APP_NAME} to latest version by overwriting: {lli_file_path}") # noqa: E501 # Remove existing downloaded file if different version. if lli_download_path.is_file(): logging.info("Checking if existing LLI binary is latest version.") lli_download_ver = utils.get_lli_release_version(lli_download_path) - if not lli_download_ver or lli_download_ver != config.LLI_LATEST_VERSION: # noqa: E501 + if not lli_download_ver or lli_download_ver != app.conf.app_latest_version: # noqa: E501 logging.info(f"Removing \"{lli_download_path}\", version: {lli_download_ver}") # noqa: E501 # Remove incompatible file. lli_download_path.unlink() logos_reuse_download( - config.LOGOS_LATEST_VERSION_URL, - config.name_binary, - config.MYDOWNLOADS, + app.conf.app_latest_version_url, + constants.BINARY_NAME, + app.conf.download_dir, app=app, ) shutil.copy(lli_download_path, temp_path) @@ -593,5 +608,5 @@ def update_lli_binary(app=None): return os.chmod(sys.argv[0], os.stat(sys.argv[0]).st_mode | 0o111) - logging.debug(f"Successfully updated {config.name_app}.") + logging.debug(f"Successfully updated {constants.APP_NAME}.") utils.restart_lli() diff --git a/ou_dedetai/system.py b/ou_dedetai/system.py index 330187a6..2bcaa039 100644 --- a/ou_dedetai/system.py +++ b/ou_dedetai/system.py @@ -1,4 +1,5 @@ -from typing import Optional +from dataclasses import dataclass +from typing import Optional, Tuple import distro import logging import os @@ -12,15 +13,15 @@ import zipfile from pathlib import Path +from ou_dedetai.app import App -from . import config -from . import msg + +from . import constants from . import network -from . import utils # TODO: Replace functions in control.py and wine.py with Popen command. -def run_command(command, retries=1, delay=0, **kwargs) -> Optional[subprocess.CompletedProcess[any]]: # noqa: E501 +def run_command(command, retries=1, delay=0, **kwargs) -> Optional[subprocess.CompletedProcess]: # noqa: E501 check = kwargs.get("check", True) text = kwargs.get("text", True) capture_output = kwargs.get("capture_output", True) @@ -59,7 +60,7 @@ def run_command(command, retries=1, delay=0, **kwargs) -> Optional[subprocess.Co for attempt in range(retries): try: - result = subprocess.run( + result: subprocess.CompletedProcess = subprocess.run( command, check=check, text=text, @@ -93,7 +94,8 @@ def run_command(command, retries=1, delay=0, **kwargs) -> Optional[subprocess.Co ) return result except subprocess.CalledProcessError as e: - logging.error(f"Error occurred in run_command() while executing \"{command}\": {e}") # noqa: E501 + logging.error(f"Error occurred in run_command() while executing \"{command}\": {e}.") # noqa: E501 + logging.debug(f"Command failed with output:\n{e.stdout}\nand stderr:\n{e.stderr}") #noqa: E501 if "lock" in str(e): logging.debug(f"Database appears to be locked. Retrying in {delay} seconds…") # noqa: E501 time.sleep(delay) @@ -107,7 +109,7 @@ def run_command(command, retries=1, delay=0, **kwargs) -> Optional[subprocess.Co return None -def popen_command(command, retries=1, delay=0, **kwargs): +def popen_command(command, retries=1, delay=0, **kwargs) -> Optional[subprocess.Popen[bytes]]: #noqa: E501 shell = kwargs.get("shell", False) env = kwargs.get("env", None) cwd = kwargs.get("cwd", None) @@ -132,7 +134,6 @@ def popen_command(command, retries=1, delay=0, **kwargs): process_group = kwargs.get("process_group", None) encoding = kwargs.get("encoding", None) errors = kwargs.get("errors", None) - text = kwargs.get("text", None) if retries < 1: retries = 1 @@ -140,7 +141,7 @@ def popen_command(command, retries=1, delay=0, **kwargs): if isinstance(command, str) and not shell: command = command.split() - for attempt in range(retries): + for _ in range(retries): try: process = subprocess.Popen( command, @@ -168,7 +169,7 @@ def popen_command(command, retries=1, delay=0, **kwargs): process_group=process_group, encoding=encoding, errors=errors, - text=text + text=False ) return process @@ -187,7 +188,7 @@ def popen_command(command, retries=1, delay=0, **kwargs): return None -def get_pids(query): +def get_pids(query) -> list[psutil.Process]: results = [] for process in psutil.process_iter(['pid', 'name', 'cmdline']): try: @@ -198,16 +199,9 @@ def get_pids(query): return results -def get_logos_pids(): - config.processes[config.LOGOS_EXE] = get_pids(config.LOGOS_EXE) - config.processes[config.logos_login_cmd] = get_pids(config.logos_login_cmd) - config.processes[config.logos_cef_cmd] = get_pids(config.logos_cef_cmd) - config.processes[config.logos_indexer_exe] = get_pids(config.logos_indexer_exe) # noqa: E501 - - -def reboot(): +def reboot(superuser_command: str): logging.info("Rebooting system.") - command = f"{config.SUPERUSER_COMMAND} reboot now" + command = f"{superuser_command} reboot now" subprocess.run( command, stdout=subprocess.PIPE, @@ -218,27 +212,44 @@ def reboot(): sys.exit(0) -def get_dialog(): +def get_dialog() -> str: + """Returns which frontend the user prefers + + Uses "DIALOG" from environment if found, + otherwise opens curses if the user has a tty + + Returns: + dialog - tk (graphical), curses (terminal ui), or cli (command line) + """ if not os.environ.get('DISPLAY'): - msg.logos_error("The installer does not work unless you are running a display") # noqa: E501 + print("The installer does not work unless you are running a display", file=sys.stderr) # noqa: E501 + sys.exit(1) dialog = os.getenv('DIALOG') - # Set config.DIALOG. + # find dialog if dialog is not None: dialog = dialog.lower() if dialog not in ['cli', 'curses', 'tk']: - msg.logos_error("Valid values for DIALOG are 'cli', 'curses' or 'tk'.") # noqa: E501 - config.DIALOG = dialog - elif sys.__stdin__.isatty(): - config.DIALOG = 'curses' + print("Valid values for DIALOG are 'cli', 'curses' or 'tk'.", file=sys.stderr) # noqa: E501 + sys.exit(1) + return dialog + elif sys.__stdin__ is not None and sys.__stdin__.isatty(): + return 'curses' else: - config.DIALOG = 'tk' + return 'tk' -def get_architecture(): +def get_architecture() -> Tuple[str, int]: + """Queries the device and returns which cpu architure and bits is supported + + Returns: + architecture: x86_64 x86_32 or """ machine = platform.machine().lower() bits = struct.calcsize("P") * 8 + # FIXME: consider conforming to a standard for the architecture name + # normally see arm64 in lowercase for example and risc as riscv64 on + # debian's support architectures for example https://wiki.debian.org/SupportedArchitectures if "x86_64" in machine or "amd64" in machine: architecture = "x86_64" elif "i386" in machine or "i686" in machine: @@ -260,11 +271,13 @@ def get_architecture(): def install_elf_interpreter(): - # TODO: This probably needs to be changed to another install step that requests the user to choose a specific - # ELF interpreter between box64, FEX-EMU, and hangover. That or else we have to pursue a particular interpreter + # TODO: This probably needs to be changed to another install step that requests the + # user to choose a specific ELF interpreter between box64, FEX-EMU, and hangover. + # That or else we have to pursue a particular interpreter # for the install routine, depending on what's needed logging.critical("ELF interpretation is not yet coded in the installer.") - # if "x86_64" not in config.architecture: + # architecture, bits = get_architecture() + # if "x86_64" not in architecture: # if config.ELFPACKAGES is not None: # utils.install_packages(config.ELFPACKAGES) # else: @@ -275,69 +288,105 @@ def install_elf_interpreter(): def check_architecture(): - if "x86_64" in config.architecture: + architecture, bits = get_architecture() + logging.debug(f"Current Architecture: {architecture}, {bits}bit.") + if "x86_64" in architecture: pass - elif "ARM64" in config.architecture: - logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.") + elif "ARM64" in architecture: + logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.") #noqa: E501 install_elf_interpreter() - elif "RISC-V 64" in config.architecture: - logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.") + elif "RISC-V 64" in architecture: + logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.") #noqa: E501 install_elf_interpreter() - elif "x86_32" in config.architecture: - logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.") + elif "x86_32" in architecture: + logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.") #noqa: E501 install_elf_interpreter() - elif "ARM32" in config.architecture: - logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.") + elif "ARM32" in architecture: + logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.") #noqa: E501 install_elf_interpreter() - elif "RISC-V 32" in config.architecture: - logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.") + elif "RISC-V 32" in architecture: + logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.") #noqa: E501 install_elf_interpreter() else: logging.critical("System archictecture unknown.") -def get_os(): +def get_os() -> Tuple[str, str]: + """Gets OS information + + Returns: + OS name + OS release + """ # FIXME: Not working? Returns "Linux" on some systems? On Ubuntu 24.04 it # correctly returns "ubuntu". - config.OS_NAME = distro.id() - logging.info(f"OS name: {config.OS_NAME}") - config.OS_RELEASE = distro.version() - logging.info(f"OS release: {config.OS_RELEASE}") - return config.OS_NAME, config.OS_RELEASE + os_name = distro.id() + logging.info(f"OS name: {os_name}") + os_release = distro.version() + logging.info(f"OS release: {os_release}") + return os_name, os_release -def get_superuser_command(): - if config.DIALOG == 'tk': - if shutil.which('pkexec'): - config.SUPERUSER_COMMAND = "pkexec" - else: - msg.logos_error("No superuser command found. Please install pkexec.") # noqa: E501 +class SuperuserCommandNotFound(Exception): + """Superuser command not found. Install pkexec or sudo or doas""" + + +def get_superuser_command() -> str: + if shutil.which('pkexec'): + return "pkexec" + elif shutil.which('sudo'): + return "sudo" + elif shutil.which('doas'): + return "doas" else: - if shutil.which('pkexec'): - config.SUPERUSER_COMMAND = "pkexec" - elif shutil.which('sudo'): - config.SUPERUSER_COMMAND = "sudo" - elif shutil.which('doas'): - config.SUPERUSER_COMMAND = "doas" - else: - msg.logos_error("No superuser command found. Please install sudo or doas.") # noqa: E501 - logging.debug(f"{config.SUPERUSER_COMMAND=}") + raise SuperuserCommandNotFound + + +@dataclass +class PackageManager: + """Dataclass to pass around relevant OS context regarding system packages""" + # Commands + install: list[str] + download: list[str] + remove: list[str] + query: list[str] + query_prefix: str -def get_package_manager(): + packages: str + logos_9_packages: str + + incompatible_packages: str + # For future expansion: + # elf_packages: str + + +def get_package_manager() -> PackageManager | None: major_ver = distro.major_version() - logging.debug(f"{config.OS_NAME=}; {major_ver=}") + os_name = distro.id() + logging.debug(f"{os_name=}; {major_ver=}") # Check for package manager and associated packages. # NOTE: cabextract and sed are included in the appimage, so they are not # included as system dependencies. + + install_command: list[str] + download_command: list[str] + remove_command: list[str] + query_command: list[str] + query_prefix: str + packages: str + # FIXME: Missing Logos 9 Packages + logos_9_packages: str = "" + incompatible_packages: str + if shutil.which('apt') is not None: # debian, ubuntu, & derivatives - config.PACKAGE_MANAGER_COMMAND_INSTALL = ["apt", "install", "-y"] - config.PACKAGE_MANAGER_COMMAND_DOWNLOAD = ["apt", "install", "--download-only", "-y"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_REMOVE = ["apt", "remove", "-y"] - config.PACKAGE_MANAGER_COMMAND_QUERY = ["dpkg", "-l"] - config.QUERY_PREFIX = '.i ' + install_command = ["apt", "install", "-y"] + download_command = ["apt", "install", "--download-only", "-y"] # noqa: E501 + remove_command = ["apt", "remove", "-y"] + query_command = ["dpkg", "-l"] + query_prefix = '.i ' # Set default package list. - config.PACKAGES = ( + packages = ( "libfuse2 " # appimages "binutils wget winbind " # wine "p7zip-full " # winetricks @@ -351,62 +400,58 @@ def get_package_manager(): # - https://en.wikipedia.org/wiki/Elementary_OS # - https://github.com/which-distro/os-release/tree/main if ( - (config.OS_NAME == 'debian' and major_ver >= '13') - or (config.OS_NAME == 'ubuntu' and major_ver >= '24') - or (config.OS_NAME == 'linuxmint' and major_ver >= '22') - or (config.OS_NAME == 'elementary' and major_ver >= '8') + (os_name == 'debian' and major_ver >= '13') + or (os_name == 'ubuntu' and major_ver >= '24') + or (os_name == 'linuxmint' and major_ver >= '22') + or (os_name == 'elementary' and major_ver >= '8') ): - config.PACKAGES = ( + packages = ( "libfuse3-3 " # appimages "binutils wget winbind " # wine "7zip " # winetricks ) - config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages - config.ELFPACKAGES = "" - config.BADPACKAGES = "" # appimagelauncher handled separately + logos_9_packages = "" + incompatible_packages = "" # appimagelauncher handled separately elif shutil.which('dnf') is not None: # rhel, fedora - config.PACKAGE_MANAGER_COMMAND_INSTALL = ["dnf", "install", "-y"] - config.PACKAGE_MANAGER_COMMAND_DOWNLOAD = ["dnf", "install", "--downloadonly", "-y"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_REMOVE = ["dnf", "remove", "-y"] + install_command = ["dnf", "install", "-y"] + download_command = ["dnf", "install", "--downloadonly", "-y"] # noqa: E501 + remove_command = ["dnf", "remove", "-y"] # Fedora < 41 uses dnf4, while Fedora 41 uses dnf5. The dnf list # command is sligtly different between the two. # https://discussion.fedoraproject.org/t/after-f41-upgrade-dnf-says-no-packages-are-installed/135391 # noqa: E501 # Fedora < 41 - # config.PACKAGE_MANAGER_COMMAND_QUERY = ["dnf", "list", "installed"] + # query_command = ["dnf", "list", "installed"] # Fedora 41 - # config.PACKAGE_MANAGER_COMMAND_QUERY = ["dnf", "list", "--installed"] - config.PACKAGE_MANAGER_COMMAND_QUERY = ["rpm", "-qa"] # workaround - config.QUERY_PREFIX = '' - # config.PACKAGES = "patch fuse3 fuse3-libs mod_auth_ntlm_winbind samba-winbind samba-winbind-clients cabextract bc libxml2 curl" # noqa: E501 - config.PACKAGES = ( + # query_command = ["dnf", "list", "--installed"] + query_command = ["rpm", "-qa"] # workaround + query_prefix = '' + # logos_10_packages = "patch fuse3 fuse3-libs mod_auth_ntlm_winbind samba-winbind samba-winbind-clients cabextract bc libxml2 curl" # noqa: E501 + packages = ( "fuse fuse-libs " # appimages "mod_auth_ntlm_winbind samba-winbind samba-winbind-clients " # wine # noqa: E501 "p7zip-plugins " # winetricks ) - config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages - config.ELFPACKAGES = "" - config.BADPACKAGES = "" # appimagelauncher handled separately - elif shutil.which('zypper') is not None: # opensuse - config.PACKAGE_MANAGER_COMMAND_INSTALL = ["zypper", "--non-interactive", "install"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_DOWNLOAD = ["zypper", "download"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_REMOVE = ["zypper", "--non-interactive", "remove"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_QUERY = ["zypper", "se", "-si"] - config.QUERY_PREFIX = 'i | ' - config.PACKAGES = ( + incompatible_packages = "" # appimagelauncher handled separately + elif shutil.which('zypper') is not None: # manjaro + install_command = ["zypper", "--non-interactive", "install"] # noqa: E501 + download_command = ["zypper", "download"] # noqa: E501 + remove_command = ["zypper", "--non-interactive", "remove"] # noqa: E501 + query_command = ["zypper", "se", "-si"] + query_prefix = 'i | ' + packages = ( "fuse2 " # appimages "samba wget " # wine "7zip " # winetricks "curl gawk grep " # other ) - config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages - config.BADPACKAGES = "" # appimagelauncher handled separately + incompatible_packages = "" # appimagelauncher handled separately elif shutil.which('apk') is not None: # alpine - config.PACKAGE_MANAGER_COMMAND_INSTALL = ["apk", "--no-interactive", "add"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_DOWNLOAD = ["apk", "--no-interactive", "fetch"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_REMOVE = ["apk", "--no-interactive", "del"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_QUERY = ["apk", "list", "-i"] - config.QUERY_PREFIX = '' - config.PACKAGES = ( + install_command = ["apk", "--no-interactive", "add"] # noqa: E501 + download_command = ["apk", "--no-interactive", "fetch"] # noqa: E501 + remove_command = ["apk", "--no-interactive", "del"] # noqa: E501 + query_command = ["apk", "list", "-i"] + query_prefix = '' + packages = ( "bash bash-completion" # bash support "gcompat" # musl to glibc "fuse-common fuse" # appimages @@ -414,34 +459,31 @@ def get_package_manager(): "7zip" # winetricks "samba sed grep gawk bash bash-completion" # other ) - config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages - config.BADPACKAGES = "" # appimagelauncher handled separately + incompatible_packages = "" # appimagelauncher handled separately elif shutil.which('pamac') is not None: # manjaro - config.PACKAGE_MANAGER_COMMAND_INSTALL = ["pamac", "install", "--no-upgrade", "--no-confirm"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_DOWNLOAD = ["pamac", "install", "--no-upgrade", "--download-only", "--no-confirm"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_REMOVE = ["pamac", "remove", "--no-confirm"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_QUERY = ["pamac", "list", "-i"] - config.QUERY_PREFIX = '' - config.PACKAGES = ( + install_command = ["pamac", "install", "--no-upgrade", "--no-confirm"] # noqa: E501 + download_command = ["pamac", "install", "--no-upgrade", "--download-only", "--no-confirm"] # noqa: E501 + remove_command = ["pamac", "remove", "--no-confirm"] # noqa: E501 + query_command = ["pamac", "list", "-i"] + query_prefix = '' + packages = ( "fuse2 " # appimages "samba wget " # wine "p7zip " # winetricks "curl gawk grep " # other ) - config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages - config.ELFPACKAGES = "" - config.BADPACKAGES = "" # appimagelauncher handled separately + incompatible_packages = "" # appimagelauncher handled separately elif shutil.which('pacman') is not None: # arch, steamOS - config.PACKAGE_MANAGER_COMMAND_INSTALL = ["pacman", "-Syu", "--overwrite", "\\*", "--noconfirm", "--needed"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_DOWNLOAD = ["pacman", "-Sw", "-y"] - config.PACKAGE_MANAGER_COMMAND_REMOVE = ["pacman", "-R", "--no-confirm"] # noqa: E501 - config.PACKAGE_MANAGER_COMMAND_QUERY = ["pacman", "-Q"] - config.QUERY_PREFIX = '' - if config.OS_NAME == "steamos": # steamOS - config.PACKAGES = "patch wget sed grep gawk cabextract samba bc libxml2 curl print-manager system-config-printer cups-filters nss-mdns foomatic-db-engine foomatic-db-ppds foomatic-db-nonfree-ppds ghostscript glibc samba extra-rel/apparmor core-rel/libcurl-gnutls winetricks appmenu-gtk-module lib32-libjpeg-turbo qt5-virtualkeyboard wine-staging giflib lib32-giflib libpng lib32-libpng libldap lib32-libldap gnutls lib32-gnutls mpg123 lib32-mpg123 openal lib32-openal v4l-utils lib32-v4l-utils libpulse lib32-libpulse libgpg-error lib32-libgpg-error alsa-plugins lib32-alsa-plugins alsa-lib lib32-alsa-lib libjpeg-turbo lib32-libjpeg-turbo sqlite lib32-sqlite libxcomposite lib32-libxcomposite libxinerama lib32-libgcrypt libgcrypt lib32-libxinerama ncurses lib32-ncurses ocl-icd lib32-ocl-icd libxslt lib32-libxslt libva lib32-libva gtk3 lib32-gtk3 gst-plugins-base-libs lib32-gst-plugins-base-libs vulkan-icd-loader lib32-vulkan-icd-loader" # noqa: #E501 + install_command = ["pacman", "-Syu", "--overwrite", "\\*", "--noconfirm", "--needed"] # noqa: E501 + download_command = ["pacman", "-Sw", "-y"] + remove_command = ["pacman", "-R", "--no-confirm"] # noqa: E501 + query_command = ["pacman", "-Q"] + query_prefix = '' + if os_name == "steamos": # steamOS + packages = "patch wget sed grep gawk cabextract samba bc libxml2 curl print-manager system-config-printer cups-filters nss-mdns foomatic-db-engine foomatic-db-ppds foomatic-db-nonfree-ppds ghostscript glibc samba extra-rel/apparmor core-rel/libcurl-gnutls winetricks appmenu-gtk-module lib32-libjpeg-turbo qt5-virtualkeyboard wine-staging giflib lib32-giflib libpng lib32-libpng libldap lib32-libldap gnutls lib32-gnutls mpg123 lib32-mpg123 openal lib32-openal v4l-utils lib32-v4l-utils libpulse lib32-libpulse libgpg-error lib32-libgpg-error alsa-plugins lib32-alsa-plugins alsa-lib lib32-alsa-lib libjpeg-turbo lib32-libjpeg-turbo sqlite lib32-sqlite libxcomposite lib32-libxcomposite libxinerama lib32-libgcrypt libgcrypt lib32-libxinerama ncurses lib32-ncurses ocl-icd lib32-ocl-icd libxslt lib32-libxslt libva lib32-libva gtk3 lib32-gtk3 gst-plugins-base-libs lib32-gst-plugins-base-libs vulkan-icd-loader lib32-vulkan-icd-loader" # noqa: E501 else: # arch - # config.PACKAGES = "patch wget sed grep cabextract samba glibc samba apparmor libcurl-gnutls winetricks appmenu-gtk-module lib32-libjpeg-turbo wine giflib lib32-giflib libpng lib32-libpng libldap lib32-libldap gnutls lib32-gnutls mpg123 lib32-mpg123 openal lib32-openal v4l-utils lib32-v4l-utils libpulse lib32-libpulse libgpg-error lib32-libgpg-error alsa-plugins lib32-alsa-plugins alsa-lib lib32-alsa-lib libjpeg-turbo lib32-libjpeg-turbo sqlite lib32-sqlite libxcomposite lib32-libxcomposite libxinerama lib32-libgcrypt libgcrypt lib32-libxinerama ncurses lib32-ncurses ocl-icd lib32-ocl-icd libxslt lib32-libxslt libva lib32-libva gtk3 lib32-gtk3 gst-plugins-base-libs lib32-gst-plugins-base-libs vulkan-icd-loader lib32-vulkan-icd-loader" # noqa: E501 - config.PACKAGES = ( + # logos_10_packages = "patch wget sed grep cabextract samba glibc samba apparmor libcurl-gnutls winetricks appmenu-gtk-module lib32-libjpeg-turbo wine giflib lib32-giflib libpng lib32-libpng libldap lib32-libldap gnutls lib32-gnutls mpg123 lib32-mpg123 openal lib32-openal v4l-utils lib32-v4l-utils libpulse lib32-libpulse libgpg-error lib32-libgpg-error alsa-plugins lib32-alsa-plugins alsa-lib lib32-alsa-lib libjpeg-turbo lib32-libjpeg-turbo sqlite lib32-sqlite libxcomposite lib32-libxcomposite libxinerama lib32-libgcrypt libgcrypt lib32-libxinerama ncurses lib32-ncurses ocl-icd lib32-ocl-icd libxslt lib32-libxslt libva lib32-libva gtk3 lib32-gtk3 gst-plugins-base-libs lib32-gst-plugins-base-libs vulkan-icd-loader lib32-vulkan-icd-loader" # noqa: E501 + packages = ( "fuse2 " # appimages "binutils libwbclient samba wget " # wine "p7zip " # winetricks @@ -451,18 +493,24 @@ def get_package_manager(): "libva mpg123 v4l-utils " # video "libxslt sqlite " # misc ) - config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages - config.ELFPACKAGES = "" - config.BADPACKAGES = "" # appimagelauncher handled separately + incompatible_packages = "" # appimagelauncher handled separately else: # Add more conditions for other package managers as needed. - msg.logos_error("Your package manager is not yet supported. Please contact the developers.") # noqa: E501 + logging.critical("Your package manager is not yet supported. Please contact the developers.") #noqa: E501 + return None - # Add logging output. - logging.debug(f"{config.PACKAGE_MANAGER_COMMAND_INSTALL=}") - logging.debug(f"{config.PACKAGE_MANAGER_COMMAND_QUERY=}") - logging.debug(f"{config.PACKAGES=}") - logging.debug(f"{config.L9PACKAGES=}") + output = PackageManager( + install=install_command, + download=download_command, + query=query_command, + remove=remove_command, + incompatible_packages=incompatible_packages, + packages=packages, + logos_9_packages=logos_9_packages, + query_prefix=query_prefix + ) + logging.debug("Package Manager: {output}") + return output def get_runmode(): @@ -472,17 +520,21 @@ def get_runmode(): return 'script' -def query_packages(packages, mode="install", app=None): - result = "" +def query_packages(package_manager: PackageManager, packages, mode="install") -> list[str]: #noqa: E501 + result = None missing_packages = [] conflicting_packages = [] - command = config.PACKAGE_MANAGER_COMMAND_QUERY + + command = package_manager.query try: result = run_command(command) except Exception as e: logging.error(f"Error occurred while executing command: {e}") - logging.error(e.output) + # FIXME: consider raising an exception + if result is None: + logging.error("Failed to query packages") + return [] package_list = result.stdout logging.debug(f"packages to check: {packages}") @@ -494,7 +546,7 @@ def query_packages(packages, mode="install", app=None): for line in package_list.split('\n'): # logging.debug(f"{line=}") l_num += 1 - if config.PACKAGE_MANAGER_COMMAND_QUERY[0] == 'dpkg': + if package_manager.query[0] == 'dpkg': parts = line.strip().split() if l_num < 6 or len(parts) < 2: # skip header, etc. continue @@ -508,7 +560,7 @@ def query_packages(packages, mode="install", app=None): status[p] = 'Conflicting' break else: - if line.strip().startswith(f"{config.QUERY_PREFIX}{p}") and mode == "install": # noqa: E501 + if line.strip().startswith(f"{package_manager.query_prefix}{p}") and mode == "install": # noqa: E501 logging.debug(f"'{p}' installed: {line}") status[p] = "Installed" break @@ -537,6 +589,8 @@ def query_packages(packages, mode="install", app=None): txt = f"Conflicting packages: {' '.join(conflicting_packages)}" logging.info(f"Conflicting packages: {txt}") return conflicting_packages + else: + raise ValueError(f"Invalid query mode: {mode}") def have_dep(cmd): @@ -585,10 +639,13 @@ def parse_date(version): return None -def remove_appimagelauncher(app=None): +def remove_appimagelauncher(app: App): + app.status("Removing AppImageLauncher…") pkg = "appimagelauncher" - cmd = [config.SUPERUSER_COMMAND, *config.PACKAGE_MANAGER_COMMAND_REMOVE, pkg] # noqa: E501 - msg.status("Removing AppImageLauncher…", app) + package_manager = get_package_manager() + if package_manager is None: + app.exit("Failed to find the package manager to uninstall AppImageLauncher.") + cmd = [app.superuser_command, *package_manager.remove, pkg] # noqa: E501 try: logging.debug(f"Running command: {cmd}") run_command(cmd) @@ -598,284 +655,243 @@ def remove_appimagelauncher(app=None): else: logging.error(f"An error occurred: {e}") logging.error(f"Command output: {e.output}") - msg.logos_error("Failed to uninstall AppImageLauncher.") - sys.exit(1) + app.exit(f"Failed to uninstall AppImageLauncher: {e}") logging.info("System reboot is required.") sys.exit() -def preinstall_dependencies_steamos(): +def preinstall_dependencies_steamos(superuser_command: str): logging.debug("Disabling read only, updating pacman keys…") command = [ - config.SUPERUSER_COMMAND, "steamos-readonly", "disable", "&&", - config.SUPERUSER_COMMAND, "pacman-key", "--init", "&&", - config.SUPERUSER_COMMAND, "pacman-key", "--populate", "archlinux", + superuser_command, "steamos-readonly", "disable", "&&", + superuser_command, "pacman-key", "--init", "&&", + superuser_command, "pacman-key", "--populate", "archlinux", ] return command -def postinstall_dependencies_steamos(): +def postinstall_dependencies_steamos(superuser_command: str): logging.debug("Updating DNS settings & locales, enabling services & read-only system…") # noqa: E501 command = [ - config.SUPERUSER_COMMAND, "sed", '-i', + superuser_command, "sed", '-i', 's/mymachines resolve/mymachines mdns_minimal [NOTFOUND=return] resolve/', # noqa: E501 '/etc/nsswitch.conf', '&&', - config.SUPERUSER_COMMAND, "locale-gen", '&&', - config.SUPERUSER_COMMAND, "systemctl", "enable", "--now", "avahi-daemon", "&&", # noqa: E501 - config.SUPERUSER_COMMAND, "systemctl", "enable", "--now", "cups", "&&", # noqa: E501 - config.SUPERUSER_COMMAND, "steamos-readonly", "enable", + superuser_command, "locale-gen", '&&', + superuser_command, "systemctl", "enable", "--now", "avahi-daemon", "&&", # noqa: E501 + superuser_command, "systemctl", "enable", "--now", "cups", "&&", # noqa: E501 + superuser_command, "steamos-readonly", "enable", ] return command -def postinstall_dependencies_alpine(): +def postinstall_dependencies_alpine(superuser_command: str): user = os.getlogin() command = [ - config.SUPERUSER_COMMAND, "modprobe", "fuse", "&&", - config.SUPERUSER_COMMAND, "rc-update", "add", "fuse", "boot", "&&", - config.SUPERUSER_COMMAND, "sed", "-i", "'s/#user_allow_other/user_allow_other/g'", "/etc/fuse.conf", "&&", - config.SUPERUSER_COMMAND, "addgroup", "fuse", "&&", - config.SUPERUSER_COMMAND, "adduser", f"{user}", "fuse", "&&", - config.SUPERUSER_COMMAND, "rc-service", "fuse", "restart", + superuser_command, "modprobe", "fuse", "&&", + superuser_command, "rc-update", "add", "fuse", "boot", "&&", + superuser_command, "sed", "-i", "'s/#user_allow_other/user_allow_other/g'", "/etc/fuse.conf", "&&", #noqa: E501 + superuser_command, "addgroup", "fuse", "&&", + superuser_command, "adduser", f"{user}", "fuse", "&&", + superuser_command, "rc-service", "fuse", "restart", ] return command -def preinstall_dependencies(app=None): +def preinstall_dependencies(superuser_command: str): command = [] logging.debug("Performing pre-install dependencies…") - if config.OS_NAME == "Steam": - command = preinstall_dependencies_steamos() + os_name, _ = get_os() + if os_name == "Steam": + command = preinstall_dependencies_steamos(superuser_command) else: logging.debug("No pre-install dependencies required.") return command -def postinstall_dependencies(app=None): +def postinstall_dependencies(superuser_command: str): command = [] logging.debug("Performing post-install dependencies…") - if config.OS_NAME == "Steam": - command = postinstall_dependencies_steamos() - if config.OS_NAME == "alpine": - command = postinstall_dependencies_alpine() + os_name, _ = get_os() + if os_name == "Steam": + command = postinstall_dependencies_steamos(superuser_command) + elif os_name == "alpine": + command = postinstall_dependencies_alpine(superuser_command) else: logging.debug("No post-install dependencies required.") return command -def install_dependencies(packages, bad_packages, logos9_packages=None, app=None): # noqa: E501 - if config.SKIP_DEPENDENCIES: +def install_dependencies(app: App, target_version=10): # noqa: E501 + if app.conf.skip_install_system_dependencies: return install_deps_failed = False manual_install_required = False - message = None - no_message = None - secondary = None + reboot_required = False + message: Optional[str] = None + secondary: Optional[str] = None command = [] preinstall_command = [] install_command = [] remove_command = [] postinstall_command = [] - missing_packages = {} - conflicting_packages = {} + missing_packages = [] + conflicting_packages = [] package_list = [] bad_package_list = [] bad_os = ['fedora', 'arch', 'alpine'] - if packages: - package_list = packages.split() - - if bad_packages: - bad_package_list = bad_packages.split() + package_manager = get_package_manager() - if logos9_packages: - package_list.extend(logos9_packages.split()) + os_name, _ = get_os() - if config.PACKAGE_MANAGER_COMMAND_QUERY: - logging.debug("Querying packages…") - missing_packages = query_packages( - package_list, - app=app - ) - conflicting_packages = query_packages( - bad_package_list, - mode="remove", - app=app + if not package_manager: + app.exit( + f"The script could not determine your {os_name} install's package manager or it is unsupported." # noqa: E501 ) - if config.PACKAGE_MANAGER_COMMAND_INSTALL: - if config.OS_NAME in bad_os: - message = False - no_message = False - secondary = False - elif missing_packages and conflicting_packages: - message = f"Your {config.OS_NAME} computer requires installing and removing some software.\nProceed?" # noqa: E501 - no_message = "User refused to install and remove software via the application" # noqa: E501 - secondary = f"To continue, the program will attempt to install the following package(s) by using '{config.PACKAGE_MANAGER_COMMAND_INSTALL}':\n{missing_packages}\nand will remove the following package(s) by using '{config.PACKAGE_MANAGER_COMMAND_REMOVE}':\n{conflicting_packages}" # noqa: E501 - elif missing_packages: - message = f"Your {config.OS_NAME} computer requires installing some software.\nProceed?" # noqa: E501 - no_message = "User refused to install software via the application." # noqa: E501 - secondary = f"To continue, the program will attempt to install the following package(s) by using '{config.PACKAGE_MANAGER_COMMAND_INSTALL}':\n{missing_packages}" # noqa: E501 - elif conflicting_packages: - message = f"Your {config.OS_NAME} computer requires removing some software.\nProceed?" # noqa: E501 - no_message = "User refused to remove software via the application." # noqa: E501 - secondary = f"To continue, the program will attempt to remove the following package(s) by using '{config.PACKAGE_MANAGER_COMMAND_REMOVE}':\n{conflicting_packages}" # noqa: E501 - else: - message = None + package_list = package_manager.packages.split() - if message is None: - logging.debug("No missing or conflicting dependencies found.") - elif not message: - m = "Your distro requires manual dependency installation." - logging.error(m) - else: - msg.logos_continue_question(message, no_message, secondary, app) - if config.DIALOG == "curses": - app.confirm_e.wait() + bad_package_list = package_manager.incompatible_packages.split() - # TODO: Need to send continue question to user based on DIALOG. - # All we do above is create a message that we never send. - # Do we need a TK continue question? I see we have a CLI and curses one - # in msg.py + if target_version == 9: + package_list.extend(package_manager.logos_9_packages.split()) - preinstall_command = preinstall_dependencies() + logging.debug("Querying packages…") + missing_packages = query_packages( + package_manager, + package_list, + ) + conflicting_packages = query_packages( + package_manager, + bad_package_list, + mode="remove", + ) - if missing_packages: - install_command = config.PACKAGE_MANAGER_COMMAND_INSTALL + missing_packages # noqa: E501 - else: - logging.debug("No missing packages detected.") + if os_name in bad_os: + m = "Your distro requires manual dependency installation." + logging.error(m) + return + elif missing_packages and conflicting_packages: + message = f"Your {os_name} computer requires installing and removing some software.\nProceed?" # noqa: E501 + secondary = f"To continue, the program will attempt to install the following package(s) by using '{package_manager.install}':\n{missing_packages}\nand will remove the following package(s) by using '{package_manager.remove}':\n{conflicting_packages}" # noqa: E501 + elif missing_packages: + message = f"Your {os_name} computer requires installing some software.\nProceed?" # noqa: E501 + secondary = f"To continue, the program will attempt to install the following package(s) by using '{package_manager.install}':\n{missing_packages}" # noqa: E501 + elif conflicting_packages: + message = f"Your {os_name} computer requires removing some software.\nProceed?" # noqa: E501 + secondary = f"To continue, the program will attempt to remove the following package(s) by using '{package_manager.remove}':\n{conflicting_packages}" # noqa: E501 + + if message is None: + logging.debug("No missing or conflicting dependencies found.") + elif not message: + m = "Your distro requires manual dependency installation." + logging.error(m) + else: + app.approve_or_exit(message, secondary) - if conflicting_packages: - # TODO: Verify with user before executing - # AppImage Launcher is the only known conflicting package. - remove_command = config.PACKAGE_MANAGER_COMMAND_REMOVE + conflicting_packages # noqa: E501 - config.REBOOT_REQUIRED = True - logging.info("System reboot required.") - else: - logging.debug("No conflicting packages detected.") - - postinstall_command = postinstall_dependencies(app) - - if preinstall_command: - command.extend(preinstall_command) - if install_command: - if command: - command.append('&&') - command.extend(install_command) - if remove_command: - if command: - command.append('&&') - command.extend(remove_command) - if postinstall_command: - if command: - command.append('&&') - command.extend(postinstall_command) - if not command: # nothing to run; avoid running empty pkexec command - if app: - msg.status("All dependencies are met.", app) - if config.DIALOG == "curses": - app.installdeps_e.set() - return - - if app and config.DIALOG == 'tk': - app.root.event_generate('<>') - msg.status("Installing dependencies…", app) - final_command = [ - f"{config.SUPERUSER_COMMAND}", 'sh', '-c', '"', *command, '"' - ] - command_str = ' '.join(final_command) - # TODO: Fix fedora/arch handling. - if config.OS_NAME in ['fedora', 'arch']: - manual_install_required = True - sudo_command = command_str.replace("pkexec", "sudo") - message = "The system needs to install/remove packages, but it requires manual intervention." # noqa: E501 - detail = ( - "Please run the following command in a terminal, then restart " - f"{config.name_app}:\n{sudo_command}\n" - ) - if config.DIALOG == "tk": - if hasattr(app, 'root'): - detail += "\nThe command has been copied to the clipboard." # noqa: E501 - app.root.clipboard_clear() - app.root.clipboard_append(sudo_command) - app.root.update() - msg.logos_error( - message, - detail=detail, - app=app, - parent='installer_win' - ) - elif config.DIALOG == 'cli': - msg.logos_error(message + "\n" + detail) - install_deps_failed = True + preinstall_command = preinstall_dependencies(app.superuser_command) - if manual_install_required and app and config.DIALOG == "curses": - app.screen_q.put( - app.stack_confirm( - 17, - app.manualinstall_q, - app.manualinstall_e, - f"Please run the following command in a terminal, then select \"Continue\" when finished.\n\n{config.name_app}:\n{sudo_command}\n", # noqa: E501 - "User cancelled dependency installation.", # noqa: E501 - message, - options=["Continue", "Return to Main Menu"], dialog=config.use_python_dialog)) # noqa: E501 - app.manualinstall_e.wait() - - if not install_deps_failed and not manual_install_required: - if config.DIALOG == 'cli': - command_str = command_str.replace("pkexec", "sudo") - try: - logging.debug(f"Attempting to run this command: {command_str}") - run_command(command_str, shell=True) - except subprocess.CalledProcessError as e: - if e.returncode == 127: - logging.error("User cancelled dependency installation.") - else: - logging.error(f"An error occurred in install_dependencies(): {e}") # noqa: E501 - logging.error(f"Command output: {e.output}") - install_deps_failed = True + if missing_packages: + install_command = package_manager.install + missing_packages # noqa: E501 else: - msg.logos_error( - f"The script could not determine your {config.OS_NAME} install's package manager or it is unsupported. " # noqa: E501 - f"Your computer is missing the command(s) {missing_packages}. " - f"Please install your distro's package(s) associated with {missing_packages} for {config.OS_NAME}.") # noqa: E501 - - if config.REBOOT_REQUIRED: - question = "Should the program reboot the host now?" # noqa: E501 - no_text = "The user has chosen not to reboot." - secondary = "The system has installed or removed a package that requires a reboot." # noqa: E501 - if msg.logos_continue_question(question, no_text, secondary): - reboot() - else: - logging.error("Cannot proceed until reboot. Exiting.") - sys.exit(1) - - if install_deps_failed: - if app: - if config.DIALOG == "curses": - app.choice_q.put("Return to Main Menu") + logging.debug("No missing packages detected.") + + if conflicting_packages: + # TODO: Verify with user before executing + # AppImage Launcher is the only known conflicting package. + remove_command = package_manager.remove + conflicting_packages # noqa: E501 + reboot_required = True + logging.info("System reboot required.") else: + logging.debug("No conflicting packages detected.") + + postinstall_command = postinstall_dependencies(app.superuser_command) + + if preinstall_command: + command.extend(preinstall_command) + if install_command: + if command: + command.append('&&') + command.extend(install_command) + if remove_command: + if command: + command.append('&&') + command.extend(remove_command) + if postinstall_command: + if command: + command.append('&&') + command.extend(postinstall_command) + if not command: # nothing to run; avoid running empty pkexec command if app: - if config.DIALOG == "curses": - app.installdeps_e.set() + app.status("All dependencies are met.", 100) + return + + app.status("Installing dependencies…") + final_command = [ + # FIXME: Consider switching this back to single quotes + # (the sed line in alpine post will need to change to double if so) + f"{app.superuser_command}", 'sh', '-c', '"', *command, '"' + ] + command_str = ' '.join(final_command) + # TODO: Fix fedora/arch handling. + if os_name in ['fedora', 'arch']: + manual_install_required = True + sudo_command = command_str.replace("pkexec", "sudo") + message = "The system needs to install/remove packages, but it requires manual intervention." # noqa: E501 + detail = ( + "Please run the following command in a terminal, then restart " + f"{constants.APP_NAME}:\n{sudo_command}\n" + ) + from ou_dedetai import gui_app + if isinstance(app, gui_app.GuiApp): + detail += "\nThe command has been copied to the clipboard." # noqa: E501 + app.root.clipboard_clear() + app.root.clipboard_append(sudo_command) + app.root.update() + app.approve_or_exit(message + " \n" + detail) + + if not install_deps_failed and not manual_install_required: + try: + logging.debug(f"Attempting to run this command: {command_str}") + run_command(command_str, shell=True) + except subprocess.CalledProcessError as e: + if e.returncode == 127: + logging.error("User cancelled dependency installation.") + else: + logging.error(f"An error occurred in install_dependencies(): {e}") # noqa: E501 + logging.error(f"Command output: {e.output}") + install_deps_failed = True + + + if reboot_required: + question = "The system has installed or removed a package that requires a reboot. Do you want to restart now?" # noqa: E501 + if app.approve_or_exit(question): + reboot(app.superuser_command) + else: + logging.error("Please reboot then launch the installer again.") + sys.exit(1) def install_winetricks( - installdir, - app=None, - version=config.WINETRICKS_VERSION, -): - msg.status(f"Installing winetricks v{version}…") + installdir, + app: App, + version=constants.WINETRICKS_VERSION, + status_messages: bool = True +) -> str: + winetricks_path = f"{installdir}/winetricks" + if status_messages: + app.status(f"Installing winetricks v{version}…") base_url = "https://codeload.github.com/Winetricks/winetricks/zip/refs/tags" # noqa: E501 zip_name = f"{version}.zip" network.logos_reuse_download( f"{base_url}/{version}", zip_name, - config.MYDOWNLOADS, + app.conf.download_dir, app=app, + status_messages=status_messages ) - wtzip = f"{config.MYDOWNLOADS}/{zip_name}" + wtzip = f"{app.conf.download_dir}/{zip_name}" logging.debug(f"Extracting winetricks script into {installdir}…") with zipfile.ZipFile(wtzip) as z: for zi in z.infolist(): @@ -885,6 +901,24 @@ def install_winetricks( if zi.filename == 'winetricks': z.extract(zi, path=installdir) break - os.chmod(f"{installdir}/winetricks", 0o755) - config.WINETRICKSBIN = f"{installdir}/winetricks" + os.chmod(winetricks_path, 0o755) + app.conf.winetricks_binary = winetricks_path logging.debug("Winetricks installed.") + return winetricks_path + +def wait_pid(process: Optional[subprocess.Popen]): + if process is not None: + os.waitpid(-process.pid, 0) + + +def check_incompatibilities(app: App): + # Check for AppImageLauncher + if shutil.which('AppImageLauncher'): + question_text = "Remove AppImageLauncher? A reboot will be required." + secondary = ( + "Your system currently has AppImageLauncher installed.\n" + f"{constants.APP_NAME} is not compatible with AppImageLauncher.\n" + f"For more information, see: {constants.REPOSITORY_LINK}/issues/114" + ) + app.approve_or_exit(question_text, secondary) + remove_appimagelauncher(app) \ No newline at end of file diff --git a/ou_dedetai/tui_app.py b/ou_dedetai/tui_app.py index 6a118ba4..0257ec4d 100644 --- a/ou_dedetai/tui_app.py +++ b/ou_dedetai/tui_app.py @@ -1,18 +1,26 @@ import logging import os import signal +import sys import threading import time import curses from pathlib import Path from queue import Queue +from typing import Any, Optional + +from ou_dedetai.app import App +from ou_dedetai.constants import ( + PROMPT_OPTION_DIRECTORY, + PROMPT_OPTION_FILE +) +from ou_dedetai.config import EphemeralConfiguration -from . import config from . import control +from . import constants from . import installer from . import logos from . import msg -from . import network from . import system from . import tui_curses from . import tui_screen @@ -22,100 +30,195 @@ console_message = "" +class ReturningToMainMenu(Exception): + """Exception raised when user returns to the main menu + + effectively stopping execution on the executing thread where this exception + originated from""" + + # TODO: Fix hitting cancel in Dialog Screens; currently crashes program. -class TUI: - def __init__(self, stdscr): +class TUI(App): + def __init__(self, stdscr: curses.window, ephemeral_config: EphemeralConfiguration): + super().__init__(ephemeral_config) self.stdscr = stdscr - # if config.current_logos_version is not None: - self.title = f"Welcome to {config.name_app} {config.LLI_CURRENT_VERSION} ({config.lli_release_channel})" # noqa: E501 - self.subtitle = f"Logos Version: {config.current_logos_version} ({config.logos_release_channel})" # noqa: E501 + self.set_title() # else: - # self.title = f"Welcome to {config.name_app} ({config.LLI_CURRENT_VERSION})" # noqa: E501 + # self.title = f"Welcome to {constants.APP_NAME} ({constants.LLI_CURRENT_VERSION})" # noqa: E501 self.console_message = "Starting TUI…" self.llirunning = True self.active_progress = False - self.logos = logos.LogosManager(app=self) self.tmp = "" + # Generic ask/response events/threads + self.ask_answer_queue: Queue[str] = Queue() + self.ask_answer_event = threading.Event() + # Queues self.main_thread = threading.Thread() - self.get_q = Queue() - self.get_e = threading.Event() - self.input_q = Queue() - self.input_e = threading.Event() - self.status_q = Queue() + self.status_q: Queue[str] = Queue() self.status_e = threading.Event() - self.progress_q = Queue() - self.progress_e = threading.Event() - self.todo_q = Queue() + self.todo_q: Queue[str] = Queue() self.todo_e = threading.Event() - self.screen_q = Queue() - self.choice_q = Queue() - self.switch_q = Queue() + self.screen_q: Queue[None] = Queue() + self.choice_q: Queue[str] = Queue() + self.switch_q: Queue[int] = Queue() # Install and Options - self.product_q = Queue() - self.product_e = threading.Event() - self.version_q = Queue() - self.version_e = threading.Event() - self.releases_q = Queue() - self.releases_e = threading.Event() - self.release_q = Queue() - self.release_e = threading.Event() - self.manualinstall_q = Queue() - self.manualinstall_e = threading.Event() - self.installdeps_q = Queue() - self.installdeps_e = threading.Event() - self.installdir_q = Queue() - self.installdir_e = threading.Event() - self.wines_q = Queue() - self.wine_e = threading.Event() - self.tricksbin_q = Queue() - self.tricksbin_e = threading.Event() - self.deps_q = Queue() - self.deps_e = threading.Event() - self.finished_q = Queue() - self.finished_e = threading.Event() - self.config_q = Queue() - self.config_e = threading.Event() - self.confirm_q = Queue() - self.confirm_e = threading.Event() - self.password_q = Queue() + self.password_q: Queue[str] = Queue() self.password_e = threading.Event() - self.appimage_q = Queue() + self.appimage_q: Queue[str] = Queue() self.appimage_e = threading.Event() - self.install_icu_q = Queue() - self.install_icu_e = threading.Event() - self.install_logos_q = Queue() - self.install_logos_e = threading.Event() + self._installer_thread: Optional[threading.Thread] = None + + self.terminal_margin = 0 + self.resizing = False + # These two are updated in set_window_dimensions + self.console_log_lines = 0 + self.options_per_page = 0 # Window and Screen Management - self.tui_screens = [] - self.menu_options = [] - self.window_height = self.window_width = self.console = self.menu_screen = self.active_screen = None - self.main_window_ratio = self.main_window_ratio = self.menu_window_ratio = self.main_window_min = None - self.menu_window_min = self.main_window_height = self.menu_window_height = self.main_window = None - self.menu_window = self.resize_window = None + self.tui_screens: list[tui_screen.Screen] = [] + self.menu_options: list[Any] = [] + + # Default height and width to something reasonable so these values are always + # ints, on each loop these values will be updated to their real values + self.window_height = self.window_width = 80 + self.main_window_height = self.menu_window_height = 80 + # Default to a value to allow for int type + self.main_window_min: int = 0 + self.menu_window_min: int = 0 + + self.menu_window_ratio: Optional[float] = None + self.main_window_ratio: Optional[float] = None + self.main_window_ratio = None + self.main_window: Optional[curses.window] = None + self.menu_window: Optional[curses.window] = None + self.resize_window: Optional[curses.window] = None + + # For menu dialogs. + # a new MenuDialog is created every loop, so we can't store it there. + self.current_option: int = 0 + self.current_page: int = 0 + self.total_pages: int = 0 + + # Start internal property variables, shouldn't be accessed directly, see their + # corresponding @property functions + self._menu_screen: Optional[tui_screen.MenuScreen] = None + self._active_screen: Optional[tui_screen.Screen] = None + self._console: Optional[tui_screen.ConsoleScreen] = None + # End internal property values + + # Lines for the on-screen console log + self.console_log: list[str] = [] + + # Note to reviewers: + # This does expose some possibly untested code paths + # + # Before some function calls didn't pass use_python_dialog falling back to False + # now it all respects use_python_dialog + # some menus may open in dialog that didn't before. + self.use_python_dialog: bool = False + if "dialog" in sys.modules and ephemeral_config.terminal_app_prefer_dialog is not False: #noqa: E501 + result = system.test_dialog_version() + + if result is None: + logging.debug( + "The 'dialog' package was not found. Falling back to Python Curses." + ) # noqa: E501 + elif result: + logging.debug("Dialog version is up-to-date.") + self.use_python_dialog = True + else: + logging.error( + "Dialog version is outdated. The program will fall back to Curses." + ) # noqa: E501 + # FIXME: remove this hard-coding after considering whether we want to continue + # to support both + self.use_python_dialog = False + + logging.debug(f"Use Python Dialog?: {self.use_python_dialog}") self.set_window_dimensions() + self.config_updated_hooks += [self._config_update_hook] + + def set_title(self): + self.title = f"Welcome to {constants.APP_NAME} {constants.LLI_CURRENT_VERSION} ({self.conf.app_release_channel})" # noqa: E501 + product_name = self.conf._raw.faithlife_product or constants.FAITHLIFE_PRODUCTS[0] #noqa: E501 + if self.is_installed(): + self.subtitle = f"{product_name} Version: {self.conf.installed_faithlife_product_release} ({self.conf.faithlife_product_release_channel})" # noqa: E501 + else: + self.subtitle = f"{product_name} not installed" + # Reset the console to force a re-draw + self._console = None + + @property + def active_screen(self) -> tui_screen.Screen: + if self._active_screen is None: + self._active_screen = self.menu_screen + if self._active_screen is None: + raise ValueError("Curses hasn't been initialized yet") + return self._active_screen + + @active_screen.setter + def active_screen(self, value: tui_screen.Screen): + self._active_screen = value + + @property + def menu_screen(self) -> tui_screen.MenuScreen: + if self._menu_screen is None: + self._menu_screen = tui_screen.MenuScreen( + self, + 0, + self.status_q, + self.status_e, + "Main Menu", + self.set_tui_menu_options(), + ) # noqa: E501 + return self._menu_screen + + @property + def console(self) -> tui_screen.ConsoleScreen: + if self._console is None: + self._console = tui_screen.ConsoleScreen( + self, 0, self.status_q, self.status_e, self.title, self.subtitle, 0 + ) # noqa: E501 + return self._console + + @property + def recent_console_log(self) -> list[str]: + """Outputs console log trimmed by the maximum length""" + return self.console_log[-self.console_log_lines:] + def set_window_dimensions(self): self.update_tty_dimensions() curses.resizeterm(self.window_height, self.window_width) self.main_window_ratio = 0.25 - if config.console_log: - min_console_height = len(tui_curses.wrap_text(self, config.console_log[-1])) + if self.console_log: + min_console_height = len(tui_curses.wrap_text(self, self.console_log[-1])) else: min_console_height = 2 - self.main_window_min = len(tui_curses.wrap_text(self, self.title)) + len( - tui_curses.wrap_text(self, self.subtitle)) + min_console_height + self.main_window_min = ( + len(tui_curses.wrap_text(self, self.title)) + + len(tui_curses.wrap_text(self, self.subtitle)) + + min_console_height + ) self.menu_window_ratio = 0.75 self.menu_window_min = 3 - self.main_window_height = max(int(self.window_height * self.main_window_ratio), self.main_window_min) - self.menu_window_height = max(self.window_height - self.main_window_height, int(self.window_height * self.menu_window_ratio), self.menu_window_min) - config.console_log_lines = max(self.main_window_height - self.main_window_min, 1) - config.options_per_page = max(self.window_height - self.main_window_height - 6, 1) + self.main_window_height = max( + int(self.window_height * self.main_window_ratio), self.main_window_min + ) # noqa: E501#noqa: E501 + self.menu_window_height = max( + self.window_height - self.main_window_height, + int(self.window_height * self.menu_window_ratio), + self.menu_window_min, + ) # noqa: E501 + self.console_log_lines = max(self.main_window_height - self.main_window_min, 1) + self.options_per_page = max(self.window_height - self.main_window_height - 6, 1) self.main_window = curses.newwin(self.main_window_height, curses.COLS, 0, 0) - self.menu_window = curses.newwin(self.menu_window_height, curses.COLS, self.main_window_height + 1, 0) + self.menu_window = curses.newwin( + self.menu_window_height, curses.COLS, self.main_window_height + 1, 0 + ) # noqa: E501 resize_lines = tui_curses.wrap_text(self, "Screen too small.") self.resize_window = curses.newwin(len(resize_lines) + 1, curses.COLS, 0, 0) @@ -123,9 +226,9 @@ def set_window_dimensions(self): def set_curses_style(): curses.start_color() curses.use_default_colors() - curses.init_color(curses.COLOR_BLUE, 0, 510, 1000) # Logos Blue - curses.init_color(curses.COLOR_CYAN, 906, 906, 906) # Logos Gray - curses.init_color(curses.COLOR_WHITE, 988, 988, 988) # Logos White + curses.init_color(curses.COLOR_BLUE, 0, 510, 1000) # Logos Blue + curses.init_color(curses.COLOR_CYAN, 906, 906, 906) # Logos Gray + curses.init_color(curses.COLOR_WHITE, 988, 988, 988) # Logos White curses.init_pair(1, curses.COLOR_BLUE, curses.COLOR_CYAN) curses.init_pair(2, curses.COLOR_BLUE, curses.COLOR_WHITE) curses.init_pair(3, curses.COLOR_CYAN, curses.COLOR_BLUE) @@ -134,78 +237,75 @@ def set_curses_style(): curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_WHITE) curses.init_pair(7, curses.COLOR_WHITE, curses.COLOR_BLACK) - def set_curses_colors_logos(self): - self.stdscr.bkgd(' ', curses.color_pair(3)) - self.main_window.bkgd(' ', curses.color_pair(3)) - self.menu_window.bkgd(' ', curses.color_pair(3)) - - def set_curses_colors_light(self): - self.stdscr.bkgd(' ', curses.color_pair(6)) - self.main_window.bkgd(' ', curses.color_pair(6)) - self.menu_window.bkgd(' ', curses.color_pair(6)) - - def set_curses_colors_dark(self): - self.stdscr.bkgd(' ', curses.color_pair(7)) - self.main_window.bkgd(' ', curses.color_pair(7)) - self.menu_window.bkgd(' ', curses.color_pair(7)) - - def change_color_scheme(self): - if config.curses_colors == "Logos": - config.curses_colors = "Light" - self.set_curses_colors_light() - elif config.curses_colors == "Light": - config.curses_colors = "Dark" - self.set_curses_colors_dark() - else: - config.curses_colors = "Logos" - config.curses_colors = "Logos" - self.set_curses_colors_logos() + def set_curses_colors(self): + if self.conf.curses_colors == "Logos": + self.stdscr.bkgd(" ", curses.color_pair(3)) + if self.main_window: + self.main_window.bkgd(" ", curses.color_pair(3)) + if self.menu_window: + self.menu_window.bkgd(" ", curses.color_pair(3)) + elif self.conf.curses_colors == "Light": + self.stdscr.bkgd(" ", curses.color_pair(6)) + if self.main_window: + self.main_window.bkgd(" ", curses.color_pair(6)) + if self.menu_window: + self.menu_window.bkgd(" ", curses.color_pair(6)) + elif self.conf.curses_colors == "Dark": + self.stdscr.bkgd(" ", curses.color_pair(7)) + if self.main_window: + self.main_window.bkgd(" ", curses.color_pair(7)) + if self.menu_window: + self.menu_window.bkgd(" ", curses.color_pair(7)) def update_windows(self): if isinstance(self.active_screen, tui_screen.CursesScreen): - self.main_window.erase() - self.menu_window.erase() + if self.main_window: + self.main_window.erase() + if self.menu_window: + self.menu_window.erase() self.stdscr.timeout(100) self.console.display() def clear(self): self.stdscr.clear() - self.main_window.clear() - self.menu_window.clear() - self.resize_window.clear() + if self.main_window: + self.main_window.clear() + if self.menu_window: + self.menu_window.clear() + if self.resize_window: + self.resize_window.clear() def refresh(self): - self.main_window.noutrefresh() - self.menu_window.noutrefresh() - self.resize_window.noutrefresh() + if self.main_window: + self.main_window.noutrefresh() + if self.menu_window: + self.menu_window.noutrefresh() + if self.resize_window: + self.resize_window.noutrefresh() curses.doupdate() def init_curses(self): try: if curses.has_colors(): - if config.curses_colors is None or config.curses_colors == "Logos": - config.curses_colors = "Logos" - self.set_curses_style() - self.set_curses_colors_logos() - elif config.curses_colors == "Light": - config.curses_colors = "Light" - self.set_curses_style() - self.set_curses_colors_light() - elif config.curses_colors == "Dark": - config.curses_colors = "Dark" - self.set_curses_style() - self.set_curses_colors_dark() + self.set_curses_style() + self.set_curses_colors() curses.curs_set(0) curses.noecho() curses.cbreak() self.stdscr.keypad(True) - self.console = tui_screen.ConsoleScreen(self, 0, self.status_q, self.status_e, self.title, self.subtitle, 0) - self.menu_screen = tui_screen.MenuScreen(self, 0, self.status_q, self.status_e, - "Main Menu", self.set_tui_menu_options(dialog=False)) - #self.menu_screen = tui_screen.MenuDialog(self, 0, self.status_q, self.status_e, "Main Menu", - # self.set_tui_menu_options(dialog=True)) + # Reset console/menu_screen. They'll be initialized next access + self._console = None + self._menu_screen = tui_screen.MenuScreen( + self, + 0, + self.status_q, + self.status_e, + "Main Menu", + self.set_tui_menu_options(), + ) # noqa: E501 + # self.menu_screen = tui_screen.MenuDialog(self, 0, self.status_q, self.status_e, "Main Menu", self.set_tui_menu_options(dialog=True)) #noqa: E501 self.refresh() except curses.error as e: logging.error(f"Curses error in init_curses: {e}") @@ -233,10 +333,11 @@ def end(self, signal, frame): def update_main_window_contents(self): self.clear() - self.title = f"Welcome to {config.name_app} {config.LLI_CURRENT_VERSION} ({config.lli_release_channel})" # noqa: E501 - self.subtitle = f"Logos Version: {config.current_logos_version} ({config.logos_release_channel})" # noqa: E501 - self.console = tui_screen.ConsoleScreen(self, 0, self.status_q, self.status_e, self.title, self.subtitle, 0) # noqa: E501 - self.menu_screen.set_options(self.set_tui_menu_options(dialog=False)) + self.title = f"Welcome to {constants.APP_NAME} {constants.LLI_CURRENT_VERSION} ({self.conf.app_release_channel})" # noqa: E501 + self.subtitle = f"Logos Version: {self.conf.installed_faithlife_product_release} ({self.conf.faithlife_product_release_channel})" # noqa: E501 + # Reset internal variable, it'll be reset next access + self._console = None + self.menu_screen.set_options(self.set_tui_menu_options()) # self.menu_screen.set_options(self.set_tui_menu_options(dialog=True)) self.switch_q.put(1) self.refresh() @@ -249,25 +350,28 @@ def update_main_window_contents(self): # even though the resize signal is sent. See tui_curses, line #251 and # tui_screen, line #98. def resize_curses(self): - config.resizing = True + self.resizing = True curses.endwin() self.update_tty_dimensions() self.set_window_dimensions() self.clear() self.init_curses() self.refresh() - msg.status("Window resized.", self) - config.resizing = False + logging.debug("Window resized.", self) + self.resizing = False def signal_resize(self, signum, frame): self.resize_curses() self.choice_q.put("resize") - if config.use_python_dialog: - if isinstance(self.active_screen, tui_screen.TextDialog) and self.active_screen.text == "Screen Too Small": + if self.use_python_dialog: + if ( + isinstance(self.active_screen, tui_screen.TextDialog) + and self.active_screen.text == "Screen Too Small" + ): self.choice_q.put("Return to Main Menu") else: - if self.active_screen.get_screen_id == 14: + if self.active_screen.screen_id == 14: self.update_tty_dimensions() if self.window_height > 9: self.switch_q.put(1) @@ -277,29 +381,42 @@ def signal_resize(self, signum, frame): def draw_resize_screen(self): self.clear() if self.window_width > 10: - margin = config.margin + margin = self.terminal_margin else: margin = 0 resize_lines = tui_curses.wrap_text(self, "Screen too small.") self.resize_window = curses.newwin(len(resize_lines) + 1, curses.COLS, 0, 0) for i, line in enumerate(resize_lines): if i < self.window_height: - tui_curses.write_line(self, self.resize_window, i, margin, line, self.window_width - config.margin, curses.A_BOLD) + tui_curses.write_line( + self, + self.resize_window, + i, + margin, + line, + self.window_width - self.terminal_margin, + curses.A_BOLD, + ) self.refresh() def display(self): signal.signal(signal.SIGWINCH, self.signal_resize) signal.signal(signal.SIGINT, self.end) msg.initialize_tui_logging() - msg.status(self.console_message, self) + + # Makes sure status stays shown + timestamp = utils.get_timestamp() + self.status_q.put(f"{timestamp} {self.console_message}") + self.report_waiting(f"{self.console_message}") # noqa: E501 + self.active_screen = self.menu_screen last_time = time.time() self.logos.monitor() while self.llirunning: if self.window_height >= 10 and self.window_width >= 35: - config.margin = 2 - if not config.resizing: + self.terminal_margin = 2 + if not self.resizing: self.update_windows() self.active_screen.display() @@ -307,8 +424,9 @@ def display(self): if self.choice_q.qsize() > 0: self.choice_processor( self.menu_window, - self.active_screen.get_screen_id(), - self.choice_q.get()) + self.active_screen.screen_id, + self.choice_q.get(), + ) if self.screen_q.qsize() > 0: self.screen_q.get() @@ -316,7 +434,7 @@ def display(self): if self.switch_q.qsize() > 0: self.switch_q.get() - self.switch_screen(config.use_python_dialog) + self.switch_screen() if len(self.tui_screens) == 0: self.active_screen = self.menu_screen @@ -327,16 +445,17 @@ def display(self): run_monitor, last_time = utils.stopwatch(last_time, 2.5) if run_monitor: self.logos.monitor() - self.task_processor(self, task="PID") + self.menu_screen.set_options(self.set_tui_menu_options()) if isinstance(self.active_screen, tui_screen.CursesScreen): self.refresh() elif self.window_width >= 10: if self.window_width < 10: - config.margin = 1 # Avoid drawing errors on very small screens + # Avoid drawing errors on very small screens + self.terminal_margin = 1 self.draw_resize_screen() elif self.window_width < 10: - config.margin = 0 # Avoid drawing errors on very small screens + self.terminal_margin = 0 # Avoid drawing errors on very small screens def run(self): try: @@ -349,107 +468,102 @@ def run(self): self.end_curses() signal.signal(signal.SIGINT, self.end) - def task_processor(self, evt=None, task=None): - if task == 'FLPRODUCT': - utils.start_thread(self.get_product, config.use_python_dialog) - elif task == 'TARGETVERSION': - utils.start_thread(self.get_version, config.use_python_dialog) - elif task == 'TARGET_RELEASE_VERSION': - utils.start_thread(self.get_release, config.use_python_dialog) - elif task == 'INSTALLDIR': - utils.start_thread(self.get_installdir, config.use_python_dialog) - elif task == 'WINE_EXE': - utils.start_thread(self.get_wine, config.use_python_dialog) - elif task == 'WINETRICKSBIN': - utils.start_thread(self.get_winetricksbin, config.use_python_dialog) - elif task == 'INSTALL' or task == 'INSTALLING': - utils.start_thread(self.get_waiting, config.use_python_dialog) - elif task == 'INSTALLING_PW': - utils.start_thread(self.get_waiting, config.use_python_dialog, screen_id=15) - elif task == 'CONFIG': - utils.start_thread(self.get_config, config.use_python_dialog) - elif task == 'DONE': - self.update_main_window_contents() - elif task == 'PID': - self.menu_screen.set_options(self.set_tui_menu_options(dialog=False)) + def installing_pw_waiting(self): + # self.start_thread(self.get_waiting, screen_id=15) + pass def choice_processor(self, stdscr, screen_id, choice): screen_actions = { 0: self.main_menu_select, 1: self.custom_appimage_select, - 2: self.product_select, - 3: self.version_select, - 4: self.release_select, - 5: self.installdir_select, - 6: self.wine_select, - 7: self.winetricksbin_select, + 2: self.handle_ask_response, 8: self.waiting, - 9: self.config_update_select, 10: self.waiting_releases, 11: self.winetricks_menu_select, 12: self.logos.start, 13: self.waiting_finish, 14: self.waiting_resize, 15: self.password_prompt, - 16: self.install_dependencies_confirm, - 17: self.manual_install_confirm, 18: self.utilities_menu_select, 19: self.renderer_select, 20: self.win_ver_logos_select, 21: self.win_ver_index_select, - 22: self.verify_backup_path, - 23: self.use_backup_path, 24: self.confirm_restore_dir, - 25: self.choose_restore_dir + 25: self.choose_restore_dir, } # Capture menu exiting before processing in the rest of the handler - if screen_id != 0 and (choice == "Return to Main Menu" or choice == "Exit"): + if screen_id not in [0, 2] and (choice in ["Return to Main Menu", "Exit"]): + if choice == "Return to Main Menu": + self.tui_screens = [] self.reset_screen() self.switch_q.put(1) - #FIXME: There is some kind of graphical glitch that activates on returning to Main Menu, - # but not from all submenus. - # Further, there appear to be issues with how the program exits on Ctrl+C as part of this. + # FIXME: There is some kind of graphical glitch that activates on returning + # to Main Menu, but not from all submenus. + # Further, there appear to be issues with how the program exits on Ctrl+C as + # part of this. else: action = screen_actions.get(screen_id) if action: - action(choice) + # Start the action in a new thread to not interrupt the input thread + self.start_thread( + action, + choice, + daemon_bool=False, + ) else: pass def reset_screen(self): self.active_screen.running = 0 self.active_screen.choice = "Processing" + self.current_option = 0 + self.current_page = 0 + self.total_pages = 0 def go_to_main_menu(self): + self.reset_screen() self.menu_screen.choice = "Processing" self.choice_q.put("Return to Main Menu") def main_menu_select(self, choice): + def _install(): + try: + installer.install(app=self) + self.go_to_main_menu() + except ReturningToMainMenu: + pass if choice is None or choice == "Exit": - msg.logos_warn("Exiting installation.") + logging.info("Exiting installation.") self.tui_screens = [] self.llirunning = False elif choice.startswith("Install"): self.reset_screen() - config.INSTALL_STEPS_COUNT = 0 - config.INSTALL_STEP = 0 - utils.start_thread( - installer.ensure_launcher_shortcuts, + self.installer_step = 0 + self.installer_step_count = 0 + if self._installer_thread is not None: + # The install thread should have completed with ReturningToMainMenu + # Check just in case + if self._installer_thread.is_alive(): + raise Exception("Previous install is still running") + # Reset user choices and try again! + self.conf.faithlife_product = None # type: ignore[assignment] + self._installer_thread = self.start_thread( + _install, daemon_bool=True, - app=self, ) - elif choice.startswith(f"Update {config.name_app}"): - utils.update_to_latest_lli_release() - elif choice == f"Run {config.FLPRODUCT}": + + elif choice.startswith(f"Update {constants.APP_NAME}"): + utils.update_to_latest_lli_release(self) + elif self.conf._raw.faithlife_product and choice == f"Run {self.conf._raw.faithlife_product}": #noqa: E501 self.reset_screen() self.logos.start() - self.menu_screen.set_options(self.set_tui_menu_options(dialog=False)) + self.menu_screen.set_options(self.set_tui_menu_options()) self.switch_q.put(1) - elif choice == f"Stop {config.FLPRODUCT}": + elif self.conf._raw.faithlife_product and choice == f"Stop {self.conf.faithlife_product}": #noqa: E501 self.reset_screen() self.logos.stop() - self.menu_screen.set_options(self.set_tui_menu_options(dialog=False)) + self.menu_screen.set_options(self.set_tui_menu_options()) self.switch_q.put(1) elif choice == "Run Indexing": self.active_screen.running = 0 @@ -458,202 +572,169 @@ def main_menu_select(self, choice): elif choice == "Remove Library Catalog": self.active_screen.running = 0 self.active_screen.choice = "Processing" - control.remove_library_catalog() + control.remove_library_catalog(self) elif choice.startswith("Winetricks"): self.reset_screen() - self.screen_q.put(self.stack_menu(11, self.todo_q, self.todo_e, "Winetricks Menu", - self.set_winetricks_menu_options(), dialog=config.use_python_dialog)) - self.choice_q.put("0") + self.screen_q.put( + self.stack_menu( + 11, + self.todo_q, + self.todo_e, + "Winetricks Menu", + self.set_winetricks_menu_options(), + ) + ) # noqa: E501 elif choice.startswith("Utilities"): self.reset_screen() - self.screen_q.put(self.stack_menu(18, self.todo_q, self.todo_e, "Utilities Menu", - self.set_utilities_menu_options(), dialog=config.use_python_dialog)) - self.choice_q.put("0") + self.screen_q.put( + self.stack_menu( + 18, + self.todo_q, + self.todo_e, + "Utilities Menu", + self.set_utilities_menu_options(), + ) + ) # noqa: E501 elif choice == "Change Color Scheme": - self.change_color_scheme() - msg.status("Changing color scheme", self) - self.reset_screen() - utils.write_config(config.CONFIG_FILE) + self.status("Changing color scheme") + self.conf.cycle_curses_color_scheme() + self.go_to_main_menu() def winetricks_menu_select(self, choice): if choice == "Download or Update Winetricks": self.reset_screen() - control.set_winetricks() + control.set_winetricks(self) self.go_to_main_menu() elif choice == "Run Winetricks": self.reset_screen() - wine.run_winetricks() + self.status("Running winetricks…") + wine.run_winetricks(self) self.go_to_main_menu() elif choice == "Install d3dcompiler": self.reset_screen() - wine.install_d3d_compiler() + self.status("Installing d3dcompiler…") + wine.install_d3d_compiler(self) + self.go_to_main_menu() elif choice == "Install Fonts": self.reset_screen() - wine.install_fonts() + wine.install_fonts(self) self.go_to_main_menu() elif choice == "Set Renderer": self.reset_screen() - self.screen_q.put(self.stack_menu(19, self.todo_q, self.todo_e, - "Choose Renderer", - self.set_renderer_menu_options(), - dialog=config.use_python_dialog)) + self.screen_q.put( + self.stack_menu( + 19, + self.todo_q, + self.todo_e, + "Choose Renderer", + self.set_renderer_menu_options(), + ) + ) self.choice_q.put("0") elif choice == "Set Windows Version for Logos": self.reset_screen() - self.screen_q.put(self.stack_menu(20, self.todo_q, self.todo_e, - "Set Windows Version for Logos", - self.set_win_ver_menu_options(), - dialog=config.use_python_dialog)) + self.screen_q.put( + self.stack_menu( + 20, + self.todo_q, + self.todo_e, + "Set Windows Version for Logos", + self.set_win_ver_menu_options(), + ) + ) self.choice_q.put("0") elif choice == "Set Windows Version for Indexer": self.reset_screen() - self.screen_q.put(self.stack_menu(21, self.todo_q, self.todo_e, - "Set Windows Version for Indexer", - self.set_win_ver_menu_options(), - dialog=config.use_python_dialog)) + self.screen_q.put( + self.stack_menu( + 21, + self.todo_q, + self.todo_e, + "Set Windows Version for Indexer", + self.set_win_ver_menu_options(), + ) + ) self.choice_q.put("0") def utilities_menu_select(self, choice): if choice == "Remove Library Catalog": self.reset_screen() - control.remove_library_catalog() + control.remove_library_catalog(self) self.go_to_main_menu() elif choice == "Remove All Index Files": self.reset_screen() - control.remove_all_index_files() + control.remove_all_index_files(self) self.go_to_main_menu() elif choice == "Edit Config": self.reset_screen() - control.edit_config() + control.edit_file(self.conf.config_file_path) + self.go_to_main_menu() + elif choice == "Reload Config": + self.conf.reload() self.go_to_main_menu() elif choice == "Change Logos Release Channel": self.reset_screen() - utils.change_logos_release_channel() + self.conf.toggle_faithlife_product_release_channel() self.update_main_window_contents() self.go_to_main_menu() - elif choice == f"Change {config.name_app} Release Channel": + elif choice == f"Change {constants.APP_NAME} Release Channel": self.reset_screen() - utils.change_lli_release_channel() - network.set_logoslinuxinstaller_latest_release_config() + self.conf.toggle_installer_release_channel() self.update_main_window_contents() self.go_to_main_menu() elif choice == "Install Dependencies": self.reset_screen() - msg.status("Checking dependencies…", self) self.update_windows() utils.install_dependencies(self) self.go_to_main_menu() elif choice == "Back Up Data": self.reset_screen() - self.get_backup_path(mode="backup") - utils.start_thread(self.do_backup) + self.start_thread(self.do_backup) elif choice == "Restore Data": self.reset_screen() - self.get_backup_path(mode="restore") - utils.start_thread(self.do_backup) + self.start_thread(self.do_backup) elif choice == "Update to Latest AppImage": self.reset_screen() - utils.update_to_latest_recommended_appimage() + utils.update_to_latest_recommended_appimage(self) self.go_to_main_menu() elif choice == "Set AppImage": # TODO: Allow specifying the AppImage File - appimages = utils.find_appimage_files(utils.which_release()) - appimage_choices = [["AppImage", filename, "AppImage of Wine64"] for filename in - appimages] # noqa: E501 + appimages = self.conf.wine_app_image_files + # NOTE to reviewer: is this logic correct? + appimage_choices = appimages appimage_choices.extend(["Input Custom AppImage", "Return to Main Menu"]) self.menu_options = appimage_choices question = "Which AppImage should be used?" - self.screen_q.put(self.stack_menu(1, self.appimage_q, self.appimage_e, question, appimage_choices)) + self.screen_q.put( + self.stack_menu( + 1, self.appimage_q, self.appimage_e, question, appimage_choices + ) + ) # noqa: E501 elif choice == "Install ICU": self.reset_screen() - wine.enforce_icu_data_files() + wine.enforce_icu_data_files(self) self.go_to_main_menu() elif choice.endswith("Logging"): self.reset_screen() - wine.switch_logging() + self.logos.switch_logging() self.go_to_main_menu() - def custom_appimage_select(self, choice): - #FIXME + def custom_appimage_select(self, choice: str): if choice == "Input Custom AppImage": - appimage_filename = tui_curses.get_user_input(self, "Enter AppImage filename: ", "") + appimage_filename = self.ask("Enter AppImage filename: ", [PROMPT_OPTION_FILE]) #noqa: E501 else: appimage_filename = choice - config.SELECTED_APPIMAGE_FILENAME = appimage_filename - utils.set_appimage_symlink() + self.conf.wine_appimage_path = Path(appimage_filename) + utils.set_appimage_symlink(self) + if not self.menu_window: + raise ValueError("Curses hasn't been initialized") self.menu_screen.choice = "Processing" - self.appimage_q.put(config.SELECTED_APPIMAGE_FILENAME) + self.appimage_q.put(str(self.conf.wine_appimage_path)) self.appimage_e.set() - def product_select(self, choice): - if choice: - if str(choice).startswith("Logos"): - config.FLPRODUCT = "Logos" - elif str(choice).startswith("Verbum"): - config.FLPRODUCT = "Verbum" - self.menu_screen.choice = "Processing" - self.product_q.put(config.FLPRODUCT) - self.product_e.set() - - def version_select(self, choice): - if choice: - if "10" in choice: - config.TARGETVERSION = "10" - elif "9" in choice: - config.TARGETVERSION = "9" - self.menu_screen.choice = "Processing" - self.version_q.put(config.TARGETVERSION) - self.version_e.set() - - def release_select(self, choice): - if choice: - config.TARGET_RELEASE_VERSION = choice - self.menu_screen.choice = "Processing" - self.release_q.put(config.TARGET_RELEASE_VERSION) - self.release_e.set() - - def installdir_select(self, choice): - if choice: - config.INSTALLDIR = choice - config.APPDIR_BINDIR = f"{config.INSTALLDIR}/data/bin" - self.menu_screen.choice = "Processing" - self.installdir_q.put(config.INSTALLDIR) - self.installdir_e.set() - - def wine_select(self, choice): - config.WINE_EXE = choice - if choice: - self.menu_screen.choice = "Processing" - self.wines_q.put(config.WINE_EXE) - self.wine_e.set() - - def winetricksbin_select(self, choice): - winetricks_options = utils.get_winetricks_options() - if choice.startswith("Download"): - self.menu_screen.choice = "Processing" - self.tricksbin_q.put("Download") - self.tricksbin_e.set() - else: - self.menu_screen.choice = "Processing" - config.WINETRICKSBIN = winetricks_options[0] - self.tricksbin_q.put(config.WINETRICKSBIN) - self.tricksbin_e.set() - def waiting(self, choice): pass - def config_update_select(self, choice): - if choice: - if choice == "Yes": - msg.status("Updating config file.", self) - utils.write_config(config.CONFIG_FILE) - else: - msg.status("Config file left unchanged.", self) - self.menu_screen.choice = "Processing" - self.config_q.put(True) - self.config_e.set() - self.screen_q.put(self.stack_text(13, self.todo_q, self.todo_e, "Finishing install…", dialog=config.use_python_dialog)) - def waiting_releases(self, choice): pass @@ -669,49 +750,36 @@ def password_prompt(self, choice): self.password_q.put(choice) self.password_e.set() - def install_dependencies_confirm(self, choice): - if choice: - if choice == "No": - self.go_to_main_menu() - else: - self.menu_screen.choice = "Processing" - self.confirm_e.set() - self.screen_q.put(self.stack_text(13, self.todo_q, self.todo_e, - "Installing dependencies…\n", wait=True, - dialog=config.use_python_dialog)) - def renderer_select(self, choice): if choice in ["gdi", "gl", "vulkan"]: self.reset_screen() - wine.set_renderer(choice) - msg.status(f"Changed renderer to {choice}.", self) + self.status(f"Changing renderer to {choice}.", 0) + wine.set_renderer(self, choice) + self.status(f"Changed renderer to {choice}.", 100) self.go_to_main_menu() def win_ver_logos_select(self, choice): if choice in ["vista", "win7", "win8", "win10", "win11"]: self.reset_screen() - wine.set_win_version("logos", choice) - msg.status(f"Changed Windows version for Logos to {choice}.", self) + self.status(f"Changing Windows version for Logos to {choice}.", 0) + wine.set_win_version(self, "logos", choice) + self.status(f"Changed Windows version for Logos to {choice}.", 100) self.go_to_main_menu() def win_ver_index_select(self, choice): if choice in ["vista", "win7", "win8", "win10", "win11"]: self.reset_screen() - wine.set_win_version("indexer", choice) - msg.status(f"Changed Windows version for Indexer to {choice}.", self) + self.status(f"Changing Windows version for Indexer to {choice}.", 0) + wine.set_win_version(self, "indexer", choice) + self.status(f"Changed Windows version for Indexer to {choice}.", 100) self.go_to_main_menu() - def manual_install_confirm(self, choice): - if choice: - if choice == "Continue": - self.menu_screen.choice = "Processing" - self.manualinstall_e.set() - self.screen_q.put(self.stack_text(13, self.todo_q, self.todo_e, - "Installing dependencies…\n", wait=True, - dialog=config.use_python_dialog)) - - def switch_screen(self, dialog): - if self.active_screen is not None and self.active_screen != self.menu_screen and len(self.tui_screens) > 0: + def switch_screen(self): + if ( + self.active_screen is not None + and self.active_screen != self.menu_screen + and len(self.tui_screens) > 0 + ): # noqa: E501 self.tui_screens.pop(0) if self.active_screen == self.menu_screen: self.menu_screen.choice = "Processing" @@ -719,165 +787,82 @@ def switch_screen(self, dialog): if isinstance(self.active_screen, tui_screen.CursesScreen): self.clear() - def get_product(self, dialog): - question = "Choose which FaithLife product the script should install:" # noqa: E501 - labels = ["Logos", "Verbum", "Return to Main Menu"] - options = self.which_dialog_options(labels, dialog) - self.menu_options = options - self.screen_q.put(self.stack_menu(2, self.product_q, self.product_e, question, options, dialog=dialog)) - - def set_product(self, choice): - if str(choice).startswith("Logos"): - config.FLPRODUCT = "Logos" - elif str(choice).startswith("Verbum"): - config.FLPRODUCT = "Verbum" - self.menu_screen.choice = "Processing" - self.product_q.put(config.FLPRODUCT) - self.product_e.set() - - def get_version(self, dialog): - self.product_e.wait() - question = f"Which version of {config.FLPRODUCT} should the script install?" # noqa: E501 - labels = ["10", "9", "Return to Main Menu"] - options = self.which_dialog_options(labels, dialog) - self.menu_options = options - self.screen_q.put(self.stack_menu(3, self.version_q, self.version_e, question, options, dialog=dialog)) - - def set_version(self, choice): - if "10" in choice: - config.TARGETVERSION = "10" - elif "9" in choice: - config.TARGETVERSION = "9" - self.menu_screen.choice = "Processing" - self.version_q.put(config.TARGETVERSION) - self.version_e.set() - - def get_release(self, dialog): - labels = [] - self.screen_q.put(self.stack_text(10, self.version_q, self.version_e, "Waiting to acquire Logos versions…", wait=True, dialog=dialog)) - self.version_e.wait() - question = f"Which version of {config.FLPRODUCT} {config.TARGETVERSION} do you want to install?" # noqa: E501 - utils.start_thread(network.get_logos_releases, daemon_bool=True, app=self) - self.releases_e.wait() - - labels = self.releases_q.get() - - if labels is None: - msg.logos_error("Failed to fetch TARGET_RELEASE_VERSION.") - labels.append("Return to Main Menu") - options = self.which_dialog_options(labels, dialog) - self.menu_options = options - self.screen_q.put(self.stack_menu(4, self.release_q, self.release_e, question, options, dialog=dialog)) - - def set_release(self, choice): - config.TARGET_RELEASE_VERSION = choice - self.menu_screen.choice = "Processing" - self.release_q.put(config.TARGET_RELEASE_VERSION) - self.release_e.set() - - def get_installdir(self, dialog): - self.release_e.wait() - default = f"{str(Path.home())}/{config.FLPRODUCT}Bible{config.TARGETVERSION}" # noqa: E501 - question = f"Where should {config.FLPRODUCT} files be installed to? [{default}]: " # noqa: E501 - self.screen_q.put(self.stack_input(5, self.installdir_q, self.installdir_e, question, default, dialog=dialog)) - - def set_installdir(self, choice): - config.INSTALLDIR = choice - config.APPDIR_BINDIR = f"{config.INSTALLDIR}/data/bin" - self.menu_screen.choice = "Processing" - self.installdir_q.put(config.INSTALLDIR) - self.installdir_e.set() - - def get_wine(self, dialog): - self.installdir_e.wait() - self.screen_q.put(self.stack_text(10, self.wines_q, self.wine_e, "Waiting to acquire available Wine binaries…", wait=True, dialog=dialog)) - question = f"Which Wine AppImage or binary should the script use to install {config.FLPRODUCT} v{config.TARGET_RELEASE_VERSION} in {config.INSTALLDIR}?" # noqa: E501 - labels = utils.get_wine_options( - utils.find_appimage_files(config.TARGET_RELEASE_VERSION), - utils.find_wine_binary_files(config.TARGET_RELEASE_VERSION) + _exit_option = "Return to Main Menu" + + def _ask(self, question: str, options: list[str] | str) -> Optional[str]: + self.ask_answer_event.clear() + if isinstance(options, str): + answer = options + elif isinstance(options, list): + self.menu_options = self.which_dialog_options(options) + self.screen_q.put( + self.stack_menu( + 2, Queue(), threading.Event(), question, self.menu_options + ) + ) # noqa: E501 + + # Now wait for it to complete. + self.ask_answer_event.wait() + answer = self.ask_answer_queue.get() + + self.ask_answer_event.clear() + if answer in [PROMPT_OPTION_DIRECTORY, PROMPT_OPTION_FILE]: + self.screen_q.put( + self.stack_input( + 2, + Queue(), + threading.Event(), + question, + os.path.expanduser("~/"), + ) + ) # noqa: E501 + # Now wait for it to complete + self.ask_answer_event.wait() + new_answer = self.ask_answer_queue.get() + if answer == PROMPT_OPTION_DIRECTORY: + # Make the directory if it doesn't exit. + # form a terminal UI, it's not easy for the user to manually + os.makedirs(new_answer, exist_ok=True) + + answer = new_answer + + if answer == self._exit_option: + self.tui_screens = [] + self.reset_screen() + self.switch_q.put(1) + raise ReturningToMainMenu + + return answer + + def handle_ask_response(self, choice: str): + self.ask_answer_queue.put(choice) + self.ask_answer_event.set() + + def _status(self, message: str, percent: int | None = None): + message = message.lstrip("\r") + if self.console_log[-1] == message: + return + self.console_log.append(message) + self.screen_q.put( + self.stack_text( + 8, + self.status_q, + self.status_e, + message, + wait=True, + percent=percent or 0, + ) ) - labels.append("Return to Main Menu") - max_length = max(len(label) for label in labels) - max_length += len(str(len(labels))) + 10 - options = self.which_dialog_options(labels, dialog) - self.menu_options = options - self.screen_q.put(self.stack_menu(6, self.wines_q, self.wine_e, question, options, width=max_length, dialog=dialog)) - - def set_wine(self, choice): - self.wines_q.put(utils.get_relative_path(utils.get_config_var(choice), config.INSTALLDIR)) - self.menu_screen.choice = "Processing" - self.wine_e.set() - - def get_winetricksbin(self, dialog): - self.wine_e.wait() - winetricks_options = utils.get_winetricks_options() - question = f"Should the script use the system's local winetricks or download the latest winetricks from the Internet? The script needs to set some Wine options that {config.FLPRODUCT} requires on Linux." # noqa: E501 - options = self.which_dialog_options(winetricks_options, dialog) - self.menu_options = options - self.screen_q.put(self.stack_menu(7, self.tricksbin_q, self.tricksbin_e, question, options, dialog=dialog)) - - def set_winetricksbin(self, choice): - if choice.startswith("Download"): - self.tricksbin_q.put("Download") - else: - winetricks_options = utils.get_winetricks_options() - self.tricksbin_q.put(winetricks_options[0]) - self.menu_screen.choice = "Processing" - self.tricksbin_e.set() - - def get_waiting(self, dialog, screen_id=8): - text = ["Install is running…\n"] - processed_text = utils.str_array_to_string(text) - percent = installer.get_progress_pct(config.INSTALL_STEP, config.INSTALL_STEPS_COUNT) - self.screen_q.put(self.stack_text(screen_id, self.status_q, self.status_e, processed_text, - wait=True, percent=percent, dialog=dialog)) - - def get_config(self, dialog): - question = f"Update config file at {config.CONFIG_FILE}?" - labels = ["Yes", "No"] - options = self.which_dialog_options(labels, dialog) - self.menu_options = options - #TODO: Switch to msg.logos_continue_message - self.screen_q.put(self.stack_menu(9, self.config_q, self.config_e, question, options, dialog=dialog)) + + def _config_update_hook(self): + self.update_main_window_contents() + self.set_curses_colors() + self.set_title() # def get_password(self, dialog): # question = (f"Logos Linux Installer needs to run a command as root. " # f"Please provide your password to provide escalation privileges.") - # self.screen_q.put(self.stack_password(15, self.password_q, self.password_e, question, dialog=dialog)) - - def get_backup_path(self, mode): - self.tmp = mode - if config.BACKUPDIR is None or not Path(config.BACKUPDIR).is_dir(): - if config.BACKUPDIR is None: - question = "Please provide a backups folder path:" - else: - question = f"Current backups folder path \"{config.BACKUPDIR}\" is invalid. Please provide a new one:" - self.screen_q.put(self.stack_input(22, self.todo_q, self.todo_e, question, - os.path.expanduser("~/Backups"), dialog=config.use_python_dialog)) - else: - verb = 'Use' if mode == 'backup' else 'Restore backup from' - question = f"{verb} backup from existing backups folder \"{config.BACKUPDIR}\"?" - self.screen_q.put(self.stack_confirm(23, self.todo_q, self.todo_e, question, "", - "", dialog=config.use_python_dialog)) - - def verify_backup_path(self, choice): - if choice: - if not Path(choice).is_dir(): - msg.status(f"Not a valid folder path: {choice}. Try again.", app=self) - question = "Please provide a different backups folder path:" - self.screen_q.put(self.stack_input(22, self.todo_q, self.todo_e, question, - os.path.expanduser("~/Backups"), dialog=config.use_python_dialog)) - else: - config.BACKUPDIR = choice - self.todo_e.set() - - def use_backup_path(self, choice): - if choice == "No": - question = "Please provide a new backups folder path:" - self.screen_q.put(self.stack_input(22, self.todo_q, self.todo_e, question, - os.path.expanduser(f"{config.BACKUPDIR}"), dialog=config.use_python_dialog)) - else: - self.todo_e.set() + # self.screen_q.put(self.stack_password(15, self.password_q, self.password_e, question, dialog=dialog)) #noqa: E501 def confirm_restore_dir(self, choice): if choice: @@ -895,79 +880,72 @@ def choose_restore_dir(self, choice): def do_backup(self): self.todo_e.wait() self.todo_e.clear() - if self.tmp == 'backup': + if self.tmp == "backup": control.backup(self) else: control.restore(self) self.go_to_main_menu() - def report_waiting(self, text, dialog): - #self.screen_q.put(self.stack_text(10, self.status_q, self.status_e, text, wait=True, dialog=dialog)) - config.console_log.append(text) + def report_waiting(self, text): + # self.screen_q.put(self.stack_text(10, self.status_q, self.status_e, text, wait=True, dialog=dialog)) #noqa: E501 + self.console_log.append(text) - def which_dialog_options(self, labels, dialog=False): - options = [] + def which_dialog_options(self, labels: list[str]) -> list[Any]: #noqa: E501 + # curses - list[str] + # dialog - list[tuple[str, str]] + options: list[Any] = [] option_number = 1 for label in labels: - if dialog: + if self.use_python_dialog: options.append((str(option_number), label)) option_number += 1 else: options.append(label) return options - def set_tui_menu_options(self, dialog=False): + def set_tui_menu_options(self): labels = [] - if config.LLI_LATEST_VERSION and system.get_runmode() == 'binary': - status = config.logos_linux_installer_status - error_message = config.logos_linux_installer_status_info.get(status) # noqa: E501 - if status == 0: - labels.append(f"Update {config.name_app}") - elif status == 1: + if system.get_runmode() == "binary": + status = utils.compare_logos_linux_installer_version(self) + if status == utils.VersionComparison.OUT_OF_DATE: + labels.append(f"Update {constants.APP_NAME}") + elif status == utils.VersionComparison.UP_TO_DATE: # logging.debug("Logos Linux Installer is up-to-date.") pass - elif status == 2: + elif status == utils.VersionComparison.DEVELOPMENT: # logging.debug("Logos Linux Installer is newer than the latest release.") # noqa: E501 pass else: - logging.error(f"{error_message}") + logging.error(f"Unknown result: {status}") - if utils.app_is_installed(): + if self.is_installed(): if self.logos.logos_state in [logos.State.STARTING, logos.State.RUNNING]: # noqa: E501 - run = f"Stop {config.FLPRODUCT}" + run = f"Stop {self.conf.faithlife_product}" elif self.logos.logos_state in [logos.State.STOPPING, logos.State.STOPPED]: # noqa: E501 - run = f"Run {config.FLPRODUCT}" + run = f"Run {self.conf.faithlife_product}" if self.logos.indexing_state == logos.State.RUNNING: indexing = "Stop Indexing" elif self.logos.indexing_state == logos.State.STOPPED: indexing = "Run Indexing" - labels_default = [ - run, - indexing - ] + labels_default = [run, indexing] else: labels_default = ["Install Logos Bible Software"] labels.extend(labels_default) - labels_support = [ - "Utilities →", - "Winetricks →" - ] + labels_support = ["Utilities →", "Winetricks →"] labels.extend(labels_support) - labels_options = [ - "Change Color Scheme" - ] + labels_options = ["Change Color Scheme"] labels.extend(labels_options) labels.append("Exit") - options = self.which_dialog_options(labels, dialog=False) + options = self.which_dialog_options(labels) return options - def set_winetricks_menu_options(self, dialog=False): + def set_winetricks_menu_options(self): labels = [] labels_support = [ "Download or Update Winetricks", @@ -976,151 +954,271 @@ def set_winetricks_menu_options(self, dialog=False): "Install Fonts", "Set Renderer", "Set Windows Version for Logos", - "Set Windows Version for Indexer" + "Set Windows Version for Indexer", ] labels.extend(labels_support) labels.append("Return to Main Menu") - options = self.which_dialog_options(labels, dialog=False) + options = self.which_dialog_options(labels) return options - def set_renderer_menu_options(self, dialog=False): + def set_renderer_menu_options(self): labels = [] - labels_support = [ - "gdi", - "gl", - "vulkan" - ] + labels_support = ["gdi", "gl", "vulkan"] labels.extend(labels_support) labels.append("Return to Main Menu") - options = self.which_dialog_options(labels, dialog=False) + options = self.which_dialog_options(labels) return options - def set_win_ver_menu_options(self, dialog=False): + def set_win_ver_menu_options(self): labels = [] - labels_support = [ - "vista", - "win7", - "win8", - "win10", - "win11" - ] + labels_support = ["vista", "win7", "win8", "win10", "win11"] labels.extend(labels_support) labels.append("Return to Main Menu") - options = self.which_dialog_options(labels, dialog=False) + options = self.which_dialog_options(labels) return options - def set_utilities_menu_options(self, dialog=False): + def set_utilities_menu_options(self): labels = [] - if utils.file_exists(config.LOGOS_EXE): + if self.is_installed(): labels_catalog = [ - "Remove Library Catalog", - "Remove All Index Files", - "Install ICU" + "Remove Library Catalog", + "Remove All Index Files", + "Install ICU", ] labels.extend(labels_catalog) - labels_utilities = [ - "Install Dependencies", - "Edit Config" - ] + labels_utilities = ["Install Dependencies", "Edit Config", "Reload Config"] labels.extend(labels_utilities) - if utils.file_exists(config.LOGOS_EXE): + if self.is_installed(): labels_utils_installed = [ "Change Logos Release Channel", - f"Change {config.name_app} Release Channel", + f"Change {constants.APP_NAME} Release Channel", # "Back Up Data", # "Restore Data" ] labels.extend(labels_utils_installed) - label = "Enable Logging" if config.LOGS == "DISABLED" else "Disable Logging" + label = ( + "Enable Logging" + if self.conf.faithlife_product_logging + else "Disable Logging" + ) # noqa: E501 labels.append(label) labels.append("Return to Main Menu") - options = self.which_dialog_options(labels, dialog=False) + options = self.which_dialog_options(labels) return options - def stack_menu(self, screen_id, queue, event, question, options, height=None, width=None, menu_height=8, dialog=False): - if dialog: - utils.append_unique(self.tui_screens, - tui_screen.MenuDialog(self, screen_id, queue, event, question, options, - height, width, menu_height)) + def stack_menu( + self, + screen_id, + queue, + event, + question, + options, + height=None, + width=None, + menu_height=8, + ): # noqa: E501 + if self.use_python_dialog: + utils.append_unique( + self.tui_screens, + tui_screen.MenuDialog( + self, + screen_id, + queue, + event, + question, + options, + height, + width, + menu_height, + ), + ) # noqa: E501 else: - utils.append_unique(self.tui_screens, - tui_screen.MenuScreen(self, screen_id, queue, event, question, options, - height, width, menu_height)) - - def stack_input(self, screen_id, queue, event, question, default, dialog=False): - if dialog: - utils.append_unique(self.tui_screens, - tui_screen.InputDialog(self, screen_id, queue, event, question, default)) + utils.append_unique( + self.tui_screens, + tui_screen.MenuScreen( + self, + screen_id, + queue, + event, + question, + options, + height, + width, + menu_height, + ), + ) # noqa: E501 + + def stack_input(self, screen_id, queue, event, question: str, default): + if self.use_python_dialog: + utils.append_unique( + self.tui_screens, + tui_screen.InputDialog( + self, screen_id, queue, event, question, default + ), + ) # noqa: E501 else: - utils.append_unique(self.tui_screens, - tui_screen.InputScreen(self, screen_id, queue, event, question, default)) - - def stack_password(self, screen_id, queue, event, question, default="", dialog=False): - if dialog: - utils.append_unique(self.tui_screens, - tui_screen.PasswordDialog(self, screen_id, queue, event, question, default)) + utils.append_unique( + self.tui_screens, + tui_screen.InputScreen( + self, screen_id, queue, event, question, default + ), + ) # noqa: E501 + + def stack_password( + self, screen_id, queue, event, question, default="" + ): # noqa: E501 + if self.use_python_dialog: + utils.append_unique( + self.tui_screens, + tui_screen.PasswordDialog( + self, screen_id, queue, event, question, default + ), + ) # noqa: E501 else: - utils.append_unique(self.tui_screens, - tui_screen.PasswordScreen(self, screen_id, queue, event, question, default)) - - def stack_confirm(self, screen_id, queue, event, question, no_text, secondary, options=["Yes", "No"], dialog=False): - if dialog: + utils.append_unique( + self.tui_screens, + tui_screen.PasswordScreen( + self, screen_id, queue, event, question, default + ), + ) # noqa: E501 + + def stack_confirm( + self, + screen_id, + queue, + event, + question, + no_text, + secondary, + options=["Yes", "No"], + ): # noqa: E501 + if self.use_python_dialog: yes_label = options[0] no_label = options[1] - utils.append_unique(self.tui_screens, - tui_screen.ConfirmDialog(self, screen_id, queue, event, question, no_text, secondary, - yes_label=yes_label, no_label=no_label)) + utils.append_unique( + self.tui_screens, + tui_screen.ConfirmDialog( + self, + screen_id, + queue, + event, + question, + no_text, + secondary, + yes_label=yes_label, + no_label=no_label, + ), + ) # noqa: E501 else: - utils.append_unique(self.tui_screens, - tui_screen.ConfirmScreen(self, screen_id, queue, event, question, no_text, secondary, - options)) - - def stack_text(self, screen_id, queue, event, text, wait=False, percent=None, dialog=False): - if dialog: - utils.append_unique(self.tui_screens, - tui_screen.TextDialog(self, screen_id, queue, event, text, wait, percent)) + utils.append_unique( + self.tui_screens, + tui_screen.ConfirmScreen( + self, screen_id, queue, event, question, no_text, secondary, options + ), + ) # noqa: E501 + + def stack_text( + self, screen_id, queue, event, text, wait=False, percent=None + ): # noqa: E501 + if self.use_python_dialog: + utils.append_unique( + self.tui_screens, + tui_screen.TextDialog( + self, screen_id, queue, event, text, wait, percent + ), + ) # noqa: E501 else: - utils.append_unique(self.tui_screens, tui_screen.TextScreen(self, screen_id, queue, event, text, wait)) - - def stack_tasklist(self, screen_id, queue, event, text, elements, percent, dialog=False): + utils.append_unique( + self.tui_screens, + tui_screen.TextScreen(self, screen_id, queue, event, text, wait), + ) # noqa: E501 + + def stack_tasklist( + self, screen_id, queue, event, text, elements, percent + ): # noqa: E501 logging.debug(f"Elements stacked: {elements}") - if dialog: - utils.append_unique(self.tui_screens, tui_screen.TaskListDialog(self, screen_id, queue, event, text, - elements, percent)) + if self.use_python_dialog: + utils.append_unique( + self.tui_screens, + tui_screen.TaskListDialog( + self, screen_id, queue, event, text, elements, percent + ), + ) # noqa: E501 else: - #TODO: curses version + # TODO: curses version pass - def stack_buildlist(self, screen_id, queue, event, question, options, height=None, width=None, list_height=None, dialog=False): - if dialog: - utils.append_unique(self.tui_screens, - tui_screen.BuildListDialog(self, screen_id, queue, event, question, options, - height, width, list_height)) + def stack_buildlist( + self, + screen_id, + queue, + event, + question, + options, + height=None, + width=None, + list_height=None, + ): # noqa: E501 + if self.use_python_dialog: + utils.append_unique( + self.tui_screens, + tui_screen.BuildListDialog( + self, + screen_id, + queue, + event, + question, + options, + height, + width, + list_height, + ), + ) # noqa: E501 else: # TODO pass - def stack_checklist(self, screen_id, queue, event, question, options, - height=None, width=None, list_height=None, dialog=False): - if dialog: - utils.append_unique(self.tui_screens, - tui_screen.CheckListDialog(self, screen_id, queue, event, question, options, - height, width, list_height)) + def stack_checklist( + self, + screen_id, + queue, + event, + question, + options, + height=None, + width=None, + list_height=None, + ): + if self.use_python_dialog: + utils.append_unique( + self.tui_screens, + tui_screen.CheckListDialog( + self, + screen_id, + queue, + event, + question, + options, + height, + width, + list_height, + ), + ) # noqa: E501 else: # TODO pass @@ -1128,13 +1226,10 @@ def stack_checklist(self, screen_id, queue, event, question, options, def update_tty_dimensions(self): self.window_height, self.window_width = self.stdscr.getmaxyx() - def get_main_window(self): - return self.main_window - def get_menu_window(self): return self.menu_window -def control_panel_app(stdscr): - os.environ.setdefault('ESCDELAY', '100') - TUI(stdscr).run() +def control_panel_app(stdscr: curses.window, ephemeral_config: EphemeralConfiguration): + os.environ.setdefault("ESCDELAY", "100") + TUI(stdscr, ephemeral_config).run() diff --git a/ou_dedetai/tui_curses.py b/ou_dedetai/tui_curses.py index 26fdf00e..07f09132 100644 --- a/ou_dedetai/tui_curses.py +++ b/ou_dedetai/tui_curses.py @@ -1,61 +1,74 @@ import curses +import os +from pathlib import Path import signal import textwrap -from . import config -from . import msg -from . import utils +from ou_dedetai import tui_screen +from ou_dedetai.app import App -def wrap_text(app, text): +def wrap_text(app: App, text: str) -> list[str]: + from ou_dedetai.tui_app import TUI + if not isinstance(app, TUI): + raise ValueError("curses MUST be used with the TUI") # Turn text into wrapped text, line by line, centered if "\n" in text: lines = text.splitlines() - wrapped_lines = [textwrap.fill(line, app.window_width - (config.margin * 2)) for line in lines] - lines = '\n'.join(wrapped_lines) + wrapped_lines = [textwrap.fill(line, app.window_width - (app.terminal_margin * 2)) for line in lines] #noqa: E501 + return wrapped_lines else: - wrapped_text = textwrap.fill(text, app.window_width - (config.margin * 2)) - lines = wrapped_text.split('\n') - return lines + wrapped_text = textwrap.fill(text, app.window_width - (app.terminal_margin * 2)) + return wrapped_text.splitlines() -def write_line(app, stdscr, start_y, start_x, text, char_limit, attributes=curses.A_NORMAL): +def write_line(app: App, stdscr: curses.window, start_y, start_x, text, char_limit, attributes=curses.A_NORMAL): #noqa: E501 + from ou_dedetai.tui_app import TUI + if not isinstance(app, TUI): + raise ValueError("curses MUST be used with the TUI") try: stdscr.addnstr(start_y, start_x, text, char_limit, attributes) except curses.error: - signal.signal(signal.SIGWINCH, app.signal_resize) + # This may happen if we try to write beyond the screen limits + # May happen when the window is resized before we've handled it + pass -def title(app, title_text, title_start_y_adj): - stdscr = app.get_main_window() +def title(app: App, title_text, title_start_y_adj): + from ou_dedetai.tui_app import TUI + if not isinstance(app, TUI): + raise ValueError("curses MUST be used with the TUI") + stdscr = app.main_window + if not stdscr: + raise Exception("Expected main window to be initialized, but it wasn't") title_lines = wrap_text(app, title_text) - title_start_y = max(0, app.window_height // 2 - len(title_lines) // 2) + # title_start_y = max(0, app.window_height // 2 - len(title_lines) // 2) last_index = 0 for i, line in enumerate(title_lines): if i < app.window_height: - write_line(app, stdscr, i + title_start_y_adj, 2, line, app.window_width, curses.A_BOLD) + write_line(app, stdscr, i + title_start_y_adj, 2, line, app.window_width, curses.A_BOLD) #noqa: E501 last_index = i return last_index -def text_centered(app, text, start_y=0): +def text_centered(app: App, text: str, start_y=0) -> tuple[int, list[str]]: + from ou_dedetai.tui_app import TUI + if not isinstance(app, TUI): + raise ValueError("curses MUST be used with the TUI") stdscr = app.get_menu_window() - if "\n" in text: - text_lines = wrap_text(app, text).splitlines() - else: - text_lines = wrap_text(app, text) + text_lines = wrap_text(app, text) text_start_y = start_y text_width = max(len(line) for line in text_lines) for i, line in enumerate(text_lines): if text_start_y + i < app.window_height: x = app.window_width // 2 - text_width // 2 - write_line(app, stdscr, text_start_y + i, x, line, app.window_width, curses.A_BOLD) + write_line(app, stdscr, text_start_y + i, x, line, app.window_width, curses.A_BOLD) #noqa: E501 return text_start_y, text_lines -def spinner(app, index, start_y=0): +def spinner(app: App, index: int, start_y: int = 0): spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"] i = index text_centered(app, spinner_chars[i], start_y) @@ -64,7 +77,10 @@ def spinner(app, index, start_y=0): #FIXME: Display flickers. -def confirm(app, question_text, height=None, width=None): +def confirm(app: App, question_text: str, height=None, width=None): + from ou_dedetai.tui_app import TUI + if not isinstance(app, TUI): + raise ValueError("curses MUST be used with the TUI") stdscr = app.get_menu_window() question_text = question_text + " [Y/n]: " question_start_y, question_lines = text_centered(app, question_text) @@ -80,16 +96,17 @@ def confirm(app, question_text, height=None, width=None): elif key.lower() == 'n': return False - write_line(app, stdscr, y, 0, "Type Y[es] or N[o]. ", app.window_width, curses.A_BOLD) + write_line(app, stdscr, y, 0, "Type Y[es] or N[o]. ", app.window_width, curses.A_BOLD) #noqa: E501 class CursesDialog: def __init__(self, app): - self.app = app - self.stdscr = self.app.get_menu_window() + from ou_dedetai.tui_app import TUI + self.app: TUI = app + self.stdscr: curses.window = self.app.get_menu_window() def __str__(self): - return f"Curses Dialog" + return "Curses Dialog" def draw(self): pass @@ -102,31 +119,37 @@ def run(self): class UserInputDialog(CursesDialog): - def __init__(self, app, question_text, default_text): + def __init__(self, app, question_text: str, default_text: str): super().__init__(app) self.question_text = question_text self.default_text = default_text self.user_input = "" self.submit = False - self.question_start_y = None - self.question_lines = None + + self.question_start_y, self.question_lines = text_centered(self.app, self.question_text) #noqa: E501 + def __str__(self): - return f"UserInput Curses Dialog" + return "UserInput Curses Dialog" def draw(self): curses.echo() curses.curs_set(1) self.stdscr.clear() - self.question_start_y, self.question_lines = text_centered(self.app, self.question_text) + self.question_start_y, self.question_lines = text_centered(self.app, self.question_text) #noqa: E501 self.input() curses.curs_set(0) curses.noecho() self.stdscr.refresh() + @property + def show_text(self) -> str: + """Text to show to the user. Normally their input""" + return self.user_input + def input(self): - write_line(self.app, self.stdscr, self.question_start_y + len(self.question_lines) + 2, 10, self.user_input, self.app.window_width) - key = self.stdscr.getch(self.question_start_y + len(self.question_lines) + 2, 10 + len(self.user_input)) + write_line(self.app, self.stdscr, self.question_start_y + len(self.question_lines) + 2, 10, self.show_text, self.app.window_width) #noqa: E501 + key = self.stdscr.getch(self.question_start_y + len(self.question_lines) + 2, 10 + len(self.show_text)) #noqa: E501 try: if key == -1: # If key not found, keep processing. @@ -136,6 +159,35 @@ def input(self): elif key == curses.KEY_BACKSPACE or key == 127: if len(self.user_input) > 0: self.user_input = self.user_input[:-1] + elif key == 9: # Tab + # Handle tab complete if the input is path life + if self.user_input.startswith("~"): + self.user_input = os.path.expanduser(self.user_input) + if self.user_input.startswith(os.path.sep): + path = Path(self.user_input) + dir_path = path.parent + if self.user_input.endswith(os.path.sep): + path_name = "" + dir_path = path + elif path.parent.exists(): + path_name = path.name + if dir_path.exists(): + options = os.listdir(dir_path) + options = [option for option in options if option.startswith(path_name)] #noqa: E501 + # Displaying all these options may be complicated, for now for + # now only display if there is only one option + if len(options) == 1: + self.user_input = options[0] + if Path(self.user_input).is_dir(): + self.user_input += os.path.sep + # Or see if all the options have the same prefix + common_chars = "" + for i in range(min([len(option) for option in options])): + # If all of the options are the same + if len(set([option[i] for option in options])) == 1: + common_chars += options[0][i] + if common_chars: + self.user_input = str(dir_path / common_chars) else: self.user_input += chr(key) except KeyboardInterrupt: @@ -152,39 +204,10 @@ def run(self): class PasswordDialog(UserInputDialog): - def __init__(self, app, question_text, default_text): - super().__init__(app, question_text, default_text) - - self.obfuscation = "" - - def run(self): - if not self.submit: - self.draw() - return "Processing" - else: - if self.user_input is None or self.user_input == "": - self.user_input = self.default_text - return self.user_input - - def input(self): - write_line(self.app, self.stdscr, self.question_start_y + len(self.question_lines) + 2, 10, self.obfuscation, - self.app.window_width) - key = self.stdscr.getch(self.question_start_y + len(self.question_lines) + 2, 10 + len(self.obfuscation)) - - try: - if key == -1: # If key not found, keep processing. - pass - elif key == ord('\n'): # Enter key - self.submit = True - elif key == curses.KEY_BACKSPACE or key == 127: - if len(self.user_input) > 0: - self.user_input = self.user_input[:-1] - self.obfuscation = '*' * len(self.user_input[:-1]) - else: - self.user_input += chr(key) - self.obfuscation = '*' * (len(self.obfuscation) + 1) - except KeyboardInterrupt: - signal.signal(signal.SIGINT, self.app.end) + @property + def show_text(self) -> str: + """Obfuscate the user's input""" + return "*" * len(self.user_input) class MenuDialog(CursesDialog): @@ -198,18 +221,22 @@ def __init__(self, app, question_text, options): self.question_lines = None def __str__(self): - return f"Menu Curses Dialog" + return "Menu Curses Dialog" def draw(self): self.stdscr.erase() - self.app.active_screen.set_options(self.options) - config.total_pages = (len(self.options) - 1) // config.options_per_page + 1 - - self.question_start_y, self.question_lines = text_centered(self.app, self.question_text) + # We should be on a menu screen at this point + if isinstance(self.app.active_screen, tui_screen.MenuScreen): + self.app.active_screen.set_options(self.options) + self.total_pages = (len(self.options) - 1) // self.app.options_per_page + 1 + # Default menu_bottom to 0, it should get set to something larger + menu_bottom = 0 + + self.question_start_y, self.question_lines = text_centered(self.app, self.question_text) #noqa: E501 # Display the options, centered options_start_y = self.question_start_y + len(self.question_lines) + 2 - for i in range(config.options_per_page): - index = config.current_page * config.options_per_page + i + for i in range(self.app.options_per_page): + index = self.app.current_page * self.app.options_per_page + i if index < len(self.options): option = self.options[index] if type(option) is list: @@ -219,10 +246,10 @@ def draw(self): wine_binary_path = option[1] wine_binary_description = option[2] wine_binary_path_wrapped = textwrap.wrap( - f"Binary Path: {wine_binary_path}", self.app.window_width - 4) + f"Binary Path: {wine_binary_path}", self.app.window_width - 4) #noqa: E501 option_lines.extend(wine_binary_path_wrapped) wine_binary_desc_wrapped = textwrap.wrap( - f"Description: {wine_binary_description}", self.app.window_width - 4) + f"Description: {wine_binary_description}", self.app.window_width - 4) #noqa: E501 option_lines.extend(wine_binary_desc_wrapped) else: wine_binary_path = option[1] @@ -240,43 +267,43 @@ def draw(self): y = options_start_y + i + j x = max(0, self.app.window_width // 2 - len(line) // 2) if y < self.app.menu_window_height: - if index == config.current_option: - write_line(self.app, self.stdscr, y, x, line, self.app.window_width, curses.A_REVERSE) + if index == self.app.current_option: + write_line(self.app, self.stdscr, y, x, line, self.app.window_width, curses.A_REVERSE) #noqa: E501 else: - write_line(self.app, self.stdscr, y, x, line, self.app.window_width) + write_line(self.app, self.stdscr, y, x, line, self.app.window_width) #noqa: E501 menu_bottom = y if type(option) is list: options_start_y += (len(option_lines)) # Display pagination information - page_info = f"Page {config.current_page + 1}/{config.total_pages} | Selected Option: {config.current_option + 1}/{len(self.options)}" - write_line(self.app, self.stdscr, max(menu_bottom, self.app.menu_window_height) - 3, 2, page_info, self.app.window_width, curses.A_BOLD) + page_info = f"Page {self.app.current_page + 1}/{self.total_pages} | Selected Option: {self.app.current_option + 1}/{len(self.options)}" #noqa: E501 + write_line(self.app, self.stdscr, max(menu_bottom, self.app.menu_window_height) - 3, 2, page_info, self.app.window_width, curses.A_BOLD) #noqa: E501 def do_menu_up(self): - if config.current_option == config.current_page * config.options_per_page and config.current_page > 0: + if self.app.current_option == self.app.current_page * self.app.options_per_page and self.app.current_page > 0: #noqa: E501 # Move to the previous page - config.current_page -= 1 - config.current_option = min(len(self.app.menu_options) - 1, (config.current_page + 1) * config.options_per_page - 1) - elif config.current_option == 0: - if config.total_pages == 1: - config.current_option = len(self.app.menu_options) - 1 + self.app.current_page -= 1 + self.app.current_option = min(len(self.app.menu_options) - 1, (self.app.current_page + 1) * self.app.options_per_page - 1) #noqa: E501 + elif self.app.current_option == 0: + if self.total_pages == 1: + self.app.current_option = len(self.app.menu_options) - 1 else: - config.current_page = config.total_pages - 1 - config.current_option = len(self.app.menu_options) - 1 + self.app.current_page = self.total_pages - 1 + self.app.current_option = len(self.app.menu_options) - 1 else: - config.current_option = max(0, config.current_option - 1) + self.app.current_option = max(0, self.app.current_option - 1) def do_menu_down(self): - if config.current_option == (config.current_page + 1) * config.options_per_page - 1 and config.current_page < config.total_pages - 1: + if self.app.current_option == (self.app.current_page + 1) * self.app.options_per_page - 1 and self.app.current_page < self.total_pages - 1: #noqa: E501 # Move to the next page - config.current_page += 1 - config.current_option = min(len(self.app.menu_options) - 1, config.current_page * config.options_per_page) - elif config.current_option == len(self.app.menu_options) - 1: - config.current_page = 0 - config.current_option = 0 + self.app.current_page += 1 + self.app.current_option = min(len(self.app.menu_options) - 1, self.app.current_page * self.app.options_per_page) #noqa: E501 + elif self.app.current_option == len(self.app.menu_options) - 1: + self.app.current_page = 0 + self.app.current_option = 0 else: - config.current_option = min(len(self.app.menu_options) - 1, config.current_option + 1) + self.app.current_option = min(len(self.app.menu_options) - 1, self.app.current_option + 1) #noqa: E501 def input(self): if len(self.app.tui_screens) > 0: @@ -288,13 +315,12 @@ def input(self): try: if key == -1: # If key not found, keep processing. pass - elif key == curses.KEY_RESIZE: - utils.send_task(self.app, 'RESIZE') elif key == curses.KEY_UP or key == 259: # Up arrow self.do_menu_up() elif key == curses.KEY_DOWN or key == 258: # Down arrow self.do_menu_down() - elif key == 27: # Sometimes the up/down arrow key is represented by a series of three keys. + elif key == 27: + # Sometimes the up/down arrow key is represented by a series of 3 keys. next_key = self.stdscr.getch() if next_key == 91: final_key = self.stdscr.getch() @@ -303,12 +329,9 @@ def input(self): elif final_key == 66: self.do_menu_down() elif key == ord('\n') or key == 10: # Enter key - self.user_input = self.options[config.current_option] + self.user_input = self.options[self.app.current_option] elif key == ord('\x1b'): signal.signal(signal.SIGINT, self.app.end) - else: - msg.status("Input unknown.", self.app) - pass except KeyboardInterrupt: signal.signal(signal.SIGINT, self.app.end) diff --git a/ou_dedetai/tui_dialog.py b/ou_dedetai/tui_dialog.py index 44a838dc..afc9ef8f 100644 --- a/ou_dedetai/tui_dialog.py +++ b/ou_dedetai/tui_dialog.py @@ -1,13 +1,14 @@ import curses import logging +from typing import Optional try: - from dialog import Dialog + from dialog import Dialog #type: ignore[import-untyped] except ImportError: pass -def text(screen, text, height=None, width=None, title=None, backtitle=None, colors=True): +def text(screen, text, height=None, width=None, title=None, backtitle=None, colors=True): # noqa: E501 dialog = Dialog() dialog.autowidgetsize = True options = {'colors': colors} @@ -22,7 +23,7 @@ def text(screen, text, height=None, width=None, title=None, backtitle=None, colo dialog.infobox(text, **options) -def progress_bar(screen, text, percent, height=None, width=None, title=None, backtitle=None, colors=True): +def progress_bar(screen, text, percent, height=None, width=None, title=None, backtitle=None, colors=True): # noqa: E501 screen.dialog = Dialog() screen.dialog.autowidgetsize = True options = {'colors': colors} @@ -49,7 +50,7 @@ def stop_progress_bar(screen): screen.dialog.gauge_stop() -def tasklist_progress_bar(screen, text, percent, elements, height=None, width=None, title=None, backtitle=None, colors=None): +def tasklist_progress_bar(screen, text, percent, elements, height=None, width=None, title=None, backtitle=None, colors=None): # noqa: E501 dialog = Dialog() dialog.autowidgetsize = True options = {'colors': colors} @@ -73,7 +74,7 @@ def tasklist_progress_bar(screen, text, percent, elements, height=None, width=No raise -def input(screen, question_text, height=None, width=None, init="", title=None, backtitle=None, colors=True): +def input(screen, question_text, height=None, width=None, init="", title=None, backtitle=None, colors=True): # noqa: E501 dialog = Dialog() dialog.autowidgetsize = True options = {'colors': colors} @@ -89,7 +90,7 @@ def input(screen, question_text, height=None, width=None, init="", title=None, return code, input -def password(screen, question_text, height=None, width=None, init="", title=None, backtitle=None, colors=True): +def password(screen, question_text, height=None, width=None, init="", title=None, backtitle=None, colors=True): # noqa: E501 dialog = Dialog() dialog.autowidgetsize = True options = {'colors': colors} @@ -101,7 +102,7 @@ def password(screen, question_text, height=None, width=None, init="", title=Non options['title'] = title if backtitle is not None: options['backtitle'] = backtitle - code, password = dialog.passwordbox(question_text, init=init, insecure=True, **options) + code, password = dialog.passwordbox(question_text, init=init, insecure=True, **options) # noqa: E501 return code, password @@ -118,13 +119,14 @@ def confirm(screen, question_text, yes_label="Yes", no_label="No", options['title'] = title if backtitle is not None: options['backtitle'] = backtitle - check = dialog.yesno(question_text, height, width, yes_label=yes_label, no_label=no_label, **options) + check = dialog.yesno(question_text, height, width, yes_label=yes_label, no_label=no_label, **options) # noqa: E501 return check # Returns "ok" or "cancel" -def directory_picker(screen, path_dir, height=None, width=None, title=None, backtitle=None, colors=True): +def directory_picker(screen, path_dir, height=None, width=None, title=None, backtitle=None, colors=True) -> Optional[str]: # noqa: E501 str_dir = str(path_dir) + path = None try: dialog = Dialog() dialog.autowidgetsize = True @@ -138,7 +140,8 @@ def directory_picker(screen, path_dir, height=None, width=None, title=None, back if backtitle is not None: options['backtitle'] = backtitle curses.curs_set(1) - _, path = dialog.dselect(str_dir, **options) + _, raw_path = dialog.dselect(str_dir, **options) + path = str(raw_path) curses.curs_set(0) except Exception as e: logging.error("An error occurred:", e) @@ -147,7 +150,7 @@ def directory_picker(screen, path_dir, height=None, width=None, title=None, back return path -def menu(screen, question_text, choices, height=None, width=None, menu_height=8, title=None, backtitle=None, colors=True): +def menu(screen, question_text, choices, height=None, width=None, menu_height=8, title=None, backtitle=None, colors=True): # noqa: E501 tag_to_description = {tag: description for tag, description in choices} dialog = Dialog(dialog="dialog") dialog.autowidgetsize = True @@ -158,7 +161,7 @@ def menu(screen, question_text, choices, height=None, width=None, menu_height=8, options['backtitle'] = backtitle menu_options = [(tag, description) for i, (tag, description) in enumerate(choices)] - code, tag = dialog.menu(question_text, height, width, menu_height, menu_options, **options) + code, tag = dialog.menu(question_text, height, width, menu_height, menu_options, **options) # noqa: E501 selected_description = tag_to_description.get(tag) if code == dialog.OK: @@ -167,7 +170,7 @@ def menu(screen, question_text, choices, height=None, width=None, menu_height=8, return None, None, "Return to Main Menu" -def buildlist(screen, text, items=[], height=None, width=None, list_height=None, title=None, backtitle=None, colors=True): +def buildlist(screen, text, items=[], height=None, width=None, list_height=None, title=None, backtitle=None, colors=True): # noqa: E501 # items is an interable of (tag, item, status) dialog = Dialog(dialog="dialog") dialog.autowidgetsize = True @@ -189,7 +192,7 @@ def buildlist(screen, text, items=[], height=None, width=None, list_height=None, return None -def checklist(screen, text, items=[], height=None, width=None, list_height=None, title=None, backtitle=None, colors=True): +def checklist(screen, text, items=[], height=None, width=None, list_height=None, title=None, backtitle=None, colors=True): # noqa: E501 # items is an iterable of (tag, item, status) dialog = Dialog(dialog="dialog") dialog.autowidgetsize = True @@ -203,7 +206,7 @@ def checklist(screen, text, items=[], height=None, width=None, list_height=None, if backtitle is not None: options['backtitle'] = backtitle - code, tags = dialog.checklist(text, choices=items, list_height=list_height, **options) + code, tags = dialog.checklist(text, choices=items, list_height=list_height, **options) # noqa: E501 if code == dialog.OK: return code, tags diff --git a/ou_dedetai/tui_screen.py b/ou_dedetai/tui_screen.py index b9b3b1a7..1201b40a 100644 --- a/ou_dedetai/tui_screen.py +++ b/ou_dedetai/tui_screen.py @@ -1,21 +1,24 @@ import curses import logging import time -from pathlib import Path +from typing import Optional + +from ou_dedetai.app import App -from . import config from . import installer from . import system from . import tui_curses -from . import utils if system.have_dep("dialog"): from . import tui_dialog class Screen: - def __init__(self, app, screen_id, queue, event): - self.app = app - self.stdscr = "" + def __init__(self, app: App, screen_id, queue, event): + from ou_dedetai.tui_app import TUI + if not isinstance(app, TUI): + raise ValueError("Cannot start TUI screen with non-TUI app") + self.app: TUI = app + self.stdscr: Optional[curses.window] = None self.screen_id = screen_id self.choice = "Processing" self.queue = queue @@ -23,28 +26,22 @@ def __init__(self, app, screen_id, queue, event): # running: # This var indicates either whether: # A CursesScreen has already submitted its choice to the choice_q, or - # The var indicates whether a Dialog has already started. If the dialog has already started, - # then the program will not display the dialog again in order to prevent phantom key presses. + # The var indicates whether a Dialog has already started. If the dialog has already started, #noqa: E501 + # then the program will not display the dialog again in order to prevent phantom key presses. #noqa: E501 # 0 = not submitted or not started # 1 = submitted or started # 2 = none or finished self.running = 0 def __str__(self): - return f"Curses Screen" + return "Curses Screen" def display(self): pass - def get_stdscr(self): + def get_stdscr(self) -> curses.window: return self.app.stdscr - def get_screen_id(self): - return self.screen_id - - def get_choice(self): - return self.choice - def wait_event(self): self.event.wait() @@ -69,36 +66,39 @@ def submit_choice_to_queue(self): class ConsoleScreen(CursesScreen): def __init__(self, app, screen_id, queue, event, title, subtitle, title_start_y): super().__init__(app, screen_id, queue, event) - self.stdscr = self.app.get_main_window() + self.stdscr: Optional[curses.window] = self.app.main_window self.title = title self.subtitle = subtitle self.title_start_y = title_start_y def __str__(self): - return f"Curses Console Screen" + return "Curses Console Screen" def display(self): + if self.stdscr is None: + raise Exception("stdscr should be set at this point in the console screen." + "Please report this incident to the developers") self.stdscr.erase() subtitle_start = tui_curses.title(self.app, self.title, self.title_start_y) tui_curses.title(self.app, self.subtitle, subtitle_start + 1) console_start_y = len(tui_curses.wrap_text(self.app, self.title)) + len( tui_curses.wrap_text(self.app, self.subtitle)) + 1 - tui_curses.write_line(self.app, self.stdscr, console_start_y, config.margin, f"---Console---", self.app.window_width - (config.margin * 2)) - recent_messages = config.console_log[-config.console_log_lines:] + tui_curses.write_line(self.app, self.stdscr, console_start_y, self.app.terminal_margin, "---Console---", self.app.window_width - (self.app.terminal_margin * 2)) #noqa: E501 + recent_messages = self.app.recent_console_log for i, message in enumerate(recent_messages, 1): message_lines = tui_curses.wrap_text(self.app, message) for j, line in enumerate(message_lines): if 2 + j < self.app.window_height: - truncated = message[:self.app.window_width - (config.margin * 2)] - tui_curses.write_line(self.app, self.stdscr, console_start_y + i, config.margin, truncated, self.app.window_width - (config.margin * 2)) + truncated = message[:self.app.window_width - (self.app.terminal_margin * 2)] #noqa: E501 + tui_curses.write_line(self.app, self.stdscr, console_start_y + i, self.app.terminal_margin, truncated, self.app.window_width - (self.app.terminal_margin * 2)) #noqa: E501 self.stdscr.noutrefresh() curses.doupdate() class MenuScreen(CursesScreen): - def __init__(self, app, screen_id, queue, event, question, options, height=None, width=None, menu_height=8): + def __init__(self, app, screen_id, queue, event, question, options, height=None, width=None, menu_height=8): #noqa: E501 super().__init__(app, screen_id, queue, event) self.stdscr = self.app.get_menu_window() self.question = question @@ -108,18 +108,19 @@ def __init__(self, app, screen_id, queue, event, question, options, height=None, self.menu_height = menu_height def __str__(self): - return f"Curses Menu Screen" + return "Curses Menu Screen" def display(self): + if self.stdscr is None: + raise Exception("stdscr should be set at this point in the console screen." + "Please report this incident to the developers") self.stdscr.erase() self.choice = tui_curses.MenuDialog( self.app, self.question, self.options ).run() - if self.choice is not None and not self.choice == "" and not self.choice == "Processing": - config.current_option = 0 - config.current_page = 0 + if self.choice is not None and not self.choice == "" and not self.choice == "Processing": #noqa: E501 self.submit_choice_to_queue() self.stdscr.noutrefresh() curses.doupdate() @@ -133,25 +134,26 @@ def set_options(self, new_options): class ConfirmScreen(MenuScreen): - def __init__(self, app, screen_id, queue, event, question, no_text, secondary, options=["Yes", "No"]): + def __init__(self, app, screen_id, queue, event, question, no_text, secondary, options=["Yes", "No"]): #noqa: E501 super().__init__(app, screen_id, queue, event, question, options, height=None, width=None, menu_height=8) self.no_text = no_text self.secondary = secondary def __str__(self): - return f"Curses Confirm Screen" + return "Curses Confirm Screen" def display(self): + if self.stdscr is None: + raise Exception("stdscr should be set at this point in the console screen." + "Please report this incident to the developers") self.stdscr.erase() self.choice = tui_curses.MenuDialog( self.app, self.secondary + "\n" + self.question, self.options ).run() - if self.choice is not None and not self.choice == "" and not self.choice == "Processing": - config.current_option = 0 - config.current_page = 0 + if self.choice is not None and not self.choice == "" and not self.choice == "Processing": #noqa: E501 if self.choice == "No": logging.critical(self.no_text) self.submit_choice_to_queue() @@ -160,7 +162,7 @@ def display(self): class InputScreen(CursesScreen): - def __init__(self, app, screen_id, queue, event, question, default): + def __init__(self, app, screen_id, queue, event, question: str, default): super().__init__(app, screen_id, queue, event) self.stdscr = self.app.get_menu_window() self.question = question @@ -172,9 +174,12 @@ def __init__(self, app, screen_id, queue, event, question, default): ) def __str__(self): - return f"Curses Input Screen" + return "Curses Input Screen" def display(self): + if self.stdscr is None: + raise Exception("stdscr should be set at this point in the console screen." + "Please report this incident to the developers") self.stdscr.erase() self.choice = self.dialog.run() if not self.choice == "Processing": @@ -192,6 +197,9 @@ def get_default(self): class PasswordScreen(InputScreen): def __init__(self, app, screen_id, queue, event, question, default): super().__init__(app, screen_id, queue, event, question, default) + # Update type for type linting + from ou_dedetai.tui_app import TUI + self.app: TUI = app self.dialog = tui_curses.PasswordDialog( self.app, self.question, @@ -199,14 +207,17 @@ def __init__(self, app, screen_id, queue, event, question, default): ) def __str__(self): - return f"Curses Password Screen" + return "Curses Password Screen" def display(self): + if self.stdscr is None: + raise Exception("stdscr should be set at this point in the console screen." + "Please report this incident to the developers") self.stdscr.erase() self.choice = self.dialog.run() if not self.choice == "Processing": self.submit_choice_to_queue() - utils.send_task(self.app, "INSTALLING_PW") + self.app.installing_pw_waiting() self.stdscr.noutrefresh() curses.doupdate() @@ -220,13 +231,16 @@ def __init__(self, app, screen_id, queue, event, text, wait): self.spinner_index = 0 def __str__(self): - return f"Curses Text Screen" + return "Curses Text Screen" def display(self): + if self.stdscr is None: + raise Exception("stdscr should be set at this point in the console screen." + "Please report this incident to the developers") self.stdscr.erase() text_start_y, text_lines = tui_curses.text_centered(self.app, self.text) if self.wait: - self.spinner_index = tui_curses.spinner(self.app, self.spinner_index, text_start_y + len(text_lines) + 1) + self.spinner_index = tui_curses.spinner(self.app, self.spinner_index, text_start_y + len(text_lines) + 1) #noqa: E501 time.sleep(0.1) self.stdscr.noutrefresh() curses.doupdate() @@ -236,7 +250,7 @@ def get_text(self): class MenuDialog(DialogScreen): - def __init__(self, app, screen_id, queue, event, question, options, height=None, width=None, menu_height=8): + def __init__(self, app, screen_id, queue, event, question, options, height=None, width=None, menu_height=8): #noqa: E501 super().__init__(app, screen_id, queue, event) self.stdscr = self.app.get_menu_window() self.question = question @@ -246,13 +260,14 @@ def __init__(self, app, screen_id, queue, event, question, options, height=None, self.menu_height = menu_height def __str__(self): - return f"PyDialog Menu Screen" + return "PyDialog Menu Screen" def display(self): if self.running == 0: self.running = 1 - _, _, self.choice = tui_dialog.menu(self.app, self.question, self.options, self.height, self.width, - self.menu_height) + _, _, self.choice = tui_dialog.menu(self.app, self.question, self.options, + self.height, self.width, + self.menu_height) self.submit_choice_to_queue() def get_question(self): @@ -270,14 +285,14 @@ def __init__(self, app, screen_id, queue, event, question, default): self.default = default def __str__(self): - return f"PyDialog Input Screen" + return "PyDialog Input Screen" def display(self): if self.running == 0: self.running = 1 - self.choice = tui_dialog.directory_picker(self.app, self.default) - if self.choice: - self.choice = Path(self.choice) + choice = tui_dialog.directory_picker(self.app, self.default) + if choice: + self.choice = choice self.submit_choice_to_queue() def get_question(self): @@ -290,20 +305,22 @@ def get_default(self): class PasswordDialog(InputDialog): def __init__(self, app, screen_id, queue, event, question, default): super().__init__(app, screen_id, queue, event, question, default) + from ou_dedetai.tui_app import TUI + self.app: TUI = app def __str__(self): - return f"PyDialog Password Screen" + return "PyDialog Password Screen" def display(self): if self.running == 0: self.running = 1 - _, self.choice = tui_dialog.password(self.app, self.question, init=self.default) + _, self.choice = tui_dialog.password(self.app, self.question, init=self.default) #noqa: E501 self.submit_choice_to_queue() - utils.send_task(self.app, "INSTALLING_PW") + self.app.installing_pw_waiting() class ConfirmDialog(DialogScreen): - def __init__(self, app, screen_id, queue, event, question, no_text, secondary, yes_label="Yes", no_label="No"): + def __init__(self, app, screen_id, queue, event, question, no_text, secondary, yes_label="Yes", no_label="No"): #noqa: E501 super().__init__(app, screen_id, queue, event) self.stdscr = self.app.get_menu_window() self.question = question @@ -313,7 +330,7 @@ def __init__(self, app, screen_id, queue, event, question, no_text, secondary, y self.no_label = no_label def __str__(self): - return f"PyDialog Confirm Screen" + return "PyDialog Confirm Screen" def display(self): if self.running == 0: @@ -332,8 +349,8 @@ def get_question(self): class TextDialog(DialogScreen): - def __init__(self, app, screen_id, queue, event, text, wait=False, percent=None, height=None, width=None, - title=None, backtitle=None, colors=True): + def __init__(self, app, screen_id, queue, event, text, wait=False, percent=None, + height=None, width=None, title=None, backtitle=None, colors=True): super().__init__(app, screen_id, queue, event) self.stdscr = self.app.get_menu_window() self.text = text @@ -348,13 +365,13 @@ def __init__(self, app, screen_id, queue, event, text, wait=False, percent=None, self.dialog = "" def __str__(self): - return f"PyDialog Text Screen" + return "PyDialog Text Screen" def display(self): if self.running == 0: if self.wait: - if config.INSTALL_STEPS_COUNT > 0: - self.percent = installer.get_progress_pct(config.INSTALL_STEP, config.INSTALL_STEPS_COUNT) + if self.app.installer_step_count > 0: + self.percent = installer.get_progress_pct(self.app.installer_step, self.app.installer_step_count) #noqa: E501 else: self.percent = 0 @@ -365,8 +382,8 @@ def display(self): self.running = 1 elif self.running == 1: if self.wait: - if config.INSTALL_STEPS_COUNT > 0: - self.percent = installer.get_progress_pct(config.INSTALL_STEP, config.INSTALL_STEPS_COUNT) + if self.app.installer_step_count > 0: + self.percent = installer.get_progress_pct(self.app.installer_step, self.app.installer_step_count) #noqa: E501 else: self.percent = 0 @@ -400,17 +417,20 @@ def __init__(self, app, screen_id, queue, event, text, elements, percent, self.updated = False def __str__(self): - return f"PyDialog Task List Screen" + return "PyDialog Task List Screen" def display(self): if self.running == 0: - tui_dialog.tasklist_progress_bar(self, self.text, self.percent, self.elements, - self.height, self.width, self.title, self.backtitle, self.colors) + tui_dialog.tasklist_progress_bar(self, self.text, self.percent, + self.elements, self.height, self.width, + self.title, self.backtitle, self.colors) self.running = 1 elif self.running == 1: if self.updated: - tui_dialog.tasklist_progress_bar(self, self.text, self.percent, self.elements, - self.height, self.width, self.title, self.backtitle, self.colors) + tui_dialog.tasklist_progress_bar(self, self.text, self.percent, + self.elements, self.height, self.width, + self.title, self.backtitle, + self.colors) else: pass @@ -428,12 +448,9 @@ def set_elements(self, elements): self.elements = elements self.updated = True - def get_text(self): - return self.text - class BuildListDialog(DialogScreen): - def __init__(self, app, screen_id, queue, event, question, options, list_height=None, height=None, width=None): + def __init__(self, app, screen_id, queue, event, question, options, list_height=None, height=None, width=None): #noqa: E501 super().__init__(app, screen_id, queue, event) self.stdscr = self.app.get_menu_window() self.question = question @@ -443,13 +460,14 @@ def __init__(self, app, screen_id, queue, event, question, options, list_height= self.list_height = list_height def __str__(self): - return f"PyDialog Build List Screen" + return "PyDialog Build List Screen" def display(self): if self.running == 0: self.running = 1 - code, self.choice = tui_dialog.buildlist(self.app, self.question, self.options, self.height, self.width, - self.list_height) + code, self.choice = tui_dialog.buildlist(self.app, self.question, + self.options, self.height, + self.width, self.list_height) self.running = 2 def get_question(self): @@ -460,7 +478,7 @@ def set_options(self, new_options): class CheckListDialog(DialogScreen): - def __init__(self, app, screen_id, queue, event, question, options, list_height=None, height=None, width=None): + def __init__(self, app, screen_id, queue, event, question, options, list_height=None, height=None, width=None): #noqa: E501 super().__init__(app, screen_id, queue, event) self.stdscr = self.app.get_menu_window() self.question = question @@ -470,17 +488,15 @@ def __init__(self, app, screen_id, queue, event, question, options, list_height= self.list_height = list_height def __str__(self): - return f"PyDialog Check List Screen" + return "PyDialog Check List Screen" def display(self): if self.running == 0: self.running = 1 - code, self.choice = tui_dialog.checklist(self.app, self.question, self.options, self.height, self.width, - self.list_height) + code, self.choice = tui_dialog.checklist(self.app, self.question, + self.options, self.height, + self.width, self.list_height) self.running = 2 - def get_question(self): - return self.question - def set_options(self, new_options): self.options = new_options diff --git a/ou_dedetai/utils.py b/ou_dedetai/utils.py index b675d221..44641236 100644 --- a/ou_dedetai/utils.py +++ b/ou_dedetai/utils.py @@ -1,9 +1,12 @@ import atexit +from datetime import datetime +import enum import glob import inspect import json import logging import os +import queue import psutil import re import shutil @@ -12,25 +15,17 @@ import subprocess import sys import tarfile -import threading import time -import tkinter as tk +from ou_dedetai.app import App from packaging import version from pathlib import Path -from typing import List, Optional, Union +from typing import List, Optional, Tuple -from . import config -from . import msg +from . import constants from . import network from . import system -if system.have_dep("dialog"): - from . import tui_dialog as tui -else: - from . import tui_curses as tui from . import wine -# TODO: Move config commands to config.py - def get_calling_function_name(): if 'inspect' in sys.modules: @@ -46,159 +41,48 @@ def append_unique(list, item): if item not in list: list.append(item) else: - msg.logos_warn(f"{item} already in {list}.") - - -# Set "global" variables. -def set_default_config(): - system.get_os() - system.get_superuser_command() - system.get_package_manager() - if config.CONFIG_FILE is None: - config.CONFIG_FILE = config.DEFAULT_CONFIG_PATH - config.PRESENT_WORKING_DIRECTORY = os.getcwd() - config.MYDOWNLOADS = get_user_downloads_dir() - os.makedirs(os.path.dirname(config.LOGOS_LOG), exist_ok=True) - - -def set_runtime_config(): - # Set runtime variables that are dependent on ones from config file. - if config.INSTALLDIR and not config.WINEPREFIX: - config.WINEPREFIX = f"{config.INSTALLDIR}/data/wine64_bottle" - if get_wine_exe_path() and not config.WINESERVER_EXE: - bin_dir = Path(get_wine_exe_path()).parent - config.WINESERVER_EXE = str(bin_dir / 'wineserver') - if config.FLPRODUCT and config.WINEPREFIX and not config.LOGOS_EXE: - config.LOGOS_EXE = find_installed_product() - if app_is_installed(): - wine.set_logos_paths() - + logging.debug(f"{item} already in {list}.") -def log_current_persistent_config(): - logging.debug("Current persistent config:") - for k in config.core_config_keys: - logging.debug(f"{k}: {config.__dict__.get(k)}") - - -def write_config(config_file_path): - logging.info(f"Writing config to {config_file_path}") - os.makedirs(os.path.dirname(config_file_path), exist_ok=True) - - config_data = {key: config.__dict__.get(key) for key in config.core_config_keys} # noqa: E501 - - try: - for key, value in config_data.items(): - if key == "WINE_EXE": - # We store the value of WINE_EXE as relative path if it is in - # the install directory. - if value is not None: - value = get_relative_path( - get_config_var(value), - config.INSTALLDIR - ) - if isinstance(value, Path): - config_data[key] = str(value) - with open(config_file_path, 'w') as config_file: - json.dump(config_data, config_file, indent=4, sort_keys=True) - config_file.write('\n') - - except IOError as e: - msg.logos_error(f"Error writing to config file {config_file_path}: {e}") # noqa: E501 - - -def update_config_file(config_file_path, key, value): - config_file_path = Path(config_file_path) - with config_file_path.open(mode='r') as f: - config_data = json.load(f) - - if config_data.get(key) != value: - logging.info(f"Updating {str(config_file_path)} with: {key} = {value}") - config_data[key] = value - try: - with config_file_path.open(mode='w') as f: - json.dump(config_data, f, indent=4, sort_keys=True) - f.write('\n') - except IOError as e: - msg.logos_error(f"Error writing to config file {config_file_path}: {e}") # noqa: E501 - -def die_if_running(): +def die_if_running(app: App): def remove_pid_file(): - if os.path.exists(config.pid_file): - os.remove(config.pid_file) + if os.path.exists(constants.PID_FILE): + os.remove(constants.PID_FILE) - if os.path.isfile(config.pid_file): - with open(config.pid_file, 'r') as f: + if os.path.isfile(constants.PID_FILE): + with open(constants.PID_FILE, 'r') as f: pid = f.read().strip() message = f"The script is already running on PID {pid}. Should it be killed to allow this instance to run?" # noqa: E501 - if config.DIALOG == "tk": - # TODO: With the GUI this runs in a thread. It's not clear if - # the messagebox will work correctly. It may need to be - # triggered from here with an event and then opened from the - # main thread. - tk_root = tk.Tk() - tk_root.withdraw() - confirm = tk.messagebox.askquestion("Confirmation", message) - tk_root.destroy() - elif config.DIALOG == "curses": - confirm = tui.confirm("Confirmation", message) - else: - confirm = msg.cli_question(message, "") - - if confirm: + if app.approve(message): os.kill(int(pid), signal.SIGKILL) atexit.register(remove_pid_file) - with open(config.pid_file, 'w') as f: + with open(constants.PID_FILE, 'w') as f: f.write(str(os.getpid())) -def die_if_root(): - if os.getuid() == 0 and not config.LOGOS_FORCE_ROOT: - msg.logos_error("Running Wine/winetricks as root is highly discouraged. Use -f|--force-root if you must run as root. See https://wiki.winehq.org/FAQ#Should_I_run_Wine_as_root.3F") # noqa: E501 - - def die(message): logging.critical(message) sys.exit(1) def restart_lli(): - logging.debug(f"Restarting {config.name_app}.") - pidfile = Path(config.pid_file) + logging.debug(f"Restarting {constants.APP_NAME}.") + pidfile = Path(constants.PID_FILE) if pidfile.is_file(): pidfile.unlink() os.execv(sys.executable, [sys.executable]) sys.exit() -def set_verbose(): - config.LOG_LEVEL = logging.INFO - config.WINEDEBUG = '' - - -def set_debug(): - config.LOG_LEVEL = logging.DEBUG - config.WINEDEBUG = "" - - def clean_all(): logging.info("Cleaning all temp files…") - os.system("rm -fr /tmp/LBS.*") - os.system(f"rm -fr {config.WORKDIR}") - os.system(f"rm -f {config.PRESENT_WORKING_DIRECTORY}/wget-log*") + os.system(f"rm -f {os.getcwd()}/wget-log*") logging.info("done") -def mkdir_critical(directory): - try: - os.mkdir(directory) - except OSError: - msg.logos_error(f"Can't create the {directory} directory") - - -def get_user_downloads_dir(): +def get_user_downloads_dir() -> str: home = Path.home() xdg_config = Path(os.getenv('XDG_CONFIG_HOME', home / '.config')) user_dirs_file = xdg_config / 'user-dirs.dirs' @@ -225,31 +109,28 @@ def delete_symlink(symlink_path): logging.error(f"Error removing symlink: {e}") -def install_dependencies(app=None): - if config.TARGETVERSION: - targetversion = int(config.TARGETVERSION) +# FIXME: should this be in control? +def install_dependencies(app: App): + if app.conf.faithlife_product_version: + targetversion = int(app.conf.faithlife_product_version) else: targetversion = 10 - msg.status(f"Checking Logos {str(targetversion)} dependencies…", app) + app.status(f"Checking Logos {str(targetversion)} dependencies…") if targetversion == 10: - system.install_dependencies(config.PACKAGES, config.BADPACKAGES, app=app) # noqa: E501 + system.install_dependencies(app, target_version=10) # noqa: E501 elif targetversion == 9: system.install_dependencies( - config.PACKAGES, - config.BADPACKAGES, - config.L9PACKAGES, - app=app + app, + target_version=9 ) else: - logging.error(f"TARGETVERSION not found: {config.TARGETVERSION}.") + logging.error(f"Unknown Target version, expecting 9 or 10 but got: {app.conf.faithlife_product_version}.") #noqa: E501 - if config.DIALOG == "tk": - # FIXME: This should get moved to gui_app. - app.root.event_generate('<>') + app.status("Installed dependencies.", 100) -def file_exists(file_path): +def file_exists(file_path: Optional[str | bytes | Path]) -> bool: if file_path is not None: expanded_path = os.path.expanduser(file_path) return os.path.isfile(expanded_path) @@ -257,44 +138,10 @@ def file_exists(file_path): return False -def change_logos_release_channel(): - if config.logos_release_channel == "stable": - config.logos_release_channel = "beta" - update_config_file( - config.CONFIG_FILE, - 'logos_release_channel', - "beta" - ) - else: - config.logos_release_channel = "stable" - update_config_file( - config.CONFIG_FILE, - 'logos_release_channel', - "stable" - ) - - -def change_lli_release_channel(): - if config.lli_release_channel == "stable": - config.logos_release_channel = "dev" - update_config_file( - config.CONFIG_FILE, - 'lli_release_channel', - "dev" - ) - else: - config.lli_release_channel = "stable" - update_config_file( - config.CONFIG_FILE, - 'lli_release_channel', - "stable" - ) - - -def get_current_logos_version(): - path_regex = f"{config.INSTALLDIR}/data/wine64_bottle/drive_c/users/*/AppData/Local/Logos/System/Logos.deps.json" # noqa: E501 +def get_current_logos_version(install_dir: str) -> Optional[str]: + path_regex = f"{install_dir}/data/wine64_bottle/drive_c/users/*/AppData/Local/Logos/System/Logos.deps.json" # noqa: E501 file_paths = glob.glob(path_regex) - logos_version_number = None + logos_version_number: Optional[str] = None if file_paths: logos_version_file = file_paths[0] with open(logos_version_file, 'r') as json_file: @@ -311,34 +158,7 @@ def get_current_logos_version(): return None else: logging.debug("Logos.deps.json not found.") - - -def convert_logos_release(logos_release): - if logos_release is not None: - ver_major = logos_release.split('.')[0] - ver_minor = logos_release.split('.')[1] - release = logos_release.split('.')[2] - point = logos_release.split('.')[3] - else: - ver_major = 0 - ver_minor = 0 - release = 0 - point = 0 - - logos_release_arr = [ - int(ver_major), - int(ver_minor), - int(release), - int(point), - ] - return logos_release_arr - - -def which_release(): - if config.current_logos_release: - return config.current_logos_release - else: - return config.TARGET_RELEASE_VERSION + return None def check_logos_release_version(version, threshold, check_version_part): @@ -349,11 +169,13 @@ def check_logos_release_version(version, threshold, check_version_part): return False -def filter_versions(versions, threshold, check_version_part): - return [version for version in versions if check_logos_release_version(version, threshold, check_version_part)] # noqa: E501 - - -def get_winebin_code_and_desc(binary): +def get_winebin_code_and_desc(app: App, binary) -> Tuple[str, str | None]: + """Gets the type of wine in use and it's description + + Returns: + code: One of: Recommended, AppImage, System, Proton, PlayOnLinux, Custom + description: Description of the above + """ # Set binary code, description, and path based on path codes = { "Recommended": "Use the recommended AppImage", @@ -374,7 +196,7 @@ def get_winebin_code_and_desc(binary): # Does it work? if isinstance(binary, Path): binary = str(binary) - if binary == f"{config.APPDIR_BINDIR}/{config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME}": # noqa: E501 + if binary == f"{app.conf.installer_binary_dir}/{app.conf.wine_appimage_recommended_file_name}": # noqa: E501 code = "Recommended" elif binary.lower().endswith('.appimage'): code = "AppImage" @@ -391,58 +213,37 @@ def get_winebin_code_and_desc(binary): return code, desc -def get_wine_options(appimages, binaries, app=None) -> Union[List[List[str]], List[str]]: # noqa: E501 +def get_wine_options(app: App) -> List[str]: # noqa: E501 + appimages = app.conf.wine_app_image_files + binaries = app.conf.wine_binary_files logging.debug(f"{appimages=}") logging.debug(f"{binaries=}") wine_binary_options = [] + reccomended_appimage = f"{app.conf.installer_binary_dir}/{app.conf.wine_appimage_recommended_file_name}" # noqa: E501 + # Add AppImages to list - # if config.DIALOG == 'tk': - wine_binary_options.append(f"{config.APPDIR_BINDIR}/{config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME}") # noqa: E501 wine_binary_options.extend(appimages) - # else: - # appimage_entries = [["AppImage", filename, "AppImage of Wine64"] for filename in appimages] # noqa: E501 - # wine_binary_options.append([ - # "Recommended", # Code - # f'{config.APPDIR_BINDIR}/{config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME}', # noqa: E501 - # f"AppImage of Wine64 {config.RECOMMENDED_WINE64_APPIMAGE_FULL_VERSION}" # noqa: E501 - # ]) - # wine_binary_options.extend(appimage_entries) + if reccomended_appimage not in wine_binary_options: + wine_binary_options.append(reccomended_appimage) sorted_binaries = sorted(list(set(binaries))) logging.debug(f"{sorted_binaries=}") - for WINEBIN_PATH in sorted_binaries: - WINEBIN_CODE, WINEBIN_DESCRIPTION = get_winebin_code_and_desc(WINEBIN_PATH) # noqa: E501 + for wine_binary_path in sorted_binaries: + code, description = get_winebin_code_and_desc(app, wine_binary_path) # noqa: E501 # Create wine binary option array - # if config.DIALOG == 'tk': - wine_binary_options.append(WINEBIN_PATH) - # else: - # wine_binary_options.append( - # [WINEBIN_CODE, WINEBIN_PATH, WINEBIN_DESCRIPTION] - # ) - # - # if config.DIALOG != 'tk': - # wine_binary_options.append(["Exit", "Exit", "Cancel installation."]) - + wine_binary_options.append(wine_binary_path) logging.debug(f"{wine_binary_options=}") - if app: - if config.DIALOG != "cli": - app.wines_q.put(wine_binary_options) - if config.DIALOG == 'tk': - app.root.event_generate(app.wine_evt) return wine_binary_options -def get_winetricks_options(): +def get_winetricks_options() -> list[str]: local_winetricks_path = shutil.which('winetricks') - winetricks_options = ['Download', 'Return to Main Menu'] + winetricks_options = ['Download'] if local_winetricks_path is not None: - # Check if local winetricks version is up-to-date. - cmd = ["winetricks", "--version"] - local_winetricks_version = subprocess.check_output(cmd).split()[0] - if str(local_winetricks_version) != config.WINETRICKS_VERSION: + if check_winetricks_version(local_winetricks_path): winetricks_options.insert(0, local_winetricks_path) else: logging.info("Local winetricks is too old.") @@ -450,15 +251,18 @@ def get_winetricks_options(): logging.info("Local winetricks not found.") return winetricks_options +def check_winetricks_version(winetricks_path: str) -> bool: + # Check if local winetricks version matches expected + cmd = [winetricks_path, "--version"] + local_winetricks_version = subprocess.check_output(cmd).split()[0] + return str(local_winetricks_version) == constants.WINETRICKS_VERSION #noqa: E501 + -def get_procs_using_file(file_path, mode=None): +def get_procs_using_file(file_path): procs = set() for proc in psutil.process_iter(['pid', 'open_files', 'name']): try: - if mode is not None: - paths = [f.path for f in proc.open_files() if f.mode == mode] - else: - paths = [f.path for f in proc.open_files()] + paths = [f.path for f in proc.open_files()] if len(paths) > 0 and file_path in paths: procs.add(proc.pid) except psutil.AccessDenied: @@ -493,32 +297,17 @@ def get_procs_using_file(file_path, mode=None): # logging.info("* End of wait_process_using_dir.") -def write_progress_bar(percent, screen_width=80): - y = '.' - n = ' ' - l_f = int(screen_width * 0.75) # progress bar length - l_y = int(l_f * percent / 100) # num. of chars. complete - l_n = l_f - l_y # num. of chars. incomplete - if config.DIALOG == 'curses': - msg.status(f" [{y * l_y}{n * l_n}] {percent:>3}%") - else: - print(f" [{y * l_y}{n * l_n}] {percent:>3}%", end='\r') - - -def app_is_installed(): - return config.LOGOS_EXE is not None and os.access(config.LOGOS_EXE, os.X_OK) # noqa: E501 - - -def find_installed_product() -> Optional[str]: - if config.FLPRODUCT and config.WINEPREFIX: - drive_c = Path(f"{config.WINEPREFIX}/drive_c/") - name = config.FLPRODUCT +def find_installed_product(faithlife_product: str, wine_prefix: str) -> Optional[str]: + if faithlife_product and wine_prefix: + drive_c = Path(f"{wine_prefix}/drive_c/") + name = faithlife_product exe = None for root, _, files in drive_c.walk(follow_symlinks=False): if root.name == name and f"{name}.exe" in files: exe = str(root / f"{name}.exe") break return exe + return None def enough_disk_space(dest_dir, bytes_required): @@ -536,7 +325,7 @@ def get_path_size(file_path): return path_size -def get_folder_group_size(src_dirs, q): +def get_folder_group_size(src_dirs: list[Path], q: queue.Queue[int]): src_size = 0 for d in src_dirs: if not d.is_dir(): @@ -545,15 +334,6 @@ def get_folder_group_size(src_dirs, q): q.put(src_size) -def get_copy_progress(dest_path, txfr_size, dest_size_init=0): - dest_size_now = get_path_size(dest_path) - if dest_size_now is None: - dest_size_now = 0 - size_diff = dest_size_now - dest_size_init - progress = round(size_diff / txfr_size * 100) - return progress - - def get_latest_folder(folder_path): folders = [f for f in Path(folder_path).glob('*')] if not folders: @@ -567,81 +347,63 @@ def get_latest_folder(folder_path): def install_premade_wine_bottle(srcdir, appdir): - msg.status(f"Extracting: '{config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME}' into: {appdir}") # noqa: E501 + logging.info(f"Extracting: '{constants.LOGOS9_WINE64_BOTTLE_TARGZ_NAME}' into: {appdir}") # noqa: E501 shutil.unpack_archive( - f"{srcdir}/{config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME}", + f"{srcdir}/{constants.LOGOS9_WINE64_BOTTLE_TARGZ_NAME}", appdir ) +class VersionComparison(enum.Enum): + OUT_OF_DATE = enum.auto() + UP_TO_DATE = enum.auto() + DEVELOPMENT = enum.auto() + -def compare_logos_linux_installer_version( - current=config.LLI_CURRENT_VERSION, - latest=config.LLI_LATEST_VERSION, -): - # NOTE: The above params evaluate the variables when the module is - # imported. The following re-evaluates when the function is called. - if latest is None: - latest = config.LLI_LATEST_VERSION +def compare_logos_linux_installer_version(app: App) -> Optional[VersionComparison]: + current = constants.LLI_CURRENT_VERSION + latest = app.conf.app_latest_version - # Check if status has already been evaluated. - if config.logos_linux_installer_status is not None: - status = config.logos_linux_installer_status - message = config.logos_linux_installer_status_info.get(status) - return status, message + if version.parse(current) < version.parse(latest): + # Current release is older than recommended. + output = VersionComparison.OUT_OF_DATE + elif version.parse(current) > version.parse(latest): + # Installed version is custom. + output = VersionComparison.DEVELOPMENT + elif version.parse(current) == version.parse(latest): + # Current release is latest. + output = VersionComparison.UP_TO_DATE + logging.debug(f"LLI self-update check: {output=}") + return output + + +def compare_recommended_appimage_version(app: App): status = None message = None - if current is not None and latest is not None: - if version.parse(current) < version.parse(latest): + wine_exe_path = app.conf.wine_binary + wine_release, error_message = wine.get_wine_release(wine_exe_path) + if wine_release is not None and wine_release is not False: + current_version = f"{wine_release.major}.{wine_release.minor}" + logging.debug(f"Current wine release: {current_version}") + + recommended_version = app.conf.wine_appimage_recommended_version + logging.debug(f"Recommended wine release: {recommended_version}") + if current_version < recommended_version: # Current release is older than recommended. status = 0 - elif version.parse(current) == version.parse(latest): + message = "yes" + elif current_version == recommended_version: # Current release is latest. status = 1 - elif version.parse(current) > version.parse(latest): - # Installed version is custom. + message = "uptodate" + elif current_version > recommended_version: + # Installed version is custom status = 2 - - config.logos_linux_installer_status = status - message = config.logos_linux_installer_status_info.get(status) - logging.debug(f"LLI self-update check: {status=}; {message=}") - return status, message - - -def compare_recommended_appimage_version(): - status = None - message = None - wine_release = [] - wine_exe_path = get_wine_exe_path() - if wine_exe_path is not None: - wine_release, error_message = wine.get_wine_release(wine_exe_path) - if wine_release is not None and wine_release is not False: - current_version = '.'.join([str(n) for n in wine_release[:2]]) - logging.debug(f"Current wine release: {current_version}") - - if config.RECOMMENDED_WINE64_APPIMAGE_VERSION: - logging.debug(f"Recommended wine release: {config.RECOMMENDED_WINE64_APPIMAGE_VERSION}") # noqa: E501 - if current_version < config.RECOMMENDED_WINE64_APPIMAGE_VERSION: # noqa: E501 - # Current release is older than recommended. - status = 0 - message = "yes" - elif current_version == config.RECOMMENDED_WINE64_APPIMAGE_VERSION: # noqa: E501 - # Current release is latest. - status = 1 - message = "uptodate" - elif current_version > config.RECOMMENDED_WINE64_APPIMAGE_VERSION: # noqa: E501 - # Installed version is custom - status = 2 - message = "no" - else: - status = False - message = f"Error: {error_message}" - else: - status = False - message = f"Error: {error_message}" + message = "no" else: - status = False - message = "config.WINE_EXE is not set." + # FIXME: should this raise an exception? + status = -1 + message = f"Error: {error_message}" logging.debug(f"{status=}; {message=}") return status, message @@ -711,15 +473,16 @@ def check_appimage(filestr): return False -def find_appimage_files(release_version, app=None): +def find_appimage_files(app: App) -> list[str]: + release_version = app.conf.installed_faithlife_product_release or app.conf.faithlife_product_version #noqa: E501 appimages = [] directories = [ os.path.expanduser("~") + "/bin", - config.APPDIR_BINDIR, - config.MYDOWNLOADS + app.conf.installer_binary_dir, + app.conf.download_dir ] - if config.CUSTOMBINPATH is not None: - directories.append(config.CUSTOMBINPATH) + if app.conf._overrides.custom_binary_path is not None: + directories.append(app.conf._overrides.custom_binary_path) if sys.version_info < (3, 12): raise RuntimeError("Python 3.12 or higher is required for .rglob() flag `case-sensitive` ") # noqa: E501 @@ -731,20 +494,17 @@ def find_appimage_files(release_version, app=None): output1, output2 = wine.check_wine_version_and_branch( release_version, p, + app.conf.faithlife_product_version ) if output1 is not None and output1: appimages.append(str(p)) else: logging.info(f"AppImage file {p} not added: {output2}") - if app: - app.appimage_q.put(appimages) - app.root.event_generate(app.appimage_evt) - return appimages -def find_wine_binary_files(release_version): +def find_wine_binary_files(app: App, release_version: Optional[str]) -> list[str]: wine_binary_path_list = [ "/usr/local/bin", os.path.expanduser("~") + "/bin", @@ -752,13 +512,11 @@ def find_wine_binary_files(release_version): os.path.expanduser("~") + "/.steam/steam/steamapps/common/Proton*/files/bin", # noqa: E501 ] - if config.CUSTOMBINPATH is not None: - wine_binary_path_list.append(config.CUSTOMBINPATH) + if app.conf._overrides.custom_binary_path is not None: + wine_binary_path_list.append(app.conf._overrides.custom_binary_path) # Temporarily modify PATH for additional WINE64 binaries. for p in wine_binary_path_list: - if p is None: - continue if p not in os.environ['PATH'] and os.path.isdir(p): os.environ['PATH'] = os.environ['PATH'] + os.pathsep + p @@ -774,6 +532,7 @@ def find_wine_binary_files(release_version): output1, output2 = wine.check_wine_version_and_branch( release_version, binary, + app.conf.faithlife_product_version ) if output1 is not None and output1: continue @@ -784,93 +543,72 @@ def find_wine_binary_files(release_version): return binaries -def set_appimage_symlink(app=None): +def set_appimage_symlink(app: App): # This function assumes make_skel() has been run once. - # if config.APPIMAGE_FILE_PATH is None: - # config.APPIMAGE_FILE_PATH = config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME # noqa: E501 - - logging.debug(f"{config.APPIMAGE_FILE_PATH=}") - logging.debug(f"{config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME=}") - appimage_file_path = Path(config.APPIMAGE_FILE_PATH) - appdir_bindir = Path(config.APPDIR_BINDIR) - appimage_symlink_path = appdir_bindir / config.APPIMAGE_LINK_SELECTION_NAME - if appimage_file_path.name == config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME: # noqa: E501 + if app.conf.wine_binary_code not in ["AppImage", "Recommended"]: + logging.debug("AppImage commands disabled since we're not using an appimage") # noqa: E501 + return + if app.conf.wine_appimage_path is None: + logging.debug("No need to set appimage symlink, as it wasn't set") + return + + logging.debug(f"{app.conf.wine_appimage_path=}") + logging.debug(f"{app.conf.wine_appimage_recommended_file_name=}") + appimage_file_path = Path(app.conf.wine_appimage_path) + appdir_bindir = Path(app.conf.installer_binary_dir) + appimage_symlink_path = appdir_bindir / app.conf.wine_appimage_link_file_name + + destination_file_path = appdir_bindir / appimage_file_path.name # noqa: E501 + + if appimage_file_path.name == app.conf.wine_appimage_recommended_file_name: # noqa: E501 # Default case. - network.get_recommended_appimage() - selected_appimage_file_path = Path(config.APPDIR_BINDIR) / appimage_file_path.name # noqa: E501 - bindir_appimage = selected_appimage_file_path / config.APPDIR_BINDIR - if not bindir_appimage.exists(): - logging.info(f"Copying {selected_appimage_file_path} to {config.APPDIR_BINDIR}.") # noqa: E501 - shutil.copy(selected_appimage_file_path, f"{config.APPDIR_BINDIR}") + # This saves in the install binary dir + network.dwonload_recommended_appimage(app) else: - selected_appimage_file_path = appimage_file_path # Verify user-selected AppImage. - if not check_appimage(selected_appimage_file_path): - msg.logos_error(f"Cannot use {selected_appimage_file_path}.") + if not check_appimage(appimage_file_path): + app.exit(f"Cannot use {appimage_file_path}.") - # Determine if user wants their AppImage in the app bin dir. - copy_message = ( - f"Should the program copy {selected_appimage_file_path} to the" - f" {config.APPDIR_BINDIR} directory?" - ) - # FIXME: What if user cancels the confirmation dialog? - if config.DIALOG == "tk": - # TODO: With the GUI this runs in a thread. It's not clear if the - # messagebox will work correctly. It may need to be triggered from - # here with an event and then opened from the main thread. - tk_root = tk.Tk() - tk_root.withdraw() - confirm = tk.messagebox.askquestion("Confirmation", copy_message) - tk_root.destroy() - elif config.DIALOG in ['curses', 'dialog']: - confirm = tui.confirm("Confirmation", copy_message) - elif config.DIALOG == 'cli': - confirm = msg.logos_acknowledge_question(copy_message, '', '') - - # Copy AppImage if confirmed. - if confirm is True or confirm == 'yes': - logging.info(f"Copying {selected_appimage_file_path} to {config.APPDIR_BINDIR}.") # noqa: E501 - dest = appdir_bindir / selected_appimage_file_path.name - if not dest.exists(): - shutil.copy(selected_appimage_file_path, f"{config.APPDIR_BINDIR}") # noqa: E501 - selected_appimage_file_path = dest + if destination_file_path != appimage_file_path: + logging.info(f"Copying {destination_file_path} to {app.conf.installer_binary_dir}.") # noqa: E501 + shutil.copy(appimage_file_path, destination_file_path) delete_symlink(appimage_symlink_path) - os.symlink(selected_appimage_file_path, appimage_symlink_path) - config.SELECTED_APPIMAGE_FILENAME = f"{selected_appimage_file_path.name}" # noqa: E501 + os.symlink(destination_file_path, appimage_symlink_path) + app.conf.wine_appimage_path = destination_file_path # noqa: E501 - write_config(config.CONFIG_FILE) - if config.DIALOG == 'tk': - app.root.event_generate("<>") - -def update_to_latest_lli_release(app=None): - status, _ = compare_logos_linux_installer_version() +def update_to_latest_lli_release(app: App): + result = compare_logos_linux_installer_version(app) if system.get_runmode() != 'binary': - logging.error(f"Can't update {config.name_app} when run as a script.") - elif status == 0: + logging.error(f"Can't update {constants.APP_NAME} when run as a script.") + elif result == VersionComparison.OUT_OF_DATE: network.update_lli_binary(app=app) - elif status == 1: - logging.debug(f"{config.LLI_TITLE} is already at the latest version.") - elif status == 2: - logging.debug(f"{config.LLI_TITLE} is at a newer version than the latest.") # noqa: 501 - - -def update_to_latest_recommended_appimage(): - config.APPIMAGE_FILE_PATH = config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME # noqa: E501 - status, _ = compare_recommended_appimage_version() + elif result == VersionComparison.UP_TO_DATE: + logging.debug(f"{constants.APP_NAME} is already at the latest version.") + elif result == VersionComparison.DEVELOPMENT: + logging.debug(f"{constants.APP_NAME} is at a newer version than the latest.") # noqa: 501 + + +# FIXME: consider moving this to control +def update_to_latest_recommended_appimage(app: App): + if app.conf.wine_binary_code not in ["AppImage", "Recommended"]: + logging.debug("AppImage commands disabled since we're not using an appimage") # noqa: E501 + return + app.conf.wine_appimage_path = Path(app.conf.wine_appimage_recommended_file_name) # noqa: E501 + status, _ = compare_recommended_appimage_version(app) if status == 0: - set_appimage_symlink() + set_appimage_symlink(app) elif status == 1: logging.debug("The AppImage is already set to the latest recommended.") elif status == 2: logging.debug("The AppImage version is newer than the latest recommended.") # noqa: E501 -def get_downloaded_file_path(filename): +def get_downloaded_file_path(download_dir: str, filename: str): dirs = [ - config.MYDOWNLOADS, + Path(download_dir), Path.home(), Path.cwd(), ] @@ -882,15 +620,6 @@ def get_downloaded_file_path(filename): logging.debug(f"File not found: {filename}") -def send_task(app, task): - # logging.debug(f"{task=}") - app.todo_q.put(task) - if config.DIALOG == 'tk': - app.root.event_generate('<>') - elif config.DIALOG == 'curses': - app.task_processor(app, task=task) - - def grep(regexp, filepath): fp = Path(filepath) found = False @@ -906,27 +635,6 @@ def grep(regexp, filepath): return found -def start_thread(task, *args, daemon_bool=True, **kwargs): - thread = threading.Thread( - name=f"{task}", - target=task, - daemon=daemon_bool, - args=args, - kwargs=kwargs - ) - config.threads.append(thread) - thread.start() - return thread - - -def str_array_to_string(text, delimeter="\n"): - try: - processed_text = delimeter.join(text) - return processed_text - except TypeError: - return text - - def untar_file(file_path, output_dir): if not os.path.exists(output_dir): os.makedirs(output_dir) @@ -939,13 +647,13 @@ def untar_file(file_path, output_dir): logging.error(f"Error extracting '{file_path}': {e}") -def is_relative_path(path): +def is_relative_path(path: str | Path) -> bool: if isinstance(path, str): path = Path(path) return not path.is_absolute() -def get_relative_path(path, base_path): +def get_relative_path(path: Path | str, base_path: str) -> str | Path: if is_relative_path(path): return path else: @@ -958,47 +666,6 @@ def get_relative_path(path, base_path): return path -def create_dynamic_path(path, base_path): - if is_relative_path(path): - if isinstance(path, str): - path = Path(path) - if isinstance(base_path, str): - base_path = Path(base_path) - logging.debug(f"dynamic_path: {base_path / path}") - return base_path / path - else: - logging.debug(f"dynamic_path: {Path(path)}") - return Path(path) - - -def get_config_var(var): - if var is not None: - if callable(var): - return var() - return var - else: - return None - - -def get_wine_exe_path(path=None): - if path is not None: - path = get_relative_path(get_config_var(path), config.INSTALLDIR) - wine_exe_path = Path(create_dynamic_path(path, config.INSTALLDIR)) - logging.debug(f"{wine_exe_path=}") - return wine_exe_path - else: - if config.WINE_EXE is not None: - path = get_relative_path( - get_config_var(config.WINE_EXE), - config.INSTALLDIR - ) - wine_exe_path = Path(create_dynamic_path(path, config.INSTALLDIR)) - logging.debug(f"{wine_exe_path=}") - return wine_exe_path - else: - return None - - def stopwatch(start_time=None, interval=10.0): if start_time is None: start_time = time.time() @@ -1011,3 +678,9 @@ def stopwatch(start_time=None, interval=10.0): return True, last_log_time else: return False, start_time + +def get_timestamp(): + return datetime.today().strftime('%Y-%m-%dT%H%M%S') + +def parse_bool(string: str) -> bool: + return string.lower() in ['true', '1', 'y', 'yes'] \ No newline at end of file diff --git a/ou_dedetai/wine.py b/ou_dedetai/wine.py index 844b1d83..c52adea3 100644 --- a/ou_dedetai/wine.py +++ b/ou_dedetai/wine.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass import logging import os import re @@ -5,88 +6,62 @@ import signal import subprocess from pathlib import Path +import tempfile from typing import Optional -from . import config -from . import msg +from ou_dedetai.app import App + from . import network from . import system from . import utils -from .config import processes - - -def get_wine_user(): - path: Optional[str] = config.LOGOS_EXE - normalized_path: str = os.path.normpath(path) - path_parts = normalized_path.split(os.sep) - config.wine_user = path_parts[path_parts.index('users') + 1] - - -def set_logos_paths(): - if config.wine_user is None: - get_wine_user() - logos_cmds = [ - config.logos_cef_cmd, - config.logos_indexer_cmd, - config.logos_login_cmd, - ] - if None in logos_cmds: - config.logos_cef_cmd = f'C:\\users\\{config.wine_user}\\AppData\\Local\\Logos\\System\\LogosCEF.exe' # noqa: E501 - config.logos_indexer_cmd = f'C:\\users\\{config.wine_user}\\AppData\\Local\\Logos\\System\\LogosIndexer.exe' # noqa: E501 - config.logos_login_cmd = f'C:\\users\\{config.wine_user}\\AppData\\Local\\Logos\\System\\Logos.exe' # noqa: E501 - config.logos_indexer_exe = str(Path(utils.find_installed_product()).parent / 'System' / 'LogosIndexer.exe') # noqa: E501 - - -def check_wineserver(): +def check_wineserver(app: App): + # FIXME: if the wine version changes, we may need to restart the wineserver + # (or at least kill it). Gotten into several states in dev where this happend + # Normally when an msi install failed try: - process = run_wine_proc(config.WINESERVER, exe_args=["-p"]) - wait_pid(process) + process = run_wine_proc(app.conf.wineserver_binary, app, exe_args=["-p"]) + if not process: + logging.debug("Failed to spawn wineserver to check it") + return False + system.wait_pid(process) return process.returncode == 0 except Exception: return False -def wineserver_kill(): - if check_wineserver(): - process = run_wine_proc(config.WINESERVER_EXE, exe_args=["-k"]) - wait_pid(process) +def wineserver_kill(app: App): + if check_wineserver(app): + process = run_wine_proc(app.conf.wineserver_binary, app, exe_args=["-k"]) + if not process: + logging.debug("Failed to spawn wineserver to kill it") + return False + system.wait_pid(process) -def wineserver_wait(): - if check_wineserver(): - process = run_wine_proc(config.WINESERVER_EXE, exe_args=["-w"]) - wait_pid(process) +def wineserver_wait(app: App): + if check_wineserver(app): + process = run_wine_proc(app.conf.wineserver_binary, app, exe_args=["-w"]) + if not process: + logging.debug("Failed to spawn wineserver to wait for it") + return False + system.wait_pid(process) -# def light_wineserver_wait(): -# command = [f"{config.WINESERVER_EXE}", "-w"] -# system.wait_on(command) +@dataclass +class WineRelease: + major: int + minor: int + release: Optional[str] -# def heavy_wineserver_wait(): -# utils.wait_process_using_dir(config.WINEPREFIX) -# # system.wait_on([f"{config.WINESERVER_EXE}", "-w"]) -# wineserver_wait() - - -def end_wine_processes(): - for process_name, process in processes.items(): - if isinstance(process, subprocess.Popen): - logging.debug(f"Found {process_name} in Processes. Attempting to close {process}.") # noqa: E501 - try: - process.terminate() - process.wait(timeout=10) - except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGTERM) - wait_pid(process) - - -def get_wine_release(binary): +# FIXME: consider raising exceptions on error +def get_wine_release(binary: str) -> tuple[Optional[WineRelease], str]: cmd = [binary, "--version"] try: version_string = subprocess.check_output(cmd, encoding='utf-8').strip() logging.debug(f"Version string: {str(version_string)}") + release: Optional[str] try: version, release = version_string.split() except ValueError: @@ -97,76 +72,78 @@ def get_wine_release(binary): logging.debug(f"Wine branch of {binary}: {release}") if release is not None: - ver_major = version.split('.')[0].lstrip('wine-') # remove 'wine-' - ver_minor = version.split('.')[1] + ver_major = int(version.split('.')[0].lstrip('wine-')) # remove 'wine-' + ver_minor = int(version.split('.')[1]) release = release.lstrip('(').rstrip(')').lower() # remove parens else: ver_major = 0 ver_minor = 0 - wine_release = [int(ver_major), int(ver_minor), release] + wine_release = WineRelease(ver_major, ver_minor, release) logging.debug(f"Wine release of {binary}: {str(wine_release)}") if ver_major == 0: - return False, "Couldn't determine wine version." + return None, "Couldn't determine wine version." else: return wine_release, "yes" except subprocess.CalledProcessError as e: - return False, f"Error running command: {e}" + return None, f"Error running command: {e}" except ValueError as e: - return False, f"Error parsing version: {e}" + return None, f"Error parsing version: {e}" except Exception as e: - return False, f"Error: {e}" + return None, f"Error: {e}" -def check_wine_rules(wine_release, release_version): +@dataclass +class WineRule: + major: int + proton: bool + minor_bad: list[int] + allowed_releases: list[str] + devel_allowed: Optional[int] = None + + +def check_wine_rules( + wine_release: Optional[WineRelease], + release_version: Optional[str], + faithlife_product_version: str +): # Does not check for Staging. Will not implement: expecting merging of # commits in time. logging.debug(f"Checking {wine_release} for {release_version}.") - if config.TARGETVERSION == "10": - if utils.check_logos_release_version(release_version, 30, 1): + if faithlife_product_version == "10": + if release_version is not None and utils.check_logos_release_version(release_version, 30, 1): #noqa: E501 required_wine_minimum = [7, 18] else: required_wine_minimum = [9, 10] - elif config.TARGETVERSION == "9": + elif faithlife_product_version == "9": required_wine_minimum = [7, 0] else: - raise ValueError(f"Invalid TARGETVERSION: {config.TARGETVERSION} ({type(config.TARGETVERSION)})") # noqa: E501 - - rules = [ - { - "major": 7, - "proton": True, # Proton release tend to use the x.0 release, but can include changes found in devel/staging # noqa: E501 - "minor_bad": [], # exceptions to minimum - "allowed_releases": ["staging"] - }, - { - "major": 8, - "proton": False, - "minor_bad": [0], - "allowed_releases": ["staging"], - "devel_allowed": 16, # devel permissible at this point - }, - { - "major": 9, - "proton": False, - "minor_bad": [], - "allowed_releases": ["devel", "staging"], - }, + raise ValueError(f"Invalid target version, expecting 9 or 10 but got: {faithlife_product_version} ({type(faithlife_product_version)})") # noqa: E501 + + rules: list[WineRule] = [ + # Proton release tend to use the x.0 release, but can include changes found in devel/staging # noqa: E501 + # exceptions to minimum + WineRule(major=7, proton=True, minor_bad=[], allowed_releases=["staging"]), + # devel permissible at this point + WineRule(major=8, proton=False, minor_bad=[0], allowed_releases=["staging"], devel_allowed=16), #noqa: E501 + WineRule(major=9, proton=False, minor_bad=[], allowed_releases=["devel", "staging"]) #noqa: E501 ] major_min, minor_min = required_wine_minimum if wine_release: - major, minor, release_type = wine_release + major = wine_release.major + minor = wine_release.minor + release_type = wine_release.release result = True, "None" # Whether the release is allowed; error message for rule in rules: - if major == rule["major"]: + if major == rule.major: # Verify release is allowed - if release_type not in rule["allowed_releases"]: - if minor >= rule.get("devel_allowed", float('inf')): + if release_type not in rule.allowed_releases: + if minor >= (rule.devel_allowed or float('inf')): if release_type not in ["staging", "devel"]: result = ( False, @@ -180,13 +157,13 @@ def check_wine_rules(wine_release, release_version): result = ( False, ( - f"Wine release needs to be {rule['allowed_releases']}. " # noqa: E501 + f"Wine release needs to be {rule.allowed_releases}. " # noqa: E501 f"Current release: {release_type}." ) ) break # Verify version is allowed - if minor in rule.get("minor_bad", []): + if minor in rule.minor_bad: result = False, f"Wine version {major}.{minor} will not work." break if major < major_min: @@ -198,7 +175,7 @@ def check_wine_rules(wine_release, release_version): ) break elif major == major_min and minor < minor_min: - if not rule["proton"]: + if not rule.proton: result = ( False, ( @@ -212,7 +189,8 @@ def check_wine_rules(wine_release, release_version): return True, "Default to trusting user override" -def check_wine_version_and_branch(release_version, test_binary): +def check_wine_version_and_branch(release_version: Optional[str], test_binary, + faithlife_product_version): if not os.path.exists(test_binary): reason = "Binary does not exist." return False, reason @@ -223,100 +201,114 @@ def check_wine_version_and_branch(release_version, test_binary): wine_release, error_message = get_wine_release(test_binary) - if wine_release is False and error_message is not None: + if wine_release is None: return False, error_message - result, message = check_wine_rules(wine_release, release_version) + result, message = check_wine_rules( + wine_release, + release_version, + faithlife_product_version + ) if not result: return result, message - if wine_release[0] > 9: + if wine_release.major > 9: pass return True, "None" -def initializeWineBottle(app=None): - msg.status("Initializing wine bottle…") - wine_exe = str(utils.get_wine_exe_path().parent / 'wine64') - logging.debug(f"{wine_exe=}") +def initializeWineBottle(wine64_binary: str, app: App) -> Optional[subprocess.Popen[bytes]]: #noqa: E501 + app.status("Initializing wine bottle…") + logging.debug(f"{wine64_binary=}") # Avoid wine-mono window - orig_overrides = config.WINEDLLOVERRIDES - config.WINEDLLOVERRIDES = f"{config.WINEDLLOVERRIDES};mscoree=" - logging.debug(f"Running: {wine_exe} wineboot --init") + wine_dll_override="mscoree=" + logging.debug(f"Running: {wine64_binary} wineboot --init") process = run_wine_proc( - wine_exe, + wine64_binary, + app=app, exe='wineboot', exe_args=['--init'], - init=True + init=True, + additional_wine_dll_overrides=wine_dll_override ) - config.WINEDLLOVERRIDES = orig_overrides return process -def wine_reg_install(reg_file): +def wine_reg_install(app: App, reg_file, wine64_binary): reg_file = str(reg_file) - msg.status(f"Installing registry file: {reg_file}") + app.status(f"Installing registry file: {reg_file}") process = run_wine_proc( - str(utils.get_wine_exe_path().parent / 'wine64'), + wine64_binary, + app=app, exe="regedit.exe", exe_args=[reg_file] ) - # NOTE: For some reason wait_pid results in the reg install failing. - # wait_pid(process) + # NOTE: For some reason system.wait_pid results in the reg install failing. + # system.wait_pid(process) + if process is None: + app.exit("Failed to spawn command to install reg file") process.wait() if process is None or process.returncode != 0: failed = "Failed to install reg file" logging.debug(f"{failed}. {process=}") - msg.logos_error(f"{failed}: {reg_file}") + app.exit(f"{failed}: {reg_file}") elif process.returncode == 0: logging.info(f"{reg_file} installed.") - # light_wineserver_wait() - wineserver_wait() + wineserver_wait(app) -def disable_winemenubuilder(): - reg_file = Path(config.WORKDIR) / 'disable-winemenubuilder.reg' +def disable_winemenubuilder(app: App, wine64_binary: str): + workdir = tempfile.mkdtemp() + reg_file = Path(workdir) / 'disable-winemenubuilder.reg' reg_file.write_text(r'''REGEDIT4 [HKEY_CURRENT_USER\Software\Wine\DllOverrides] "winemenubuilder.exe"="" ''') - wine_reg_install(reg_file) + wine_reg_install(app, reg_file, wine64_binary) + shutil.rmtree(workdir) -def install_msi(app=None): - msg.status(f"Running MSI installer: {config.LOGOS_EXECUTABLE}.", app) +def install_msi(app: App): + app.status(f"Running MSI installer: {app.conf.faithlife_installer_name}.") # Execute the .MSI - wine_exe = str(utils.get_wine_exe_path().parent / 'wine64') - exe_args = ["/i", f"{config.INSTALLDIR}/data/{config.LOGOS_EXECUTABLE}"] - if config.PASSIVE is True: + wine_exe = app.conf.wine64_binary + exe_args = ["/i", f"{app.conf.install_dir}/data/{app.conf.faithlife_installer_name}"] #noqa: E501 + if app.conf._overrides.faithlife_install_passive is True: exe_args.append('/passive') logging.info(f"Running: {wine_exe} msiexec {' '.join(exe_args)}") - process = run_wine_proc(wine_exe, exe="msiexec", exe_args=exe_args) + process = run_wine_proc(wine_exe, app, exe="msiexec", exe_args=exe_args) return process -def wait_pid(process): - os.waitpid(-process.pid, 0) - - -def run_wine_proc(winecmd, exe=None, exe_args=list(), init=False): +def get_winecmd_encoding(app: App) -> Optional[str]: + # Get wine system's cmd.exe encoding for proper decoding to UTF8 later. + logging.debug("Getting wine system's cmd.exe encoding.") + registry_value = get_registry_value( + 'HKCU\\Software\\Wine\\Fonts', + 'Codepages', + app + ) + if registry_value is not None: + codepages: str = registry_value.split(',') + return codepages[-1] + else: + m = "wine.wine_proc: wine.get_registry_value returned None." + logging.error(m) + return None + + +def run_wine_proc( + winecmd, + app: App, + exe=None, + exe_args=list(), + init=False, + additional_wine_dll_overrides: Optional[str] = None +) -> Optional[subprocess.Popen[bytes]]: logging.debug("Getting wine environment.") - env = get_wine_env() - if not init and config.WINECMD_ENCODING is None: - # Get wine system's cmd.exe encoding for proper decoding to UTF8 later. - logging.debug("Getting wine system's cmd.exe encoding.") - registry_value = get_registry_value( - 'HKCU\\Software\\Wine\\Fonts', - 'Codepages' - ) - if registry_value is not None: - codepages = registry_value.split(',') # noqa: E501 - config.WINECMD_ENCODING = codepages[-1] - else: - m = "wine.wine_proc: wine.get_registry_value returned None." - logging.error(m) + env = get_wine_env(app, additional_wine_dll_overrides) if isinstance(winecmd, Path): winecmd = str(winecmd) logging.debug(f"run_wine_proc: {winecmd}; {exe=}; {exe_args=}") @@ -328,11 +320,10 @@ def run_wine_proc(winecmd, exe=None, exe_args=list(), init=False): command.extend(exe_args) cmd = f"subprocess cmd: '{' '.join(command)}'" - with open(config.wine_log, 'a') as wine_log: - print(f"{config.get_timestamp()}: {cmd}", file=wine_log) logging.debug(cmd) try: - with open(config.wine_log, 'a') as wine_log: + with open(app.conf.app_wine_log_path, 'a') as wine_log: + print(f"{utils.get_timestamp()}: {cmd}", file=wine_log) process = system.popen_command( command, stdout=wine_log, @@ -341,8 +332,6 @@ def run_wine_proc(winecmd, exe=None, exe_args=list(), init=False): start_new_session=True ) if process is not None: - if exe is not None and isinstance(process, subprocess.Popen): - config.processes[exe] = process if process.poll() is None and process.stdout is not None: with process.stdout: for line in iter(process.stdout.readline, b''): @@ -352,10 +341,10 @@ def run_wine_proc(winecmd, exe=None, exe_args=list(), init=False): try: logging.info(line.decode().rstrip()) except UnicodeDecodeError: - if config.WINECMD_ENCODING is not None: - logging.info(line.decode(config.WINECMD_ENCODING).rstrip()) # noqa: E501 + if not init and app.conf.wine_output_encoding is not None: # noqa: E501 + logging.info(line.decode(app.conf.wine_output_encoding).rstrip()) # noqa: E501 else: - logging.error("wine.run_wine_proc: Error while decoding: WINECMD_ENCODING is None.") # noqa: E501 + logging.error("wine.run_wine_proc: Error while decoding: wine output encoding could not be found.") # noqa: E501 return process else: return None @@ -366,63 +355,54 @@ def run_wine_proc(winecmd, exe=None, exe_args=list(), init=False): return process -def run_winetricks(cmd=None): - process = run_wine_proc(config.WINETRICKSBIN, exe=cmd) - wait_pid(process) - wineserver_wait() - - -def run_winetricks_cmd(*args): +def run_winetricks(app: App, *args): cmd = [*args] - msg.status(f"Running winetricks \"{args[-1]}\"") + if "-q" not in args and app.conf.winetricks_binary: + cmd.insert(0, "-q") logging.info(f"running \"winetricks {' '.join(cmd)}\"") - process = run_wine_proc(config.WINETRICKSBIN, exe_args=cmd) - wait_pid(process) + process = run_wine_proc(app.conf.winetricks_binary, app, exe_args=cmd) + if process is None: + app.exit("Failed to spawn winetricks") + system.wait_pid(process) logging.info(f"\"winetricks {' '.join(cmd)}\" DONE!") - # heavy_wineserver_wait() - wineserver_wait() - logging.debug(f"procs using {config.WINEPREFIX}:") - for proc in utils.get_procs_using_file(config.WINEPREFIX): + wineserver_wait(app) + logging.debug(f"procs using {app.conf.wine_prefix}:") + for proc in utils.get_procs_using_file(app.conf.wine_prefix): logging.debug(f"{proc=}") else: logging.debug('') -def install_d3d_compiler(): +def install_d3d_compiler(app: App): cmd = ['d3dcompiler_47'] - if config.WINETRICKS_UNATTENDED is None: - cmd.insert(0, '-q') - run_winetricks_cmd(*cmd) + run_winetricks(app, *cmd) -def install_fonts(): - msg.status("Configuring fonts…") +def install_fonts(app: App): fonts = ['corefonts', 'tahoma'] - if not config.SKIP_FONTS: - for f in fonts: + if not app.conf.skip_install_fonts: + for i, f in enumerate(fonts): + app.status(f"Configuring font: {f}…", i / len(fonts)) # noqa: E501 args = [f] - if config.WINETRICKS_UNATTENDED: - args.insert(0, '-q') - run_winetricks_cmd(*args) + run_winetricks(app, *args) -def install_font_smoothing(): - msg.status("Setting font smoothing…") +def install_font_smoothing(app: App): + logging.info("Setting font smoothing…") args = ['settings', 'fontsmooth=rgb'] - if config.WINETRICKS_UNATTENDED: - args.insert(0, '-q') - run_winetricks_cmd(*args) + run_winetricks(app, *args) -def set_renderer(renderer): - run_winetricks_cmd("-q", "settings", f"renderer={renderer}") +def set_renderer(app: App, renderer: str): + run_winetricks(app, "-q", "settings", f"renderer={renderer}") -def set_win_version(exe, windows_version): +def set_win_version(app: App, exe: str, windows_version: str): if exe == "logos": - run_winetricks_cmd('-q', 'settings', f'{windows_version}') + run_winetricks(app, '-q', 'settings', f'{windows_version}') + elif exe == "indexer": - reg = f"HKCU\\Software\\Wine\\AppDefaults\\{config.FLPRODUCT}Indexer.exe" # noqa: E501 + reg = f"HKCU\\Software\\Wine\\AppDefaults\\{app.conf.faithlife_product}Indexer.exe" # noqa: E501 exe_args = [ 'add', reg, @@ -431,33 +411,44 @@ def set_win_version(exe, windows_version): "/d", f"{windows_version}", "/f", ] process = run_wine_proc( - str(utils.get_wine_exe_path()), + app.conf.wine_binary, + app, exe='reg', exe_args=exe_args ) - wait_pid(process) - - -def enforce_icu_data_files(app=None): - repo = "FaithLife-Community/icu" - json_data = network.get_latest_release_data(repo) - icu_url = network.get_first_asset_url(json_data) - icu_latest_version = network.get_tag_name(json_data).lstrip('v') + if process is None: + app.exit("Failed to spawn command to set windows version for indexer") + system.wait_pid(process) + + +# FIXME: Consider when to re-run this if it changes. +# Perhaps we should have a "apply installation updates" +# or similar mechanism to ensure all of our latest methods are installed +# including but not limited to: system packages, winetricks options, +# icu files, fonts, registry edits, etc. +# +# Seems like we want to have a more holistic mechanism for ensuring +# all users use the latest and greatest. +# Sort of like an update, but for wine and all of the bits underneath "Logos" itself +def enforce_icu_data_files(app: App): + app.status("Downloading ICU files…") + icu_url = app.conf.icu_latest_version_url + icu_latest_version = app.conf.icu_latest_version - if icu_url is None: - logging.critical(f"Unable to set {config.name_app} release without URL.") # noqa: E501 - return icu_filename = os.path.basename(icu_url).removesuffix(".tar.gz") # Append the version to the file name so it doesn't collide with previous versions icu_filename = f"{icu_filename}-{icu_latest_version}.tar.gz" network.logos_reuse_download( icu_url, icu_filename, - config.MYDOWNLOADS, + app.conf.download_dir, app=app ) - drive_c = f"{config.INSTALLDIR}/data/wine64_bottle/drive_c" - utils.untar_file(f"{config.MYDOWNLOADS}/{icu_filename}", drive_c) + + app.status("Copying ICU files…") + + drive_c = f"{app.conf.wine_prefix}/drive_c" + utils.untar_file(f"{app.conf.download_dir}/{icu_filename}", drive_c) # Ensure the target directory exists icu_win_dir = f"{drive_c}/icu-win/windows" @@ -465,28 +456,24 @@ def enforce_icu_data_files(app=None): os.makedirs(icu_win_dir) shutil.copytree(icu_win_dir, f"{drive_c}/windows", dirs_exist_ok=True) - if hasattr(app, 'status_evt'): - app.status_q.put("ICU files copied.") - app.root.event_generate(app.status_evt) + app.status("ICU files copied.", 100) - if app: - if config.DIALOG == "curses": - app.install_icu_e.set() -def get_registry_value(reg_path, name): +def get_registry_value(reg_path, name, app: App): logging.debug(f"Get value for: {reg_path=}; {name=}") + # FIXME: consider breaking run_wine_proc into a helper function before decoding is attempted # noqa: E501 # NOTE: Can't use run_wine_proc here because of infinite recursion while - # trying to determine WINECMD_ENCODING. + # trying to determine wine_output_encoding. value = None - env = get_wine_env() + env = get_wine_env(app) cmd = [ - str(utils.get_wine_exe_path().parent / 'wine64'), + app.conf.wine64_binary, 'reg', 'query', reg_path, '/v', name, ] err_msg = f"Failed to get registry value: {reg_path}\\{name}" - encoding = config.WINECMD_ENCODING + encoding = app.conf._wine_output_encoding if encoding is None: encoding = 'UTF-8' try: @@ -499,7 +486,7 @@ def get_registry_value(reg_path, name): if 'non-zero exit status' in str(e): logging.warning(err_msg) return None - if result.stdout is not None: + if result is not None and result.stdout is not None: for line in result.stdout.splitlines(): if line.strip().startswith(name): value = line.split()[-1].strip() @@ -510,7 +497,7 @@ def get_registry_value(reg_path, name): return value -def get_mscoree_winebranch(mscoree_file): +def get_mscoree_winebranch(mscoree_file: Path) -> Optional[str]: try: with mscoree_file.open('rb') as f: for line in f: @@ -519,9 +506,10 @@ def get_mscoree_winebranch(mscoree_file): return m[0].decode().lstrip('wine-') except FileNotFoundError as e: logging.error(e) + return None -def get_wine_branch(binary): +def get_wine_branch(binary: str) -> Optional[str]: logging.info(f"Determining wine branch of '{binary}'") binary_obj = Path(binary).expanduser().resolve() if utils.check_appimage(binary_obj): @@ -533,7 +521,7 @@ def get_wine_branch(binary): encoding='UTF8' ) branch = None - while p.returncode is None: + while p.returncode is None and p.stdout is not None: for line in p.stdout: if line.startswith('/tmp'): tmp_dir = Path(line.rstrip()) @@ -560,20 +548,20 @@ def get_wine_branch(binary): return get_mscoree_winebranch(mscoree64) -def get_wine_env(): +def get_wine_env(app: App, additional_wine_dll_overrides: Optional[str]=None): wine_env = os.environ.copy() - winepath = utils.get_wine_exe_path() + winepath = Path(app.conf.wine_binary) if winepath.name != 'wine64': # AppImage # Winetricks commands can fail if 'wine64' is not explicitly defined. # https://github.com/Winetricks/winetricks/issues/2084#issuecomment-1639259359 - winepath = winepath.parent / 'wine64' + winepath = Path(app.conf.wine64_binary) wine_env_defaults = { 'WINE': str(winepath), - 'WINEDEBUG': config.WINEDEBUG, - 'WINEDLLOVERRIDES': config.WINEDLLOVERRIDES, + 'WINEDEBUG': app.conf.wine_debug, + 'WINEDLLOVERRIDES': app.conf.wine_dll_overrides, 'WINELOADER': str(winepath), - 'WINEPREFIX': config.WINEPREFIX, - 'WINESERVER': config.WINESERVER_EXE, + 'WINEPREFIX': app.conf.wine_prefix, + 'WINESERVER': app.conf.wineserver_binary, # The following seems to cause some winetricks commands to fail; e.g. # 'winetricks settings win10' exits with ec = 1 b/c it fails to find # %ProgramFiles%, %AppData%, etc. @@ -581,17 +569,9 @@ def get_wine_env(): } for k, v in wine_env_defaults.items(): wine_env[k] = v - # if config.LOG_LEVEL > logging.INFO: - # wine_env['WINETRICKS_SUPER_QUIET'] = "1" - - # Config file takes precedence over the above variables. - cfg = config.get_config_file_dict(config.CONFIG_FILE) - if cfg is not None: - for key, value in cfg.items(): - if value is None: - continue # or value = ''? - if key in wine_env_defaults.keys(): - wine_env[key] = value + + if additional_wine_dll_overrides is not None: + wine_env["WINEDLLOVERRIDES"] += ";" + additional_wine_dll_overrides # noqa: E501 updated_env = {k: wine_env.get(k) for k in wine_env_defaults.keys()} logging.debug(f"Wine env: {updated_env}") diff --git a/pyproject.toml b/pyproject.toml index 766f400c..d86aabd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,10 +28,30 @@ oudedetai = "ou_dedetai.main:main" [tool.setuptools.dynamic] readme = {file = ["README.md"], content-type = "text/plain"} -version = {attr = "ou_dedetai.config.LLI_CURRENT_VERSION"} +version = {attr = "ou_dedetai.constants.LLI_CURRENT_VERSION"} [tool.setuptools.packages.find] where = ["."] [tool.setuptools.package-data] -"ou_dedetai.img" = ["*icon.png"] \ No newline at end of file +"ou_dedetai.img" = ["*icon.png"] + +[tool.ruff.lint] +select = ["E", "F"] + +[tool.mypy] +warn_unreachable = true +disallow_untyped_defs = false +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = true +no_implicit_reexport = true +extra_checks = true +check_untyped_defs = true + +[[tool.mypy.overrides]] +module = "ou_dedetai.config" +disallow_untyped_calls = true + +disallow_any_generic = false +strict_equality = true diff --git a/scripts/build-binary.sh b/scripts/build-binary.sh index 217cc15f..e7891a4c 100755 --- a/scripts/build-binary.sh +++ b/scripts/build-binary.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -e start_dir="$PWD" script_dir="$(dirname "$0")" repo_root="$(dirname "$script_dir")" @@ -7,5 +8,8 @@ if ! which pyinstaller >/dev/null 2>&1 || ! which oudedetai >/dev/null; then # Install build deps. python3 -m pip install .[build] fi +# Ensure the source in our python venv is up to date +python3 -m pip install . +# Build the installer binary pyinstaller --clean --log-level DEBUG ou_dedetai.spec cd "$start_dir" \ No newline at end of file diff --git a/scripts/ensure-python.sh b/scripts/ensure-python.sh index 367016d8..b702d49d 100755 --- a/scripts/ensure-python.sh +++ b/scripts/ensure-python.sh @@ -26,7 +26,7 @@ if [[ ${ans,,} != 'y' && $ans != '' ]]; then fi # Download and build python3.12 from source. -echo "Downloading $python_src..." +echo "Downloading $python_src…" wget "$python_src" if [[ -r "$tarxz" ]]; then tar xf "$tarxz" @@ -44,7 +44,7 @@ else fi # Install python. -echo "Installing..." +echo "Installing…" ./configure --enable-shared --prefix="$prefix" make sudo make install diff --git a/scripts/ensure-venv.sh b/scripts/ensure-venv.sh index ec41e306..638d161f 100755 --- a/scripts/ensure-venv.sh +++ b/scripts/ensure-venv.sh @@ -38,7 +38,7 @@ echo "Virtual env setup as '${venv}/'. Activate with:" echo "source ${venv}/bin/activate" echo echo "Install runtime dependencies with:" -echo "pip install -r requirements.txt" +echo "pip install ." echo -echo "To build locally install pyinstaller with:" -echo "pip install pyinstaller" +echo "To build locally install:" +echo "pip install .[build]" diff --git a/tests/integration.py b/tests/integration.py new file mode 100644 index 00000000..2b9406fb --- /dev/null +++ b/tests/integration.py @@ -0,0 +1,253 @@ +"""Basic implementations of some rudimentary tests + +Should be migrated into unittests once that branch is merged +""" +# FIXME: refactor into unittests + +from dataclasses import dataclass +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import time +from typing import Callable, Optional + +REPOSITORY_ROOT_PATH = Path(__file__).parent.parent + +@dataclass +class CommandFailedError(Exception): + """Command Failed to execute""" + command: list[str] + stdout: str + stderr: str + +class TestFailed(Exception): + pass + +def run_cmd(*args, **kwargs) -> subprocess.CompletedProcess[str]: + """Wrapper around subprocess.run that: + - captures stdin/stderr + - sets text mode + - checks returncode before returning + + All other args are passed through to subprocess.run + """ + if "stdout" not in kwargs: + kwargs["stdout"] = subprocess.PIPE + if "stderr" not in kwargs: + kwargs["stderr"] = subprocess.PIPE + kwargs["text"] = True + output = subprocess.run(*args, **kwargs) + try: + output.check_returncode() + except subprocess.CalledProcessError as e: + raise CommandFailedError( + command=args[0], + stderr=output.stderr, + stdout=output.stdout + ) from e + return output + +class OuDedetai: + _binary: Optional[str] = None + _temp_dir: Optional[str] = None + config: Optional[Path] = None + install_dir: Optional[Path] = None + log_level: str + """Log level. One of: + - quiet - warn+ - status + - normal - warn+ + - verbose - info+ + - debug - debug + """ + + + def __init__(self, isolate: bool = True, log_level: str = "quiet"): + if isolate: + self.isolate_files() + self.log_level = log_level + + def isolate_files(self): + if self._temp_dir is not None: + shutil.rmtree(self._temp_dir) + self._temp_dir = tempfile.mkdtemp() + self.config = Path(self._temp_dir) / "config.json" + self.install_dir = Path(self._temp_dir) / "install_dir" + + @classmethod + def _source_last_update(cls) -> float: + """Last updated time of any source code in seconds since epoch""" + path = REPOSITORY_ROOT_PATH / "ou_dedetai" + output: float = 0 + for root, _, files in os.walk(path): + for file in files: + file_m = os.stat(Path(root) / file).st_mtime + if file_m > output: + output = file_m + return output + + @classmethod + def _oudedetai_binary(cls) -> str: + """Return the path to the binary""" + output = REPOSITORY_ROOT_PATH / "dist" / "oudedetai" + # First check to see if we need to build. + # If either the file doesn't exist, or it was last modified earlier than + # the source code, rebuild. + if ( + not output.exists() + or cls._source_last_update() > os.stat(str(output)).st_mtime + ): + print("Building binary…") + if output.exists(): + os.remove(str(output)) + run_cmd(f"{REPOSITORY_ROOT_PATH / "scripts" / "build-binary.sh"}") + + if not output.exists(): + raise Exception("Build process failed to yield binary") + print("Built binary.") + + return str(output) + + def run(self, *args, **kwargs): + if self._binary is None: + self._binary = self._oudedetai_binary() + if "env" not in kwargs: + kwargs["env"] = {} + env: dict[str, str] = {} + if self.config: + env["CONFIG_FILE"] = str(self.config) + if self.install_dir: + env["INSTALLDIR"] = str(self.install_dir) + env["PATH"] = os.environ.get("PATH", "") + env["HOME"] = os.environ.get("HOME", "") + env["DISPLAY"] = os.environ.get("DISPLAY", "") + kwargs["env"] = env + log_level = "" + if self.log_level == "debug": + log_level = "--debug" + elif self.log_level == "verbose": + log_level = "--verbose" + elif self.log_level == "quiet": + log_level = "--quiet" + args = ([self._binary, log_level] + args[0], *args[1:]) + # FIXME: Output to both stdout and PIPE (for debugging these tests) + output = run_cmd(*args, **kwargs) + + # Output from the app indicates error/warning. Raise. + if output.stderr: + raise CommandFailedError( + args[0], + stdout=output.stdout, + stderr=output.stderr + ) + return output + + def clean(self): + if self.install_dir and self.install_dir.exists(): + shutil.rmtree(self.install_dir) + if self.config: + os.remove(self.config) + if self._temp_dir: + shutil.rmtree(self._temp_dir) + + +def wait_for_true(callable: Callable[[], Optional[bool]], timeout: int = 10) -> bool: + exception = None + start_time = time.time() + while time.time() - start_time < timeout: + try: + if callable(): + return True + except Exception as e: + exception = e + time.sleep(.1) + if exception: + raise exception + return False + + +def wait_for_window(window_name: str, timeout: int = 10): + """Waits for an Xorg window to open, raises exception if it doesn't""" + def _window_open(): + output = run_cmd(["xwininfo", "-tree", "-root"]) + if output.stderr: + raise Exception(f"xwininfo failed: {output.stdout}\n{output.stderr}") + if window_name not in output.stdout: + raise Exception(f"Could not find {window_name} in {output.stdout}") + return True + wait_for_true(_window_open, timeout=timeout) + + +def check_logos_open() -> None: + """Raises an exception if Logos isn't open""" + # Check with Xorg to see if there is a window running with the string logos.exe + wait_for_window("logos.exe") + + + +def test_run(ou_dedetai: OuDedetai): + ou_dedetai.run(["--stop-installed-app"]) + + # First launch Run the app. This assumes that logos is spawned before this completes + ou_dedetai.run(["--run-installed-app"]) + + wait_for_true(check_logos_open) + + ou_dedetai.run(["--stop-installed-app"]) + + +def test_install() -> OuDedetai: + ou_dedetai = OuDedetai(log_level="debug") + ou_dedetai.run(["--install-app", "--assume-yes"]) + + # To actually test the install we need to run it + test_run(ou_dedetai) + return ou_dedetai + + +def test_remove_install_dir(ou_dedetai: OuDedetai): + if ou_dedetai.install_dir is None: + raise ValueError("Can only test removing install dir on isolated install") + ou_dedetai.run(["--remove-install-dir", "--assume-yes"]) + if ou_dedetai.install_dir.exists(): + raise TestFailed("Installation directory exists after --remove-install-dir") + ou_dedetai.install_dir = None + + +def main(): + # FIXME: consider loop to run all of these in their supported distroboxes (https://distrobox.it/) + ou_dedetai = test_install() + test_remove_install_dir(ou_dedetai) + + ou_dedetai.clean() + + + # Untested: + # - run_indexing - Need to be logged in + # - edit-config - would need to modify EDITOR for this, not a lot of value + # --install-dependencies - would be easy enough to run this, but not a real test + # considering the machine the tests are running on probably already has it + # installed and it's already run in install-all + # --update-self - we might be able to fake it into thinking we're an older version + # --update-latest-appimage - we're already at latest as a result of install-app + # --install-* - already effectively tested as a result of install-app, may be + # difficult to confirm independently + # --set-appimage - just needs to be implemented + # --get-winetricks - no need to test independently, covered in install_app + # --run-winetricks - needs a way to cleanup after this spawns + # --toggle-app-logging - difficult to confirm + # --create-shortcuts - easy enough, unsure the use of this, shouldn't this already + # be done? Nothing in here should change? The user can always re-run the entire + # process if they want to do this + # --winetricks - unsure how'd we confirm it work + + # Final message + print("Tests passed.") + + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + pass