diff --git a/.github/workflows/autobuild-main.yml b/.github/workflows/autobuild-main.yml
index f082198a..dfe192f7 100644
--- a/.github/workflows/autobuild-main.yml
+++ b/.github/workflows/autobuild-main.yml
@@ -1,4 +1,4 @@
-name: Build & release to test-builds repo
+name: Auto-build & release to test-builds repo
on:
push:
branches:
@@ -22,12 +22,13 @@ jobs:
name: ${{ needs.build.outputs.bin_name }}
- name: Run shell tasks
run: |
- echo "DATE=$(date +%Y%m%d)" >> $GITHUB_ENV
- find -name LogosLinuxInstaller -type f -exec chmod +x {} \;
+ find -name oudedetai -type f -exec chmod +x {} \;
- name: Upload release to test repo
uses: softprops/action-gh-release@v1
with:
- tag_name: main-${{ env.DATE }}-${{ needs.build.outputs.sha_short }}
+ tag_name: main-latest
+ # target_commitish: ${{ needs.build.outputs.sha }}
+ body: ''
prerelease: true
files: ${{ needs.build.outputs.bin_name }}
repository: FaithLife-Community/test-builds
diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml
index 25fc2aee..1eba0c62 100644
--- a/.github/workflows/build-branch.yml
+++ b/.github/workflows/build-branch.yml
@@ -9,6 +9,8 @@ on:
bin_name:
description: "name of built binary"
value: ${{ jobs.build.outputs.bin_name }}
+ sha:
+ value: ${{ github.sha }}
sha_short:
description: "1st few chars of commit SHA"
value: ${{ jobs.build.outputs.sha_short }}
@@ -47,19 +49,17 @@ jobs:
run: |
# apt-get install python3-tk
pip install --upgrade pip
- pip install -r requirements.txt
- pip install coverage
- pip install pyinstaller
+ pip install .[build,test]
- name: Build with pyinstaller
id: pyinstaller
run: |
- pyinstaller LogosLinuxInstaller.spec --clean
- echo "bin_name=LogosLinuxInstaller" >> $GITHUB_OUTPUT
+ ./scripts/build-binary.sh
+ echo "bin_name=oudedetai" >> $GITHUB_OUTPUT
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
- name: LogosLinuxInstaller
- path: dist/LogosLinuxInstaller
+ name: oudedetai
+ path: dist/oudedetai
compression-level: 0
diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 2e5348f9..48bccc06 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -29,7 +29,7 @@ jobs:
- name: download
uses: actions/download-artifact@v4
with:
- name: LogosLinuxInstaller
+ name: oudedetai
- name: release
uses: softprops/action-gh-release@v1
env:
diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index 11dd3e29..46e51e76 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -25,8 +25,8 @@ jobs:
name: ${{ needs.build.outputs.bin_name }}
- name: Fix file permissions
run: |
- find -name LogosLinuxInstaller -type f
- find -name LogosLinuxInstaller -type f -exec chmod +x {} \;
+ find -name oudedetai -type f
+ find -name oudedetai -type f -exec chmod +x {} \;
- name: Upload release to test repo
uses: softprops/action-gh-release@v1
with:
diff --git a/.gitignore b/.gitignore
index d0a38f15..6c1324ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ env/
venv/
.venv/
.idea/
+*.egg-info
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18d1ed8e..d302bf29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,54 @@
# Changelog
+- 4.0.0-beta.4
+ - Fix #220 [N. Shaaban]
+- 4.0.0-beta.3
+ - Fix various regressions from beta (e.g., #209, #213) [N. Shaaban]
+ - Fix #191 [N. Shaaban]
+- 4.0.0-beta.2
+ - Fix #171 [N. Marti]
+ - Fix #185 [N. Marti]
+- 4.0.0-beta.1
+ - Migrate .config and logs from `~/.config/Logos_on_Linux` and `~/.local/state/Logos_on_Linux` to `~/.config/FaithLife-Community` and `~/.local/state/FaithLife-Community`
+ - Add Logos State Manager [T. H. Wright, N. Marti]
+ - Numerous bug fixes [N. Marti, ctrlaltf24]
+ - Make config.WINE_EXE dynamic [T. H. Wright]
+ - Add Docker Build File [jimbob88]
+ - Fix numerous TUI issues [T. H. Wright]
+ - Fix #16 [T. H. Wright]
+ - Fix #84 [T. H. Wright]
+ - Fix #106 [T. H. Wright]
+ - Fix #127 [T. H. Wright]
+ - Fix #128 [T. H. Wright]
+ - Fix #142 [T. H. Wright]
+ - Fix #143 [T. H. Wright]
+ - Fix #153 [T. H. Wright]
+ - Fix #157 [T. H. Wright]
+ - Fix #181 [T. H. Wright]
+ - Fix #188 [T. H. Wright]
+- 4.0.0-alpha.14
+ - Fix install routine [N. Marti, T. H. Wright]
+ - Fix #144, #154, #156
+- 4.0.0-alpha.13
+ - Fix #22. [T. Bleher, J. Goodman, N. Marti, S. Freilichtbuenhe, M. Malevic, T. H. Wright]
+ - Fix package installer and TUI app. Also fix #135, #136, #140. [T. H. Wright, N. Marti]
+ - Introduce network.py and system.py
+- 4.0.0-alpha.12
+ - Fix TUI app's installer [T. H. Wright]
+- 4.0.0-alpha.11
+ - Fix #124 [T. H. Wright]
+- 4.0.0-alpha.10
+ - Fix #121 [T. H. Wright]
+ - Prep for Logos 30+ support [N. Marti, T. H. Wright]
+- 4.0.0-alpha.9
+ - Fix #42 [T. H. Wright]
+ - Fix #76, #104, #111, #115 [T. H. Wright]
+- 4.0.0-alpha.8
+ - Fix #1 [T. H. Wright, N. Marti, T. Bleher, C. Reeder]
+ - Fix #102 [T. H. Wright]
+ - Fix #110 [N. Marti]
+- 4.0.0-alpha.7
+ - Various fixes [N. Marti]
- 4.0.0-alpha.6
- Hotfix to get correct LOGOS_EXE value after installation [N. Marti]
- 4.0.0-alpha.5
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..c5ac4b1c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,100 @@
+# Contributor Documentation
+
+## Installing/running from Source (for developers)
+
+You can clone the repo and install the app from source. To do so, you will need to ensure a few prerequisites:
+1. Install build dependencies
+2. Clone this repository
+3. Build/install Python 3.12 and Tcl/Tk
+4. Set up a virtual environment
+
+### Install build dependencies
+
+e.g. for debian-based systems:
+```
+sudo apt-get install git build-essential gdb lcov pkg-config \
+ libbz2-dev libffi-dev libgdbm-dev libgdbm-compat-dev liblzma-dev \
+ libncurses5-dev libreadline6-dev libsqlite3-dev libssl-dev \
+ lzma lzma-dev python3-tk tk-dev uuid-dev zlib1g-dev
+```
+*See Python's [Build dependencies](https://devguide.python.org/getting-started/setup-building/index.html#build-dependencies) section for further info.*
+
+### Clone this repository
+```
+git clone 'https://github.com/FaithLife-Community/LogosLinuxInstaller.git'
+```
+
+### Install Python 3.12 and Tcl/Tk
+Your system might already include Python 3.12 built with Tcl/Tk. This will verify
+the installation:
+```
+$ python3 --version
+Python 3.12.5
+$ python3 -m tkinter # should open a basic Tk window
+```
+If your Python version is < 3.12, then you might want to install 3.12 and tcl/tk
+using your system's package manager or compile it from source using the
+following guide or the script provided in `scripts/ensure-python.sh`. This is
+because the app is built using 3.12 and might have errors if run with other
+versions.
+
+**Install & build python 3.12 using the script:**
+```
+./LogosLinuxInstaller/scripts/ensure-python.sh
+```
+
+**Install & build python 3.12 manually:**
+```
+$ ver=$(wget -qO- https://www.python.org/ftp/python/ | grep -oE '3\.12\.[0-9]+' | sort -u | tail -n1)
+$ wget "https://www.python.org/ftp/python/${ver}/Python-${ver}.tar.xz"
+$ tar xf Python-${ver}.tar.xz
+$ cd Python-${ver}
+Python-3.12$ ./configure --prefix=/opt --enable-shared
+Python-3.12$ make
+Python-3.12$ sudo make install
+Python-3.12$ LD_LIBRARY_PATH=/opt/lib /opt/bin/python3.12 --version
+Python 3.12.5
+$ cd ~
+```
+Both methods install python into `/opt` to avoid interfering with system python installations.
+
+### Enter the repository folder
+```
+$ cd LogosLinuxInstaller
+LogosLinuxInstaller$
+```
+
+### Set up and use a virtual environment
+Use the following guide or the provided script at `scripts/ensure-venv.sh` to set
+up a virtual environment for running and/or building locally.
+
+**Using the script:**
+```
+./scripts/ensure-venv.sh
+```
+
+**Manual setup:**
+
+```
+LogosLinuxInstaller$ LD_LIBRARY_PATH=/opt/lib /opt/bin/python3.12 -m venv env # create a virtual env folder called "env" using python3.12's path
+LogosLinuxInstaller$ echo "LD_LIBRARY_PATH=/opt/lib" >> env/bin/activate # tell python where to find libs
+LogosLinuxInstaller$ echo "export LD_LIBRARY_PATH" >> env/bin/activate
+LogosLinuxInstaller$ source env/bin/activate # activate the env
+(env) LogosLinuxInstaller$ python --version # verify python version
+Python 3.12.5
+(env) LogosLinuxInstaller$ python -m tkinter # verify that tkinter test window opens
+(env) LogosLinuxInstaller$ pip install -r .[build] # install python packages
+(env) LogosLinuxInstaller$ python -m ou_dedetai.main --help # run the script
+```
+
+### Building using docker
+
+```bash
+$ git clone 'https://github.com/FaithLife-Community/LogosLinuxInstaller.git'
+$ cd LogosLinuxInstaller
+# docker build -t logosinstaller .
+# docker run --rm -v $(pwd):/usr/src/app logosinstaller
+```
+
+The built binary will now be in `./dist/oudedetai`.
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..a3b8d7d3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,33 @@
+# syntax=docker.io/docker/dockerfile:1.7-labs
+
+FROM ubuntu:focal
+
+# Prevent popups during install of requirements
+ENV DEBIAN_FRONTEND=noninteractive
+
+# App Requirements
+RUN apt-get update -qq \
+ && apt-get install -y -qq git build-essential gdb lcov pkg-config \
+ libbz2-dev libffi-dev libgdbm-dev libgdbm-compat-dev liblzma-dev \
+ libncurses5-dev libreadline6-dev libsqlite3-dev libssl-dev \
+ lzma lzma-dev python3-tk tk-dev uuid-dev zlib1g-dev \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# pyenv for guaranteed py 3.12
+ENV HOME="/root"
+WORKDIR ${HOME}
+RUN curl https://pyenv.run | bash
+ENV PYENV_ROOT="${HOME}/.pyenv"
+ENV PATH="${PYENV_ROOT}/shims:${PYENV_ROOT}/bin:${PATH}"
+
+# Ensure tkinter
+ENV PYTHON_CONFIGURE_OPTS "--enable-shared"
+
+# install py 3.12
+ENV PYTHON_VERSION=3.12.6
+RUN pyenv install --verbose ${PYTHON_VERSION}
+RUN pyenv global ${PYTHON_VERSION}
+
+WORKDIR /usr/src/app
+ENTRYPOINT ["sh", "-c", "pip install --no-cache-dir .[build] && pyinstaller ou_dedetai.spec"]
diff --git a/README.md b/README.md
index 054ad768..2d2cbb9c 100644
--- a/README.md
+++ b/README.md
@@ -2,286 +2,48 @@
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/f730f74748c348cb9b3ff2fa1654c84b)](https://app.codacy.com/manual/FaithLife-Community/LogosLinuxInstaller?utm_source=github.com&utm_medium=referral&utm_content=FaithLife-Community/LogosLinuxInstaller&utm_campaign=Badge_Grade_Dashboard)
[![Automation testing](https://img.shields.io/badge/Automation-testing-sucess)](https://github.com/FaithLife-Community/LogosLinuxInstallTests) [![Installer LogosBible](https://img.shields.io/badge/Installer-LogosBible-blue)](https://www.logos.com) [![LastRelease](https://img.shields.io/github/v/release/FaithLife-Community/LogosLinuxInstaller)](https://github.com/FaithLife-Community/LogosLinuxInstaller/releases)
-# Install Logos Bible Software on Linux
+# Ou Dedetai
-This repository contains a Python program for installing and maintaining FaithLife's Logos Bible (Verbum) Software on Linux.
+>Remember Jesus Christ, risen from the dead, the offspring of David, as preached in my gospel, for which I am suffering, bound with chains as a criminal. But **the word** of God **is not bound!**
+>
+>ἀλλʼ **ὁ λόγος** τοῦ θεοῦ **οὐ δέδεται**
+
+—Second Timothy 2:8–9 [ESV](https://biblia.com/bible/esv/2-timothy/2/8-9), [NA28](https://biblia.com/bible/ubs5/2-timothy/2/9)
+
+## Manages Logos Bible Software via Wine
+
+This repository contains a Python program for installing and maintaining [FaithLife](https://faithlife.com/)'s [Logos Bible (Verbum) Software](https://www.logos.com/) via [Wine](https://www.winehq.org/).
This program is created and maintained by the FaithLife Community and is licensed under the MIT License.
-## Logos Linux Installer
+## oudedetai binary
-The main program is a distributable executable and contains Python itself and all necessary Python packages.
+The main program is a distributable executable binary and contains Python itself and all necessary Python packages.
When running the program, it will attempt to determine your operating system and package manager.
-It will then attempt to install all needed system dependencies during the install of Logos.
-When the install is finished, it will place two shortcuts on your computer: one will launch Logos directly; the other will launch the Control Panel.
+It will then attempt to install all needed system dependencies during the installation of Logos.
+When the installation is finished, it will place two shortcuts on your computer: one will launch Logos directly; the other will launch the Control Panel.
-To access the GUI version of the program, double click the executable in your file browser or on your desktop, and then follow the prompts.
+To access the GUI version of the program, double-click the executable in your file browser or on your desktop, and then follow the prompts.
The program can also be run from source and should be run from a Python virtual environment.
See below.
-By default the program installs Logos, but you can pass the `-C|--control-panel` optarg to access the Control Panel, which allows you to install Logos or do various maintenance functions on your install.
-In time, you should be able to use the program to restore a backup.
-
-```
-Usage: ./LogosLinuxInstaller.sh
-Installs ${FLPRODUCT} Bible Software with Wine on Linux.
-
-Options:
- -h --help Prints this help message and exit.
- -v --version Prints version information and exit.
- -V --verbose Enable extra CLI verbosity.
- -D --debug Makes Wine print out additional info.
- -C --control-panel Open the Control Panel app.
- -c --config Use the Logos on Linux config file when
- setting environment variables. Defaults to:
- \$HOME/.config/Logos_on_Linux/Logos_on_Linux.conf
- Optionally can accept a config file provided by
- the user.
- -b --custom-binary-path Set a custom path to search for wine binaries
- during the install.
- -F --skip-fonts Skips installing corefonts and tahoma.
- -s --shortcut Create or update the Logos shortcut, located in
- HOME/.local/share/applications.
- -d --dirlink Create a symlink to the Windows Logos directory
- in your Logos on Linux install dir.
- The symlink's name will be 'installation_dir'.
- -e --edit-config Edit the Logos on Linux config file.
- -i --indexing Run the Logos indexer in the
- background.
- --remove-all-index Removes all index and library catalog files.
- --remove-library-catalog Removes all library catalog files.
- -l --logs Turn Logos logs on or off.
- -L --delete-install-log Delete the installation log file.
- -R --check-resources Check Logos's resource usage while running.
- -b --backup Saves Logos data to the config's
- backup location.
- -r --restore Restores Logos data from the config's
- backup location.
- -f --force-root Sets LOGOS_FORCE_ROOT to true, which permits
- the root user to run the script.
- -P --passive Install Logos non-interactively .
- -k --make-skel Make a skeleton install only.
-```
-
-## Installation
-
-This section is a WIP.
-
-You can either run the program from the CLI for a CLI-only install, or you can double click the icon in your file browser or on your desktop for a GUI install. Then, follow the prompts.
-
-## Installing/running from Source
-
-You can clone the repo and install the app from source. To do so, you will need to ensure a few prerequisites:
-1. Install Python 3.12 and Tcl/Tk
-1. Clone this repository
-1. Set up a virtual environment
-
-### Install Python 3.12 and Tcl/Tk
-Your system might already include Python 3.12 built with Tcl/Tk. This will verify
-the installation:
-```
-$ python3 --version
-Python 3.12.1
-$ python3 -m tkinter # should open a basic Tk window
-```
-If your Python version is < 3.12, then you might want to install 3.12 and tcl/tk using
-your system's package manager or compile it from source using the following guide
-or the script provided in `scripts/ensure-python.sh`. This is because the app is
-built using 3.12 and might have errors if run with other versions.
-```
-# install build dependencies; e.g. for debian-based systems:
-$ apt install build-essential tcl-dev tk-dev libreadline-dev libsqlite3-dev
-# install & build python 3.12
-$ wget 'https://www.python.org/ftp/python/3.12.1/Python-3.12.1.tar.xz'
-$ tar xf Python-3.12.1.tar.xz
-$ cd Python-3.12.1
-Python-3.12.1$ ./configure --prefix=/opt --enable-shared --enable-loadable-sqlite-extensions
-Python-3.12.1$ make
-Python-3.12.1$ sudo make install
-Python-3.12.1$ LD_LIBRARY_PATH=/opt/lib /opt/bin/python3.12 --version
-Python 3.12.1
-```
-The script `scripts/ensure-python.sh` is not yet fully tested. Feedback is welcome!
-Both methods install python into /opt to avoid interfering with system python installations.
-
-### Clone this repository
-```
-$ git clone 'https://github.com/FaithLife-Community/LogosLinuxInstaller.git'
-$ cd LogosLinuxInstaller
-LogosLinuxInstaller$
-```
-
-### Set up and use a virtual environment
-Use the following guide or the provided script at `scripts/ensure-venv.sh` to set
-up a virtual environment for running and/or building locally.
-```
-LogosLinuxInstaller$ LD_LIBRARY_PATH=/opt/lib /opt/bin/python3.12 -m venv env # create a virtual env folder called "env" using python3.12's path
-LogosLinuxInstaller$ echo "LD_LIBRARY_PATH=/opt/lib" >> env/bin/activate # tell python where to find libs
-LogosLinuxInstaller$ echo "export LD_LIBRARY_PATH" >> env/bin/activate
-LogosLinuxInstaller$ source env/bin/activate # activate the env
-(env) LogosLinuxInstaller$ python --version # verify python version
-Python 3.12.1
-(env) LogosLinuxInstaller$ python -m tkinter # verify that tkinter test window opens
-(env) LogosLinuxInstaller$ pip install -r requirements.txt # install python packages
-(env) LogosLinuxInstaller$ ./LogosLinuxInstaller.py --help # run the script
-```
-
-## Install Guide
-
-For an install guide with pictures and video, see the wiki's [Install Guide](https://github.com/FaithLife-Community/LogosLinuxInstaller/wiki/Install-Guide).
-
-NOTE: You can run Logos on Linux using the Steam Proton Experimental binary, which often has the latest and greatest updates to make Logos run even smoother. The script should be able to find the binary automatically, unless your Steam install is located outside of your HOME directory.
-
-If you want to install your distro's dependencies outside of the script, please see the following.
+## Install Guide (for users)
-NOTE: The following section is WIP.
+For an installation guide with pictures and video, see the wiki's [Install Guide](https://github.com/FaithLife-Community/LogosLinuxInstaller/wiki/Install-Guide).
-## Debian and Ubuntu
-
-### Install Dependencies
-
-```
-sudo apt install mktemp patch lsof wget find sed grep gawk tr winbind cabextract x11-apps bc
-```
-
-If using wine from a repo, you must install wine staging. Run:
-
-```
-sudo dpkg --add-architecture i386
-sudo mkdir -pm755 /etc/apt/keyrings
-sudo wget -O /etc/apt/keyrings/winehq-archive.key https://dl.winehq.org/wine-builds/winehq.key
-CODENAME=$(lsb_release -a | grep Codename | awk '{print $2}')
-sudo wget -NP /etc/apt/sources.list.d/ https://dl.winehq.org/wine-builds/ubuntu/dists/"${CODENAME}"/winehq-"${CODENAME}".sources
-sudo apt update
-sudo apt install --install-recommends winehq-staging
-```
-
-See https://wiki.winehq.org/Ubuntu for help.
-
-If using the AppImage, run:
-
-```
-sudo apt install fuse3
-```
-
-## Arch
-
-### Install Dependencies
-
-```
-sudo pacman -S patch lsof wget sed grep gawk cabextract samba bc
-```
-
-If using wine from a repo, run:
-
-```
-sudo pacman -S wine
-```
-
-### Manjaro
-
-#### Install Dependencies
-
-```
-sudo pamac install patch lsof wget sed grep gawk cabextract samba bc
+In Summary:
+```bash
+wget https://github.com/FaithLife-Community/LogosLinuxInstaller/releases/latest/download/oudedetai
+chmod +x ./oudedetai
+DIALOG=tk ./oudedetai
```
-If using wine from a repo, run:
+NOTE: You can run **Ou Dedetai** using the Steam Proton Experimental binary, which often has the latest and greatest updates to make Logos run even smoother. The script should be able to find the binary automatically, unless your Steam install is located outside of your HOME directory.
-```
-sudo pamac install wine
-```
-
-You may need to install pamac if you are not using Manjaro GNOME:
-
-```
-sudo pacman -S pamac-cli
-```
-
-### Steamdeck
-
-The steam deck has a locked down filesystem. There are some missing dependencies which cause irregular crashes in Logos. These can be installed following this sequence:
-
-1. Enter Desktop Mode
-2. Use `passwd` to create a password for the deck user, unless you already did this.
-3. Disable read-only mode: `sudo steamos-readonly disable`
-4. Initialize pacman keyring: `sudo pacman-key --init`
-5. Populate pacman keyring with the default Arch Linux keys: `sudo pacman-key --populate archlinux`
-6. Get package lists: `sudo pacman -Fy`
-7. Fix locale issues `sudo pacman -Syu glibc`
-8. then `sudo locale-gen`
-9. Install dependencies: `sudo pacman -S samba winbind cabextract appmenu-gtk-module patch bc lib32-libjpeg-turbo`
-
-Packages you install may be overwritten by the next Steam OS update, but you can easily reinstall them if that happens.
-
-After these steps you can go ahead and run the your install script.
-
-## RPM
-
-### Install Dependencies
-
-```
-sudo dnf install patch mod_auth_ntlm_winbind samba-winbind cabextract bc samba-winbind-clients
-```
-
-If using wine from a repo, run:
-
-```
-sudo dnf install winehq-staging
-```
-
-If using the AppImage, run:
-
-```
-sudo dnf install fuse3
-```
-
-### CentOS
-
-### Install Dependencies
-
-```
-sudo yum install patch mod_auth_ntlm_winbind samba-winbind cabextract bc
-```
-
-If using wine from a repo, run:
-
-```
-sudo yum install winehq-staging
-```
-
-If using the AppImage, run:
-
-```
-sudo yum install fuse3
-```
-
-## OpenSuse
-
-TODO
-
-```
-sudo zypper install …
-```
-
-## Alpine
-
-TODO
-
-```
-sudo apk add …
-```
-
-## BSD
-
-TODO.
-
-```
-doas pkg install …
-```
+If you want to install your distro's dependencies outside of the script, please see the [System Dependencies wiki page](https://github.com/FaithLife-Community/LogosLinuxInstaller/wiki/System-Dependencies).
-## ChromeOS
+---
-TODO.
+Soli Deo Gloria
diff --git a/installer.py b/installer.py
deleted file mode 100644
index bf5fd9dc..00000000
--- a/installer.py
+++ /dev/null
@@ -1,730 +0,0 @@
-import logging
-import os
-import re
-import shutil
-import sys
-from pathlib import Path
-
-import config
-import msg
-import tui
-import utils
-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:
- logging.debug('FLPRODUCT not set.')
- if config.DIALOG == 'tk' and app:
- send_gui_task(app, 'FLPRODUCT')
- config.FLPRODUCT = app.product_q.get()
- else:
- TITLE = "Choose Product"
- QUESTION_TEXT = "Choose which FaithLife product the script should install:" # noqa: E501
- options = ["Logos", "Verbum", "Exit"]
- product_choice = tui.menu(options, TITLE, QUESTION_TEXT)
- logging.info(f"Product: {str(product_choice)}")
- if str(product_choice).startswith("Logos"):
- logging.info("Installing Logos Bible Software")
- config.FLPRODUCT = "Logos"
- elif str(product_choice).startswith("Verbum"):
- logging.info("Installing Verbum Bible Software")
- config.FLPRODUCT = "Verbum"
- elif str(product_choice).startswith("Exit"):
- msg.logos_error("Exiting installation.", "")
- else:
- msg.logos_error("Unknown product. Installation canceled!", "")
-
- if config.FLPRODUCT == 'Logos':
- config.FLPRODUCTi = 'logos4'
- config.VERBUM_PATH = "/"
- elif config.FLPRODUCT == 'Verbum':
- config.FLPRODUCTi = '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 == 'tk' and app:
- send_gui_task(app, 'TARGETVERSION')
- config.TARGETVERSION = app.version_q.get()
- else:
- TITLE = "Choose Product Version"
- QUESTION_TEXT = f"Which version of {config.FLPRODUCT} should the script install?" # noqa: E501
- options = ["10", "9", "Exit"]
- version_choice = tui.menu(options, TITLE, QUESTION_TEXT)
- logging.info(f"Target version: {version_choice}")
- if "10" in version_choice:
- config.TARGETVERSION = "10"
- elif "9" in version_choice:
- config.TARGETVERSION = "9"
- elif version_choice == "Exit.":
- msg.logos_error("Exiting installation.", "")
- else:
- msg.logos_error("Unknown version. Installation canceled!", "")
- 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.LOGOS_RELEASE_VERSION')
-
- if not config.LOGOS_RELEASE_VERSION:
- if config.DIALOG == 'tk' and app:
- send_gui_task(app, 'LOGOS_RELEASE_VERSION')
- config.LOGOS_RELEASE_VERSION = app.release_q.get()
- logging.debug(f"{config.LOGOS_RELEASE_VERSION=}")
- else:
- TITLE = f"Choose {config.FLPRODUCT} {config.TARGETVERSION} Release"
- QUESTION_TEXT = f"Which version of {config.FLPRODUCT} {config.TARGETVERSION} do you want to install?" # noqa: E501
- releases = utils.get_logos_releases()
- if releases is None:
- msg.logos_error("Failed to fetch LOGOS_RELEASE_VERSION.")
- releases.append("Exit")
- logos_release_version = tui.menu(releases, TITLE, QUESTION_TEXT)
- logging.info(f"Release version: {logos_release_version}")
- if logos_release_version == "Exit":
- msg.logos_error("Exiting installation.", "")
- elif logos_release_version:
- config.LOGOS_RELEASE_VERSION = logos_release_version
- else:
- msg.logos_error("Failed to fetch LOGOS_RELEASE_VERSION.")
- logging.debug(f"> {config.LOGOS_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')
-
- if not config.INSTALLDIR:
- default = f"{str(Path.home())}/{config.FLPRODUCT}Bible{config.TARGETVERSION}" # noqa: E501
- if config.DIALOG == 'tk' and app:
- config.INSTALLDIR = default
- else:
- # TITLE = "Choose Installation Folder"
- QUESTION_TEXT = f"Where should {config.FLPRODUCT} files be instaled to? [{default}]: " # noqa: E501
- installdir = input(f"{QUESTION_TEXT} ")
- if not installdir:
- msg.cli_msg("Using default location.")
- installdir = default
- config.INSTALLDIR = installdir
- # Ensure APPDIR_BINDIR is set.
- config.APPDIR_BINDIR = f"{config.INSTALLDIR}/data/bin"
-
- logging.debug(f"> {config.INSTALLDIR=}")
-
-
-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 config.WINE_EXE is None:
- # Set relevant config based on up-to-date details from URL.
- utils.set_recommended_appimage_config()
- if config.DIALOG == 'tk' and app:
- send_gui_task(app, 'WINE_EXE')
- config.WINE_EXE = app.wine_q.get()
- else:
- logging.info("Creating binary list.")
- TITLE = "Choose Wine Binary"
- QUESTION_TEXT = f"Which Wine AppImage or binary should the script use to install {config.FLPRODUCT} v{config.LOGOS_RELEASE_VERSION} in {config.INSTALLDIR}?" # noqa: E501
- WINEBIN_OPTIONS = utils.get_wine_options(
- utils.find_appimage_files(),
- utils.find_wine_binary_files()
- )
-
- installation_choice = tui.menu(WINEBIN_OPTIONS, TITLE, QUESTION_TEXT) # noqa: E501
- config.WINEBIN_CODE = installation_choice[0]
- config.WINE_EXE = installation_choice[1]
- if config.WINEBIN_CODE == "Exit":
- msg.logos_error("Exiting installation.", "")
-
- # Set WINEBIN_CODE and SELECTED_APPIMAGE_FILENAME.
- if config.WINE_EXE.lower().endswith('.appimage'):
- config.SELECTED_APPIMAGE_FILENAME = config.WINE_EXE
- if not config.WINEBIN_CODE:
- config.WINEBIN_CODE = utils.get_winebin_code_and_desc(config.WINE_EXE)[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"> {config.WINE_EXE=}")
-
-
-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')
-
- # Check if local winetricks version available; else, download it.
- if config.WINETRICKSBIN is None:
- config.WINETRICKSBIN = f"{config.APPDIR_BINDIR}/winetricks"
- if config.DIALOG == 'tk':
- send_gui_task(app, 'WINETRICKSBIN')
- winetricksbin = app.tricksbin_q.get()
- if not winetricksbin.startswith('Download'):
- config.WINETRICKSBIN = winetricksbin
- else:
- winetricks_options = utils.get_winetricks_options()
- if len(winetricks_options) > 1:
- 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.menu(options, title, question_text)
-
- logging.debug(f"winetricks_choice: {winetricks_choice}")
- if winetricks_choice.startswith("1"):
- logging.info("Setting winetricks to the local binary…")
- config.WINETRICKSBIN = winetricks_options[0]
- elif not winetricks_choice.startswith("2"):
- msg.logos_error("Installation canceled!")
- else:
- msg.cli_msg("Winetricks will be downloaded from the Internet.")
- 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
- logos_icon_url = app_dir / 'img' / f"{config.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.LOGOS_RELEASE_VERSION}/{config.FLPRODUCT}-x64.msi" # noqa: E501
-
- config.LOGOS_VERSION = config.LOGOS_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 == 'tk' and app:
- send_gui_task(app, 'INSTALL')
-
-
-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')
-
- if config.INSTALLDIR is None:
- config.INSTALLDIR = f"{os.getenv('HOME')}/{config.FLPRODUCT}Bible{config.TARGETVERSION}" # noqa: E501
- logging.debug(f"> {config.INSTALLDIR=}")
- if config.WINEPREFIX is None:
- config.WINEPREFIX = f"{config.INSTALLDIR}/data/wine64_bottle"
- logging.debug(f"> {config.WINEPREFIX=}")
-
- bin_dir = Path(config.APPDIR_BINDIR)
- bin_dir.mkdir(parents=True, exist_ok=True)
- logging.debug(f"> {bin_dir} exists: {bin_dir.is_dir()}")
-
- wine_dir = Path(f"{config.WINEPREFIX}")
- wine_dir.mkdir(parents=True, exist_ok=True)
- logging.debug(f"> {wine_dir} exists: {wine_dir.is_dir()}")
-
- if config.DIALOG == 'tk' and app:
- send_gui_task(app, 'INSTALLING')
-
-
-def ensure_sys_deps(app=None):
- config.INSTALL_STEPS_COUNT += 1
- ensure_install_dirs(app=app)
- config.INSTALL_STEP += 1
- update_install_feedback("Ensuring system dependencies are met…", app=app)
-
- if not config.SKIP_DEPENDENCIES:
- utils.get_package_manager()
- utils.check_dependencies()
- logging.debug("> Done.")
- else:
- logging.debug("> Skipped.")
-
-
-def ensure_appimage_download(app=None):
- config.INSTALL_STEPS_COUNT += 1
- ensure_sys_deps(app=app)
- config.INSTALL_STEP += 1
- if config.TARGETVERSION != '9' and not config.WINE_EXE.lower().endswith('appimage'): # noqa: E501
- return
- update_install_feedback(
- "Ensuring wine AppImage is downloaded…",
- app=app
- )
-
- filename = Path(config.SELECTED_APPIMAGE_FILENAME).name
- downloaded_file = utils.get_downloaded_file_path(filename)
- if not downloaded_file:
- downloaded_file = Path(f"{config.MYDOWNLOADS}/{filename}")
- utils.logos_reuse_download(
- config.RECOMMENDED_WINE64_APPIMAGE_URL,
- filename,
- config.MYDOWNLOADS,
- app=app,
- )
- 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
- 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.
- appdir_bindir = Path(config.APPDIR_BINDIR)
- os.environ['PATH'] = f"{config.APPDIR_BINDIR}:{os.getenv('PATH')}"
-
- if not os.access(config.WINE_EXE, os.X_OK):
- # 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) # noqa: E501
- if not appimage_file.is_file():
- msg.cli_msg(f"Copying: {downloaded_file} into: {str(appdir_bindir)}") # noqa: E501
- 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"]: # noqa: E501
- appimage_filename = "none.AppImage"
- else:
- msg.logos_error("WINEBIN_CODE error. Installation canceled!")
-
- appimage_link.unlink(missing_ok=True) # remove & replace
- appimage_link.symlink_to(f"./{appimage_filename}")
-
- # Ensure wine executables symlinks.
- for name in ["wine", "wine64", "wineserver"]:
- p = appdir_bindir / name
- p.unlink(missing_ok=True)
- p.symlink_to(f"./{config.APPIMAGE_LINK_SELECTION_NAME}")
-
- # Set WINESERVER_EXE.
- config.WINESERVER_EXE = shutil.which('wineserver')
-
- logging.debug(f"> {config.WINESERVER_EXE=}")
- logging.debug(f"> wine path: {shutil.which('wine')}")
- logging.debug(f"> wine64 path: {shutil.which('wine64')}")
- logging.debug(f"> wineserver path: {shutil.which('wineserver')}")
-
-
-def ensure_winetricks_executable(app=None):
- config.INSTALL_STEPS_COUNT += 1
- ensure_wine_executables(app=app)
- config.INSTALL_STEP += 1
- update_install_feedback(
- "Ensuring winetricks executable is available…",
- app=app
- )
-
- if not os.access(config.WINETRICKSBIN, os.X_OK):
- tricksbin = Path(config.WINETRICKSBIN)
- tricksbin.unlink(missing_ok=True)
- # The choice of System winetricks was made previously. Here we are only
- # concerned about whether or not the downloaded winetricks is usable.
- msg.cli_msg("Downloading winetricks from the Internet…")
- utils.install_winetricks(
- tricksbin.parent,
- app=app
- )
- logging.debug(f"> {config.WINETRICKSBIN} is executable?: {os.access(config.WINETRICKSBIN, os.X_OK)}") # noqa: E501
- return 0
-
-
-def ensure_premade_winebottle_download(app=None):
- config.INSTALL_STEPS_COUNT += 1
- ensure_winetricks_executable(app=app)
- config.INSTALL_STEP += 1
- if config.TARGETVERSION != '9':
- return
- update_install_feedback(
- f"Ensuring {config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME} bottle is downloaded…", # noqa: E501
- app=app
- )
-
- downloaded_file = utils.get_downloaded_file_path(config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME) # noqa: E501
- if not downloaded_file:
- downloaded_file = Path(config.MYDOWNLOADS) / config.LOGOS_EXECUTABLE
- utils.logos_reuse_download(
- config.LOGOS9_WINE64_BOTTLE_TARGZ_URL,
- config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME,
- config.MYDOWNLOADS,
- app=app,
- )
- # Install bottle.
- bottle = Path(f"{config.INSTALLDIR}/data/wine64_bottle")
- if not bottle.is_dir():
- utils.install_premade_wine_bottle(
- config.MYDOWNLOADS,
- f"{config.INSTALLDIR}/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
- ensure_premade_winebottle_download(app=app)
- config.INSTALL_STEP += 1
- update_install_feedback(
- f"Ensuring {config.FLPRODUCT} installer is downloaded…",
- app=app
- )
-
- config.LOGOS_EXECUTABLE = f"{config.FLPRODUCT}_v{config.LOGOS_VERSION}-x64.msi" # noqa: E501
- downloaded_file = utils.get_downloaded_file_path(config.LOGOS_EXECUTABLE)
- if not downloaded_file:
- downloaded_file = Path(config.MYDOWNLOADS) / config.LOGOS_EXECUTABLE
- utils.logos_reuse_download(
- config.LOGOS64_URL,
- config.LOGOS_EXECUTABLE,
- config.MYDOWNLOADS,
- app=app,
- )
- # Copy file into INSTALLDIR.
- installer = Path(f"{config.INSTALLDIR}/data/{config.LOGOS_EXECUTABLE}")
- 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
- ensure_product_installer_download(app=app)
- config.INSTALL_STEP += 1
- update_install_feedback("Ensuring wineprefix is initialized…", app=app)
-
- init_file = Path(f"{config.WINEPREFIX}/system.reg")
- if not init_file.is_file():
- if config.TARGETVERSION == '9':
- utils.install_premade_wine_bottle(
- config.MYDOWNLOADS,
- f"{config.INSTALLDIR}/data",
- )
- else:
- wine.initializeWineBottle()
- logging.debug(f"> {init_file} exists?: {init_file.is_file()}")
-
-
-def ensure_winetricks_applied(app=None):
- config.INSTALL_STEPS_COUNT += 1
- ensure_wineprefix_init(app=app)
- config.INSTALL_STEP += 1
- status = "Ensuring winetricks & other settings are applied…"
- update_install_feedback(status, app=app)
- logging.debug('- disable winemenubuilder')
- logging.debug('- settings renderer=gdi')
- logging.debug('- corefonts')
- logging.debug('- tahoma')
- logging.debug('- settings fontsmooth=rgb')
- logging.debug('- d3dcompiler_47')
-
- usr_reg = Path(f"{config.WINEPREFIX}/user.reg")
- sys_reg = Path(f"{config.WINEPREFIX}/system.reg")
- if not grep(r'"winemenubuilder.exe"=""', usr_reg):
- reg_file = os.path.join(config.WORKDIR, 'disable-winemenubuilder.reg')
- with open(reg_file, 'w') as f:
- f.write(r'''REGEDIT4
-
-[HKEY_CURRENT_USER\Software\Wine\DllOverrides]
-"winemenubuilder.exe"=""
-''')
- wine.wine_reg_install(reg_file)
-
- if not grep(r'"renderer"="gdi"', usr_reg):
- wine.winetricks_install("-q", "settings", "renderer=gdi")
-
- if not config.SKIP_FONTS and not grep(r'"Tahoma \(TrueType\)"="tahoma.ttf"', sys_reg): # noqa: E501
- wine.installFonts()
-
- if not grep(r'"\*d3dcompiler_47"="native"', usr_reg):
- wine.installD3DCompiler()
-
- if not grep(r'"ProductName"="Microsoft Windows 10"', sys_reg):
- args = ["settings", "win10"]
- if not config.WINETRICKS_UNATTENDED:
- args.insert(0, "-q")
- wine.winetricks_install(*args)
-
- if config.TARGETVERSION == '9':
- msg.cli_msg(f"Setting {config.FLPRODUCT}Bible Indexing to Vista Mode.")
- exe_args = [
- 'add',
- f"HKCU\\Software\\Wine\\AppDefaults\\{config.FLPRODUCT}Indexer.exe", # noqa: E501
- "/v", "Version",
- "/t", "REG_SZ",
- "/d", "vista", "/f",
- ]
- wine.run_wine_proc(config.WINE_EXE, exe='reg', exe_args=exe_args)
- logging.debug("> Done.")
-
-
-def ensure_product_installed(app=None):
- config.INSTALL_STEPS_COUNT += 1
- ensure_winetricks_applied(app=app)
- config.INSTALL_STEP += 1
- update_install_feedback("Ensuring product is installed…", app=app)
-
- if not utils.find_installed_product():
- wine.install_msi()
- config.LOGOS_EXE = utils.find_installed_product()
- if config.DIALOG == 'tk' and app:
- send_gui_task(app, 'DONE')
-
- # Clean up temp files, etc.
- utils.clean_all()
-
- logging.debug(f"> Product path: {config.LOGOS_EXE}")
-
-
-def ensure_config_file(app=None):
- config.INSTALL_STEPS_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}")
- parent = Path.home() / ".config" / "Logos_on_Linux"
- parent.mkdir(exist_ok=True, parents=True)
- if parent.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"{str(parent)} does not exist. Failed to create config file.") # noqa: E501
- else:
- logging.info(f"Config file exists at {config.CONFIG_FILE}.")
- # 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) # noqa: E501
- different = False
- for key in config.core_config_keys:
- if current_config_file_dict.get(key) != config.__dict__.get(key):
- different = True
- break
- if different:
- if config.DIALOG == 'tk' and app:
- logging.info("Updating config file.")
- utils.write_config(config.CONFIG_FILE)
- elif 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)
- logging.debug(f"> File exists?: {config.CONFIG_FILE}: {Path(config.CONFIG_FILE).is_file()}") # noqa: E501
-
-
-def ensure_launcher_executable(app=None):
- config.INSTALL_STEPS_COUNT += 1
- ensure_config_file(app=app)
- config.INSTALL_STEP += 1
- runmode = utils.get_runmode()
- if runmode != 'binary':
- return
- update_install_feedback(
- f"Copying launcher to {config.INSTALLDIR}…",
- app=app
- )
-
- # Copy executable to config.INSTALLDIR.
- launcher_exe = Path(f"{config.INSTALLDIR}/LogosLinuxInstaller")
- if launcher_exe.is_file():
- logging.debug("Removing existing launcher binary.")
- launcher_exe.unlink()
- logging.info(f"Creating launcher binary by copying this installer binary to {launcher_exe}.") # noqa: E501
- shutil.copy(sys.executable, launcher_exe)
- logging.debug(f"> File exists?: {launcher_exe}: {launcher_exe.is_file()}") # noqa: E501
-
-
-def ensure_launcher_shortcuts(app=None):
- config.INSTALL_STEPS_COUNT += 1
- ensure_launcher_executable(app=app)
- config.INSTALL_STEP += 1
- runmode = utils.get_runmode()
- if runmode != 'binary':
- return
- update_install_feedback("Creating launcher shortcuts…", app=app)
-
- app_dir = Path(config.INSTALLDIR) / 'data'
- logos_icon_path = app_dir / config.LOGOS_ICON_FILENAME # noqa: E501
- if not logos_icon_path.is_file():
- app_dir.mkdir(exist_ok=True)
- shutil.copy(config.LOGOS_ICON_URL, logos_icon_path)
- else:
- logging.info(f"Icon found at {logos_icon_path}.")
-
- desktop_files = [
- (
- f"{config.FLPRODUCT}Bible.desktop",
- f"""[Desktop Entry]
-Name={config.FLPRODUCT}Bible
-Comment=A Bible Study Library with Built-In Tools
-Exec={config.INSTALLDIR}/LogosLinuxInstaller --run-installed-app
-Icon={str(logos_icon_path)}
-Terminal=false
-Type=Application
-Categories=Education;
-"""
- ),
- (
- f"{config.FLPRODUCT}Bible-ControlPanel.desktop",
- f"""[Desktop Entry]
-Name={config.FLPRODUCT}Bible Control Panel
-Comment=Perform various tasks for {config.FLPRODUCT} app
-Exec={config.INSTALLDIR}/LogosLinuxInstaller
-Icon={str(logos_icon_path)}
-Terminal=false
-Type=Application
-Categories=Education;
-"""
- ),
- ]
- for f, c in desktop_files:
- create_desktop_file(f, c)
- fpath = Path.home() / '.local' / 'share' / 'applications' / f
- logging.debug(f"> File exists?: {fpath}: {fpath.is_file()}")
-
-
-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.status(text, app=app)
- msg.progress(percent, app=app)
-
-
-def send_gui_task(app, task):
- logging.debug(f"{task=}")
- app.todo_q.put(task)
- app.root.event_generate('<>')
-
-
-def grep(regexp, filepath):
- fp = Path(filepath)
- if not fp.is_file():
- return None
- found = False
- ct = 0
- with fp.open() as f:
- for line in f:
- ct += 1
- text = line.rstrip()
- if re.search(regexp, text):
- logging.debug(f"{filepath}:{ct}:{text}")
- found = True
- return found
-
-
-def get_progress_pct(current, total):
- if total == 0:
- logging.warning(f"Progress {total=}; can't divide by zero")
- pct = 0
- else:
- pct = round(current * 100 / total)
- if pct > 100:
- logging.warning(f"Progress {pct=}; setting to \"100\"")
- pct = 100
- return pct
-
-
-def create_desktop_file(name, contents):
- launcher_path = Path(f"~/.local/share/applications/{name}").expanduser()
- if launcher_path.is_file():
- logging.info(f"Removing desktop launcher at {launcher_path}.")
- launcher_path.unlink()
-
- logging.info(f"Creating desktop launcher at {launcher_path}.")
- with launcher_path.open('w') as f:
- f.write(contents)
- os.chmod(launcher_path, 0o755)
diff --git a/msg.py b/msg.py
deleted file mode 100644
index f1fc2aaf..00000000
--- a/msg.py
+++ /dev/null
@@ -1,175 +0,0 @@
-import logging
-import os
-import signal
-import sys
-
-from pathlib import Path
-
-import config
-
-
-def get_log_level_name(level):
- name = None
- for k, v in config.LOG_LEVELS.items():
- if level == v:
- name = k
- break
- return name
-
-
-def initialize_logging(stderr_log_level):
- '''
- Log levels:
- Level Value Description
- CRITICAL 50 the program can't continue
- ERROR 40 the program has not been able to do something
- WARNING 30 something unexpected happened (maybe neg. effect)
- INFO 20 confirmation that things are working as expected
- DEBUG 10 detailed, dev-level information
- NOTSET 0 all events are handled
- '''
-
- # Ensure log file parent folders exist.
- log_parent = Path(config.LOGOS_LOG).parent
- if not log_parent.is_dir():
- log_parent.mkdir(parents=True)
-
- # Define logging handlers.
- file_h = logging.FileHandler(config.LOGOS_LOG, encoding='UTF8')
- file_h.setLevel(logging.DEBUG)
- # stdout_h = logging.StreamHandler(sys.stdout)
- # stdout_h.setLevel(stdout_log_level)
- stderr_h = logging.StreamHandler(sys.stderr)
- stderr_h.setLevel(stderr_log_level)
- handlers = [
- file_h,
- # stdout_h,
- stderr_h,
- ]
-
- # Set initial config.
- logging.basicConfig(
- level=logging.DEBUG,
- format='%(asctime)s %(levelname)s: %(message)s',
- datefmt='%Y-%m-%d %H:%M:%S',
- handlers=handlers,
- )
-
-
-def update_log_level(new_level):
- # Update logging level from config.
- for h in logging.getLogger().handlers:
- if type(h) is logging.StreamHandler:
- h.setLevel(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_progress():
- 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':
- cli_msg(message)
-
-
-def logos_error(message, secondary=None):
- WIKI_LINK = "https://github.com/FaithLife-Community/LogosLinuxInstaller/wiki" # noqa: E501
- 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 secondary != "info":
- logging.critical(message)
- cli_msg(help_message)
- else:
- cli_msg(message)
-
- if secondary is None or secondary == "":
- try:
- os.remove("/tmp/LogosLinuxInstaller.pid")
- except FileNotFoundError: # no pid file when testing functions
- pass
- os.kill(os.getpgid(os.getpid()), signal.SIGKILL)
- sys.exit(1)
-
-
-def cli_question(QUESTION_TEXT):
- while True:
- try:
- 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:
- cli_msg("Type Y[es] or N[o].")
-
-
-def cli_continue_question(QUESTION_TEXT, NO_TEXT, SECONDARY):
- if not cli_question(QUESTION_TEXT):
- logos_error(NO_TEXT, SECONDARY)
-
-
-def cli_acknowledge_question(QUESTION_TEXT, NO_TEXT):
- if not cli_question(QUESTION_TEXT):
- cli_msg(NO_TEXT)
- return False
- else:
- return True
-
-
-def cli_ask_filepath(question_text):
- try:
- answer = input(f"{question_text} ")
- except KeyboardInterrupt:
- print()
- logos_error("Cancelled with Ctrl+C")
- return answer.strip('"').strip("'")
-
-
-def logos_acknowledge_question(QUESTION_TEXT, NO_TEXT):
- if config.DIALOG == 'curses':
- return cli_acknowledge_question(QUESTION_TEXT, NO_TEXT)
-
-
-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."""
- logging.debug(f"Progress: {percent}%")
- if config.DIALOG == 'tk' and app:
- app.progress_q.put(percent)
- app.root.event_generate('<>')
- else:
- cli_msg(get_progress_str(percent)) # provisional
-
-
-def status(text, app=None):
- """Handles status messages for both TUI and GUI."""
- logging.debug(f"Status: {text}")
- if config.DIALOG == 'tk' and app:
- app.status_q.put(text)
- app.root.event_generate('<>')
- else:
- cli_msg(text) # provisional
diff --git a/LogosLinuxInstaller.spec b/ou_dedetai.spec
similarity index 85%
rename from LogosLinuxInstaller.spec
rename to ou_dedetai.spec
index e26942cd..7d8c51fc 100644
--- a/LogosLinuxInstaller.spec
+++ b/ou_dedetai.spec
@@ -2,11 +2,11 @@
a = Analysis(
- ['LogosLinuxInstaller.py'],
+ ['scripts/run_app.py'],
pathex=[],
#binaries=[('/usr/bin/tclsh8.6', '.')],
binaries=[],
- datas=[('img/*-128-icon.png', 'img')],
+ datas=[('ou_dedetai/img/*icon.png', 'ou_dedetai/img')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
@@ -22,7 +22,7 @@ exe = EXE(
a.binaries,
a.datas,
[],
- name='LogosLinuxInstaller',
+ name='oudedetai',
debug=False,
bootloader_ignore_signals=False,
strip=False,
diff --git a/ou_dedetai/__init__.py b/ou_dedetai/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/ou_dedetai/cli.py b/ou_dedetai/cli.py
new file mode 100644
index 00000000..9bd88433
--- /dev/null
+++ b/ou_dedetai/cli.py
@@ -0,0 +1,205 @@
+import queue
+import threading
+
+from . import control
+from . import installer
+from . import logos
+from . import wine
+from . import utils
+
+
+class CLI:
+ def __init__(self):
+ self.running: bool = True
+ self.choice_q = queue.Queue()
+ self.input_q = queue.Queue()
+ self.input_event = threading.Event()
+ self.choice_event = threading.Event()
+ self.logos = logos.LogosManager(app=self)
+
+ def backup(self):
+ control.backup(app=self)
+
+ def create_shortcuts(self):
+ installer.create_launcher_shortcuts()
+
+ def edit_config(self):
+ control.edit_config()
+
+ def get_winetricks(self):
+ control.set_winetricks()
+
+ def install_app(self):
+ self.thread = utils.start_thread(
+ installer.ensure_launcher_shortcuts,
+ app=self
+ )
+ self.user_input_processor()
+
+ def install_d3d_compiler(self):
+ wine.install_d3d_compiler()
+
+ def install_dependencies(self):
+ utils.install_dependencies(app=self)
+
+ def install_fonts(self):
+ wine.install_fonts()
+
+ def install_icu(self):
+ wine.enforce_icu_data_files()
+
+ def remove_index_files(self):
+ control.remove_all_index_files()
+
+ def remove_install_dir(self):
+ control.remove_install_dir()
+
+ def remove_library_catalog(self):
+ control.remove_library_catalog()
+
+ def restore(self):
+ control.restore(app=self)
+
+ def run_indexing(self):
+ self.logos.index()
+
+ def run_installed_app(self):
+ self.logos.start()
+
+ def run_winetricks(self):
+ wine.run_winetricks()
+
+ 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()
+
+ def update_self(self):
+ utils.update_to_latest_lli_release()
+
+ def winetricks(self):
+ import config
+ wine.run_winetricks_cmd(*config.winetricks_args)
+
+ def user_input_processor(self, evt=None):
+ while self.running:
+ prompt = None
+ question = None
+ options = None
+ choice = None
+ # Wait for next input queue item.
+ self.input_event.wait()
+ self.input_event.clear()
+ prompt = self.input_q.get()
+ if prompt is None:
+ return
+ if prompt is not None and isinstance(prompt, tuple):
+ question = prompt[0]
+ options = prompt[1]
+ 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)
+ choice = input(f"{question}: {optstr}: ")
+ if len(choice) == 0:
+ choice = default
+ if choice is not None and choice.lower() == 'exit':
+ 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/config.py b/ou_dedetai/config.py
similarity index 59%
rename from config.py
rename to ou_dedetai/config.py
index 3603806f..d4aa3dbb 100644
--- a/config.py
+++ b/ou_dedetai/config.py
@@ -2,6 +2,8 @@
import logging
import os
import tempfile
+from datetime import datetime
+from typing import Optional
LOG_LEVELS = {
@@ -11,13 +13,20 @@
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
}
+# 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", "LOGOS_RELEASE_VERSION",
+ "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"
+ "RECOMMENDED_WINE64_APPIMAGE_URL", "LLI_LATEST_VERSION",
+ "logos_release_channel", "lli_release_channel",
]
for k in core_config_keys:
globals()[k] = os.getenv(k)
@@ -25,14 +34,15 @@
# 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,
- 'LOG_LEVEL': logging.WARNING,
- 'LOGOS_LOG': os.path.expanduser("~/.local/state/Logos_on_Linux/Logos_on_Linux.log"), # noqa: E501
+ '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,
@@ -42,11 +52,14 @@
'SELECTED_APPIMAGE_FILENAME': None,
'SKIP_DEPENDENCIES': False,
'SKIP_FONTS': False,
+ 'SKIP_WINETRICKS': False,
+ 'use_python_dialog': None,
'VERBOSE': False,
- 'WINE_EXE': None,
+ 'WINEBIN_CODE': None,
'WINEDEBUG': "fixme-all,err-all",
'WINEDLLOVERRIDES': '',
'WINEPREFIX': None,
+ 'WINE_EXE': None,
'WINESERVER_EXE': None,
'WINETRICKS_UNATTENDED': None,
}
@@ -60,54 +73,86 @@
globals()[key] = val
# Set other run-time variables not set in the env.
-ACTION = 'app'
-APPDIR_BINDIR = None
-APPIMAGE_FILE_PATH = None
-BADPACKAGES = None
-DEFAULT_CONFIG_PATH = os.path.expanduser("~/.config/Logos_on_Linux/Logos_on_Linux.json") # noqa: E501
-GUI = None
-INSTALL_STEP = 0
-INSTALL_STEPS_COUNT = 0
+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_FILE = os.path.expanduser("~/.config/Logos_on_Linux/Logos_on_Linux.conf") # noqa: E501
+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-alpha.6"
-LLI_LATEST_VERSION = None
-LLI_TITLE = "Logos Linux Installer"
+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 = False
-LOGOS_ICON_FILENAME = None
-LOGOS_ICON_URL = None
-LOGOS_LATEST_VERSION_FILENAME = "LogosLinuxInstaller"
-LOGOS_LATEST_VERSION_URL = None
-LOGOS9_RELEASES = None # used to save downloaded releases list
+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
-MYDOWNLOADS = None
-OS_NAME = None
-OS_RELEASE = None
-PACKAGE_MANAGER_COMMAND_INSTALL = None
-PACKAGE_MANAGER_COMMAND_REMOVE = None
-PACKAGE_MANAGER_COMMAND_QUERY = None
-PACKAGES = None
-PASSIVE = None
-PRESENT_WORKING_DIRECTORY = os.getcwd()
-REBOOT_REQUIRED = None
-RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME = None
-RECOMMENDED_WINE64_APPIMAGE_FULL_VERSION = None
-RECOMMENDED_WINE64_APPIMAGE_FILENAME = None
-RECOMMENDED_WINE64_APPIMAGE_VERSION = None
-RECOMMENDED_WINE64_APPIMAGE_BRANCH = None
-SUPERUSER_COMMAND = None
-VERBUM_PATH = None
+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):
@@ -171,3 +216,7 @@ def get_env_config():
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')
diff --git a/control.py b/ou_dedetai/control.py
similarity index 61%
rename from control.py
rename to ou_dedetai/control.py
index e98a53f0..10e547a4 100644
--- a/control.py
+++ b/ou_dedetai/control.py
@@ -9,17 +9,15 @@
import shutil
import subprocess
import sys
-import threading
import time
-from datetime import datetime
from pathlib import Path
-import config
-# import installer
-import msg
-import tui
-import utils
-# import wine
+from . import config
+from . import msg
+from . import network
+from . import system
+from . import tui_curses
+from . import utils
def edit_config():
@@ -40,12 +38,16 @@ def restore(app=None):
backup_and_restore(mode='restore', app=app)
+# FIXME: consider moving this into it's own file/module.
def backup_and_restore(mode='backup', app=None):
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
@@ -60,57 +62,71 @@ def backup_and_restore(mode='backup', app=None):
)
# Confirm BACKUPDIR.
- if config.DIALOG == 'tk':
- pass # user confirms in GUI
+ 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
+ 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("Give backups folder path:")
+ answer = msg.cli_ask_filepath("Please provide a backups folder path:")
answer = Path(answer).expanduser().resolve()
if not answer.is_dir():
- msg.cli_msg(f"Not a valid folder path: {answer}")
+ msg.status(f"Not a valid folder path: {answer}", app=app)
config.BACKUPDIR = answer
# Set source folders.
+ backup_dir = Path(config.BACKUPDIR)
+ 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
+
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.
- config.RESTOREDIR = utils.get_latest_folder(config.BACKUPDIR)
- if not msg.cli_question(f"Restore most-recent backup?: {config.RESTOREDIR}"): # noqa: E501
+ 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
- config.RESTOREDIR = Path(config.RESTOREDIR).expanduser().resolve()
source_dir_base = config.RESTOREDIR
else:
source_dir_base = Path(config.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:
- m = "No files to backup"
- if app is not None:
- app.status_q.put(m)
- app.root.event_generate('<>')
- app.root.event_generate('<>')
- logging.warning(m)
+ msg.logos_warning(f"No files to {mode}", app=app)
return
+ 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))
+
# Get source transfer size.
q = queue.Queue()
- t = threading.Thread(
- target=utils.get_folder_group_size,
- args=[src_dirs, q],
- daemon=True
- )
- m = "Calculating backup size"
- if app is not None:
- app.status_q.put(m)
- app.root.event_generate('<>')
- app.root.event_generate('<>')
- msg.cli_msg(m, end='')
- t.start()
+ msg.status("Calculating backup size…", app=app)
+ t = utils.start_thread(utils.get_folder_group_size, src_dirs, q)
try:
while t.is_alive():
msg.logos_progress()
@@ -118,18 +134,14 @@ def backup_and_restore(mode='backup', app=None):
print()
except KeyboardInterrupt:
print()
- msg.logos_error("Cancelled with Ctrl+C.")
+ msg.logos_error("Cancelled with Ctrl+C.", app=app)
t.join()
- if app is not None:
+ if config.DIALOG == 'tk':
app.root.event_generate('<>')
app.root.event_generate('<>')
src_size = q.get()
if src_size == 0:
- m = f"Nothing to {mode}!"
- logging.warning(m)
- if app is not None:
- app.status_q.put(m)
- app.root.event_generate('<>')
+ msg.logos_warning(f"Nothing to {mode}!", app=app)
return
# Set destination folder.
@@ -140,74 +152,55 @@ def backup_and_restore(mode='backup', app=None):
dst = Path(dst_dir) / d
if dst.is_dir():
shutil.rmtree(dst)
- else:
- timestamp = datetime.today().strftime('%Y%m%dT%H%M%S')
+ else: # backup mode
+ timestamp = config.get_timestamp().replace('-', '')
current_backup_name = f"{config.FLPRODUCT}{config.TARGETVERSION}-{timestamp}" # noqa: E501
- dst_dir = Path(config.BACKUPDIR) / current_backup_name
- dst_dir.mkdir(exist_ok=True, parents=True)
-
- # Verify disk space.
- if (
- not utils.enough_disk_space(dst_dir, src_size)
- and not Path(dst_dir / 'Data').is_dir()
- ):
- m = f"Not enough free disk space for {mode}."
- if app is not None:
- app.status_q.put(m)
- app.root.event_generate('<>')
- return
- else:
- msg.logos_error(m)
+ dst_dir = backup_dir / current_backup_name
+ logging.debug(f"Backup directory path: \"{dst_dir}\".")
- # Verify destination.
- if config.BACKUPDIR is None:
- config.BACKUPDIR = Path().home() / 'Logos_on_Linux_backups'
- backup_dir = Path(config.BACKUPDIR)
- backup_dir.mkdir(exist_ok=True, parents=True)
- if not utils.enough_disk_space(backup_dir, src_size):
- msg.logos_error("Not enough free disk space for backup.")
+ # Check for existing backup.
+ try:
+ dst_dir.mkdir()
+ except FileExistsError:
+ msg.logos_error(f"Backup already exists: {dst_dir}.")
- # Run backup.
- try:
- dst_dir.mkdir()
- except FileExistsError:
- msg.logos_error(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
# Run file transfer.
- t = threading.Thread(
- target=copy_data,
- args=(src_dirs, dst_dir),
- daemon=True
- )
if mode == 'restore':
- m = f"Restoring backup from {str(source_dir_base)}"
+ m = f"Restoring backup from {str(source_dir_base)}…"
else:
- m = f"Backing up to {str(dst_dir)}"
- logging.info(m)
- msg.cli_msg(m)
- if app is not None:
- app.status_q.put(m)
- app.root.event_generate('<>')
+ 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)
- t.start()
+ msg.status("Starting backup…", app=app)
+ t = utils.start_thread(copy_data, src_dirs, dst_dir)
try:
+ counter = 0
while t.is_alive():
- progress = utils.get_copy_progress(
- dst_dir,
- src_size,
- dest_size_init=dst_dir_size
- )
- utils.write_progress_bar(progress)
- if app is not None:
- app.progress_q.put(progress)
- app.root.event_generate('<>')
- time.sleep(0.5)
+ 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.")
t.join()
- if app is not None:
+ if config.DIALOG == 'tk':
app.root.event_generate('<>')
logging.info(f"Finished. {src_size} bytes copied to {str(dst_dir)}")
@@ -248,9 +241,9 @@ def remove_all_index_files(app=None):
except OSError as e:
logging.error(f"Error removing {file_to_remove}: {e}")
- msg.cli_msg("======= Removing all LogosBible index files done! =======")
- if app is not None:
- app.root.event_generate(app.message_event)
+ msg.status("======= Removing all LogosBible index files done! =======")
+ if hasattr(app, 'status_evt'):
+ app.root.event_generate(app.status_evt)
sys.exit(0)
@@ -266,7 +259,7 @@ def remove_library_catalog():
def set_winetricks():
- msg.cli_msg("Preparing 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
@@ -276,8 +269,8 @@ def set_winetricks():
# 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) >= "20220411":
- if config.DIALOG == 'tk':
+ 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:
@@ -288,7 +281,7 @@ def set_winetricks():
"1: Use local winetricks.",
"2: Download winetricks from the Internet"
]
- winetricks_choice = tui.menu(options, title, question_text)
+ winetricks_choice = tui_curses.menu(options, title, question_text) # noqa: E501
logging.debug(f"winetricks_choice: {winetricks_choice}")
if winetricks_choice.startswith("1"):
@@ -296,29 +289,27 @@ def set_winetricks():
config.WINETRICKSBIN = local_winetricks_path
return 0
elif winetricks_choice.startswith("2"):
- # download_winetricks()
- utils.install_winetricks(config.APPDIR_BINDIR)
+ system.install_winetricks(config.APPDIR_BINDIR)
config.WINETRICKSBIN = os.path.join(
config.APPDIR_BINDIR,
"winetricks"
)
return 0
else:
- msg.cli_msg("Installation canceled!")
+ # FIXME: Should this call a function on the app object?
+ msg.status("Installation canceled!")
sys.exit(0)
else:
- msg.cli_msg("The system's winetricks is too old. Downloading an up-to-date winetricks from the Internet...") # noqa: E501
- # download_winetricks()
- utils.install_winetricks(config.APPDIR_BINDIR)
+ 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.cli_msg("Local winetricks not found. Downloading winetricks from the Internet…") # noqa: E501
- # download_winetricks()
- utils.install_winetricks(config.APPDIR_BINDIR)
+ 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"
@@ -326,13 +317,3 @@ def set_winetricks():
return 0
return 0
-
-def download_winetricks():
- msg.cli_msg("Downloading winetricks…")
- appdir_bindir = f"{config.INSTALLDIR}/data/bin"
- utils.logos_reuse_download(
- config.WINETRICKS_URL,
- "winetricks",
- appdir_bindir
- )
- os.chmod(f"{appdir_bindir}/winetricks", 0o755)
diff --git a/gui.py b/ou_dedetai/gui.py
similarity index 62%
rename from gui.py
rename to ou_dedetai/gui.py
index b39fbf9c..a370744c 100644
--- a/gui.py
+++ b/ou_dedetai/gui.py
@@ -2,6 +2,8 @@
from tkinter import BooleanVar
from tkinter import font
from tkinter import IntVar
+from tkinter import messagebox
+from tkinter import simpledialog
from tkinter import StringVar
from tkinter.ttk import Button
from tkinter.ttk import Checkbutton
@@ -12,7 +14,8 @@
from tkinter.ttk import Radiobutton
from tkinter.ttk import Separator
-import config
+from . import config
+from . import utils
class InstallerGui(Frame):
@@ -26,9 +29,9 @@ def __init__(self, root, **kwargs):
# Initialize vars from ENV.
self.flproduct = config.FLPRODUCT
self.targetversion = config.TARGETVERSION
- self.logos_release_version = config.LOGOS_RELEASE_VERSION
+ self.logos_release_version = config.TARGET_RELEASE_VERSION
self.default_config_path = config.DEFAULT_CONFIG_PATH
- self.wine_exe = config.WINE_EXE
+ self.wine_exe = utils.get_wine_exe_path()
self.winetricksbin = config.WINETRICKSBIN
self.skip_fonts = config.SKIP_FONTS
if self.skip_fonts is None:
@@ -120,27 +123,36 @@ def __init__(self, root, **kwargs):
self.progress = Progressbar(self, variable=self.progressvar)
# Place widgets.
- self.product_label.grid(column=0, row=0, sticky='nws', pady=2)
- self.product_dropdown.grid(column=1, row=0, sticky='w', pady=2)
- self.version_dropdown.grid(column=2, row=0, sticky='w', pady=2)
- self.release_label.grid(column=0, row=1, sticky='w', pady=2)
- self.release_dropdown.grid(column=1, row=1, sticky='w', pady=2)
- self.release_check_button.grid(column=2, row=1, sticky='w', pady=2)
- self.wine_label.grid(column=0, row=2, sticky='w', pady=2)
- self.wine_dropdown.grid(column=1, row=2, columnspan=3, sticky='we', pady=2) # noqa: E501
- self.wine_check_button.grid(column=4, row=2, sticky='e', pady=2)
- self.tricks_label.grid(column=0, row=3, sticky='w', pady=2)
- self.tricks_dropdown.grid(column=1, row=3, sticky='we', pady=2)
- self.fonts_label.grid(column=0, row=4, sticky='nws', pady=2)
- self.fonts_checkbox.grid(column=1, row=4, sticky='w', pady=2)
- self.skipdeps_label.grid(column=2, row=4, sticky='nws', pady=2)
- self.skipdeps_checkbox.grid(column=3, row=4, sticky='w', pady=2)
- self.cancel_button.grid(column=3, row=5, sticky='e', pady=2)
- self.okay_button.grid(column=4, row=5, sticky='e', pady=2)
+ row = 0
+ self.product_label.grid(column=0, row=row, sticky='nws', pady=2)
+ self.product_dropdown.grid(column=1, row=row, sticky='w', pady=2)
+ self.version_dropdown.grid(column=2, row=row, sticky='w', pady=2)
+ 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)
+ row += 1
+ self.fonts_label.grid(column=0, row=row, sticky='nws', pady=2)
+ self.fonts_checkbox.grid(column=1, row=row, sticky='w', pady=2)
+ self.skipdeps_label.grid(column=2, row=row, sticky='nws', pady=2)
+ self.skipdeps_checkbox.grid(column=3, 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)
+ row += 1
# Status area
- s1.grid(column=0, row=6, columnspan=5, sticky='we')
- self.status_label.grid(column=0, row=7, columnspan=5, sticky='w', pady=2) # noqa: E501
- self.progress.grid(column=0, row=8, columnspan=5, sticky='we', pady=2)
+ 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):
@@ -153,7 +165,7 @@ def __init__(self, root, *args, **kwargs):
self.installdir = config.INSTALLDIR
self.flproduct = config.FLPRODUCT
self.targetversion = config.TARGETVERSION
- self.logos_release_version = config.LOGOS_RELEASE_VERSION
+ self.logos_release_version = config.TARGET_RELEASE_VERSION
self.logs = config.LOGS
self.config_file = config.CONFIG_FILE
@@ -167,7 +179,6 @@ def __init__(self, root, *args, **kwargs):
# -> Run indexing, Remove library catalog, Remove all index files
s1 = Separator(self, orient='horizontal')
self.actionsvar = StringVar()
- self.actioncmd = None
self.actions_label = Label(self, text="App actions: ")
self.run_indexing_radio = Radiobutton(
self,
@@ -187,6 +198,12 @@ def __init__(self, root, *args, **kwargs):
variable=self.actionsvar,
value='remove-index-files',
)
+ self.install_icu_radio = Radiobutton(
+ self,
+ text="Install/Update ICU files",
+ variable=self.actionsvar,
+ value='install-icu',
+ )
self.actions_button = Button(self, text="Run action")
self.actions_button.state(['disabled'])
s2 = Separator(self, orient='horizontal')
@@ -201,7 +218,7 @@ 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="Update Logos Linux Installer") # noqa: E501
+ self.update_lli_label = Label(self, text=f"Update {config.name_app}") # noqa: E501
self.update_lli_button = Button(self, text="Update")
# AppImage buttons
self.latest_appimage_label = Label(
@@ -237,46 +254,55 @@ def __init__(self, root, *args, **kwargs):
self.progress.state(['disabled'])
# Place widgets.
- self.app_label.grid(column=0, row=0, sticky='w', pady=2)
- self.app_button.grid(column=1, row=0, sticky='w', pady=2)
-
+ 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)
+ row += 1
s1.grid(column=0, row=1, columnspan=3, sticky='we', pady=2)
- self.actions_label.grid(column=0, row=2, sticky='e', padx=20, pady=2)
- self.actions_button.grid(column=0, row=4, sticky='e', padx=20, pady=2)
- self.run_indexing_radio.grid(column=1, row=2, sticky='w', pady=2, columnspan=2) # noqa: E501
- self.remove_library_catalog_radio.grid(column=1, row=3, sticky='w', pady=2, columnspan=2) # noqa: E501
- self.remove_index_files_radio.grid(column=1, row=4, sticky='w', pady=2, columnspan=2) # noqa: E501
- s2.grid(column=0, row=5, columnspan=3, sticky='we', pady=2)
-
- self.config_label.grid(column=0, row=6, sticky='w', pady=2)
- self.config_button.grid(column=1, row=6, sticky='w', pady=2)
-
- self.deps_label.grid(column=0, row=7, sticky='w', pady=2)
- self.deps_button.grid(column=1, row=7, sticky='w', pady=2)
-
- self.backups_label.grid(column=0, row=8, sticky='w', pady=2)
- self.backup_button.grid(column=1, row=8, sticky='w', pady=2)
- self.restore_button.grid(column=2, row=8, sticky='w', pady=2)
-
- self.update_lli_label.grid(column=0, row=9, sticky='w', pady=2)
- self.update_lli_button.grid(column=1, row=9, sticky='w', pady=2)
-
- self.latest_appimage_label.grid(column=0, row=10, sticky='w', pady=2)
- self.latest_appimage_button.grid(column=1, row=10, sticky='w', pady=2)
-
- self.set_appimage_label.grid(column=0, row=11, sticky='w', pady=2)
- self.set_appimage_button.grid(column=1, row=11, sticky='w', pady=2)
-
- self.winetricks_label.grid(column=0, row=12, sticky='w', pady=2)
- self.run_winetricks_button.grid(column=1, row=12, sticky='w', pady=2)
- self.get_winetricks_button.grid(column=2, row=12, sticky='w', pady=2)
-
- self.logging_label.grid(column=0, row=13, sticky='w', pady=2)
- self.logging_button.grid(column=1, row=13, sticky='w', pady=2)
-
- s3.grid(column=0, row=14, columnspan=3, sticky='we', pady=2)
- self.message_label.grid(column=0, row=15, columnspan=3, sticky='we', pady=2) # noqa: E501
- self.progress.grid(column=0, row=16, columnspan=3, sticky='we', pady=2)
+ row += 1
+ self.actions_label.grid(column=0, row=row, sticky='e', padx=20, pady=2)
+ self.run_indexing_radio.grid(column=1, row=row, sticky='w', pady=2, columnspan=2) # noqa: E501
+ row += 1
+ self.remove_library_catalog_radio.grid(column=1, row=row, sticky='w', pady=2, columnspan=2) # noqa: E501
+ row += 1
+ self.actions_button.grid(column=0, row=row, sticky='e', padx=20, pady=2) # noqa: E501
+ self.remove_index_files_radio.grid(column=1, row=row, sticky='w', pady=2, columnspan=2) # noqa: E501
+ row += 1
+ self.install_icu_radio.grid(column=1, row=row, sticky='w', pady=2, columnspan=2) # noqa: E501
+ row += 1
+ s2.grid(column=0, row=row, columnspan=3, sticky='we', pady=2)
+ row += 1
+ self.config_label.grid(column=0, row=row, sticky='w', pady=2)
+ self.config_button.grid(column=1, row=row, sticky='w', pady=2)
+ row += 1
+ self.deps_label.grid(column=0, row=row, sticky='w', pady=2)
+ self.deps_button.grid(column=1, row=row, sticky='w', pady=2)
+ # row += 1
+ # self.backups_label.grid(column=0, row=row, sticky='w', pady=2)
+ # self.backup_button.grid(column=1, row=row, sticky='w', pady=2)
+ # self.restore_button.grid(column=2, row=row, sticky='w', pady=2)
+ row += 1
+ self.update_lli_label.grid(column=0, row=row, sticky='w', pady=2)
+ self.update_lli_button.grid(column=1, row=row, sticky='w', pady=2)
+ row += 1
+ self.latest_appimage_label.grid(column=0, row=row, sticky='w', pady=2)
+ self.latest_appimage_button.grid(column=1, row=row, sticky='w', pady=2)
+ row += 1
+ self.set_appimage_label.grid(column=0, row=row, sticky='w', pady=2)
+ self.set_appimage_button.grid(column=1, row=row, sticky='w', pady=2)
+ row += 1
+ self.winetricks_label.grid(column=0, row=row, sticky='w', pady=2)
+ self.run_winetricks_button.grid(column=1, row=row, sticky='w', pady=2)
+ self.get_winetricks_button.grid(column=2, row=row, sticky='w', pady=2)
+ row += 1
+ self.logging_label.grid(column=0, row=row, sticky='w', pady=2)
+ self.logging_button.grid(column=1, row=row, sticky='w', pady=2)
+ row += 1
+ s3.grid(column=0, row=row, columnspan=3, sticky='we', pady=2)
+ row += 1
+ self.message_label.grid(column=0, row=row, columnspan=3, sticky='we', pady=2) # noqa: E501
+ row += 1
+ self.progress.grid(column=0, row=row, columnspan=3, sticky='we', pady=2) # noqa: E501
class ToolTip:
@@ -318,3 +344,46 @@ def hide_tooltip(self, event=None):
if self.tooltip_visible:
self.tooltip_window.destroy()
self.tooltip_visible = False
+
+
+class PromptGui(Frame):
+ def __init__(self, root, title="", prompt="", **kwargs):
+ super(PromptGui, self).__init__(root, **kwargs)
+ self.options = {"title": title, "prompt": prompt}
+ if title is not None:
+ self.options['title'] = title
+ if prompt is not None:
+ self.options['prompt'] = prompt
+
+ def draw_prompt(self):
+ store_button = Button(
+ self.root,
+ text="Store Password",
+ command=lambda: input_prompt(self.root, self.options)
+ )
+ store_button.pack(pady=20)
+
+
+def show_error(message, fatal=True, detail=None, app=None, parent=None): # noqa: E501
+ title = "Error"
+ if fatal:
+ title = "Fatal Error"
+
+ kwargs = {'message': message}
+ if parent and hasattr(app, parent):
+ kwargs['parent'] = app.__dict__.get(parent)
+ if detail:
+ kwargs['detail'] = detail
+ messagebox.showerror(title, **kwargs)
+ if fatal and hasattr(app, 'root'):
+ app.root.destroy()
+
+
+def ask_question(question, secondary):
+ return messagebox.askquestion(question, secondary)
+
+
+def input_prompt(root, title, prompt):
+ # Prompt for the password
+ input = simpledialog.askstring(title, prompt, show='*', parent=root)
+ return input
diff --git a/gui_app.py b/ou_dedetai/gui_app.py
similarity index 77%
rename from gui_app.py
rename to ou_dedetai/gui_app.py
index 80fb75af..f06f9d73 100644
--- a/gui_app.py
+++ b/ou_dedetai/gui_app.py
@@ -6,7 +6,6 @@
import logging
from pathlib import Path
from queue import Queue
-from threading import Thread
from tkinter import PhotoImage
from tkinter import Tk
@@ -14,12 +13,15 @@
from tkinter import filedialog as fd
from tkinter.ttk import Style
-import config
-import control
-import gui
-import installer
-import utils
-import wine
+from . import config
+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 Root(Tk):
@@ -75,7 +77,7 @@ def __init__(self, *args, **kwargs):
# Set panel icon.
app_dir = Path(__file__).parent
- self.icon = app_dir / 'img' / 'logos4-128-icon.png'
+ self.icon = app_dir / 'img' / 'icon.png'
self.pi = PhotoImage(file=f'{self.icon}')
self.iconphoto(False, self.pi)
@@ -85,15 +87,14 @@ def __init__(self, new_win, root, **kwargs):
# Set root parameters.
self.win = new_win
self.root = root
- self.win.title("Faithlife Bible Software Installer")
+ self.win.title(f"{config.name_app} Installer")
self.win.resizable(False, False)
self.gui = gui.InstallerGui(self.win)
# Initialize variables.
self.flproduct = None # config.FLPRODUCT
- self.release_thread = None
+ self.config_thread = None
self.wine_exe = None
- self.wine_thread = None
self.winetricksbin = None
self.appimages = None
# self.appimage_verified = None
@@ -155,10 +156,8 @@ def __init__(self, new_win, root, **kwargs):
self.check_evt = "<>"
self.root.bind(self.check_evt, self.update_file_check_progress)
self.status_q = Queue()
- self.root.bind(
- "<>",
- self.update_status_text
- )
+ self.status_evt = "<>"
+ self.root.bind(self.status_evt, self.update_status_text)
self.progress_q = Queue()
self.root.bind(
"<>",
@@ -181,14 +180,16 @@ def __init__(self, new_win, root, **kwargs):
self.start_ensure_config()
def start_ensure_config(self):
- self.config_thread = Thread(
- target=installer.ensure_installation_config,
- kwargs={'app': self},
- daemon=True
+ # 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.config_thread.start()
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()
self.gui.tricksvar.set(self.gui.tricks_dropdown['values'][0])
@@ -213,6 +214,7 @@ def set_input_widgets_state(self, state, widgets='all'):
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():
@@ -228,7 +230,6 @@ def todo(self, evt=None, task=None):
self.gui.release_check_button,
self.gui.wine_dropdown,
self.gui.wine_check_button,
- # self.gui.tricks_dropdown,
self.gui.okay_button,
]
self.set_input_widgets_state('disabled', widgets=widgets)
@@ -248,7 +249,7 @@ def todo(self, evt=None, task=None):
if not self.gui.versionvar.get():
self.gui.versionvar.set(self.gui.version_dropdown['values'][1])
self.set_version()
- elif task == 'LOGOS_RELEASE_VERSION':
+ elif task == 'TARGET_RELEASE_VERSION':
# Disable all input widgets after Release.
widgets = [
self.gui.wine_dropdown,
@@ -256,23 +257,14 @@ def todo(self, evt=None, task=None):
self.gui.okay_button,
]
self.set_input_widgets_state('disabled', widgets=widgets)
- # if not self.gui.releasevar.get():
- if not self.gui.release_dropdown['values']:
- # No previous choice.
- self.start_releases_check()
- else:
- # Check if previous choice was for other TARGETVERSION.
- if config.TARGETVERSION == '9' and not self.gui.release_dropdown['values'][0].startswith('9'): # noqa: E501
- self.start_releases_check()
- if config.TARGETVERSION == '10' and self.gui.release_dropdown['values'][0].startswith('9'): # noqa: E501
- self.start_releases_check()
+ 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()
+ self.start_wine_versions_check(config.TARGET_RELEASE_VERSION)
elif task == 'WINETRICKSBIN':
# Disable all input widgets after Winetricks.
widgets = [
@@ -287,17 +279,35 @@ def todo(self, evt=None, task=None):
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()[0] == 'C': # ignore default text
+ if self.gui.productvar.get().startswith('C'): # ignore default text
return
self.gui.flproduct = 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
- config.WINETRICKSBIN = 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)
@@ -306,13 +316,20 @@ def set_version(self, evt=None):
self.gui.targetversion = self.gui.versionvar.get()
self.gui.version_dropdown.selection_clear()
if evt: # manual override; reset dependent variables
- logging.debug(f"Change TARGETVERSION to {self.gui.targetversion}")
+ logging.debug(f"User changed TARGETVERSION to '{self.gui.targetversion}'") # noqa: E501
config.TARGETVERSION = None
self.gui.releasevar.set('')
- config.LOGOS_RELEASE_VERSION = None
+ config.TARGET_RELEASE_VERSION = None
+ self.gui.releasevar.set('')
+
config.INSTALLDIR = None
+ config.APPDIR_BINDIR = None
+
config.WINE_EXE = None
- config.WINETRICKSBIN = 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)
@@ -328,17 +345,12 @@ def start_releases_check(self):
self.release_evt,
self.update_release_check_progress
)
- self.release_thread = Thread(
- target=utils.get_logos_releases,
- kwargs={'app': self},
- daemon=True,
- )
# Start progress.
self.gui.progress.config(mode='indeterminate')
self.gui.progress.start()
self.gui.statusvar.set("Downloading Release list…")
# Start thread.
- self.release_thread.start()
+ utils.start_thread(network.get_logos_releases, app=self)
def set_release(self, evt=None):
if self.gui.releasevar.get()[0] == 'C': # ignore default text
@@ -346,12 +358,22 @@ def set_release(self, evt=None):
self.gui.logos_release_version = self.gui.releasevar.get()
self.gui.release_dropdown.selection_clear()
if evt: # manual override
- config.LOGOS_RELEASE_VERSION = None
+ 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):
+ def start_find_appimage_files(self, release_version):
# Setup queue, signal, thread.
self.appimage_q = Queue()
self.appimage_evt = "<>"
@@ -359,22 +381,22 @@ def start_find_appimage_files(self):
self.appimage_evt,
self.update_find_appimage_progress
)
- self.appimage_thread = Thread(
- target=utils.find_appimage_files,
- kwargs={'app': self},
- daemon=True
- )
# Start progress.
self.gui.progress.config(mode='indeterminate')
self.gui.progress.start()
self.gui.statusvar.set("Finding available wine AppImages…")
# Start thread.
- self.appimage_thread.start()
+ utils.start_thread(
+ utils.find_appimage_files,
+ release_version=release_version,
+ app=self,
+ )
- def start_wine_versions_check(self):
- if not self.appimages:
- self.start_find_appimage_files()
- return
+ 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 = "<>"
@@ -382,30 +404,35 @@ def start_wine_versions_check(self):
self.wine_evt,
self.update_wine_check_progress
)
- self.wine_thread = Thread(
- target=utils.get_wine_options,
- args=[
- self.appimages,
- utils.find_wine_binary_files()
- ],
- kwargs={'app': self},
- daemon=True
- )
# Start progress.
self.gui.progress.config(mode='indeterminate')
self.gui.progress.start()
self.gui.statusvar.set("Finding available wine binaries…")
# Start thread.
- self.wine_thread.start()
+ utils.start_thread(
+ utils.get_wine_options,
+ self.appimages,
+ utils.find_wine_binary_files(release_version),
+ app=self,
+ )
def set_wine(self, evt=None):
self.gui.wine_exe = 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(self.gui.wine_exe)
+ self.wine_q.put(
+ utils.get_relative_path(
+ utils.get_config_var(self.gui.wine_exe),
+ config.INSTALLDIR
+ )
+ )
def set_winetricks(self, evt=None):
self.gui.winetricksbin = self.gui.tricksvar.get()
@@ -421,7 +448,7 @@ def on_release_check_released(self, evt=None):
def on_wine_check_released(self, evt=None):
self.gui.wine_check_button.state(['disabled'])
- self.start_wine_versions_check()
+ self.start_wine_versions_check(config.TARGET_RELEASE_VERSION)
def set_skip_fonts(self, evt=None):
self.gui.skip_fonts = 1 - self.gui.fontsvar.get() # invert True/False
@@ -444,12 +471,7 @@ def on_cancel_released(self, evt=None):
def start_install_thread(self, evt=None):
self.gui.progress.config(mode='determinate')
- th = Thread(
- target=installer.ensure_launcher_shortcuts,
- kwargs={'app': self},
- daemon=True
- )
- th.start()
+ utils.start_thread(installer.ensure_launcher_shortcuts, app=self)
def start_indeterminate_progress(self, evt=None):
self.gui.progress.state(['!disabled'])
@@ -478,13 +500,15 @@ 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()
+ 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()
- self.gui.winevar.set(self.gui.wine_dropdown['values'][0])
+ 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'])
@@ -536,9 +560,11 @@ class ControlWindow():
def __init__(self, root, *args, **kwargs):
# Set root parameters.
self.root = root
- self.root.title("Faithlife Bible Software Control Panel")
+ 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
@@ -555,7 +581,10 @@ def __init__(self, root, *args, **kwargs):
self.gui.remove_index_files_radio.config(
command=self.on_action_radio_clicked
)
- self.gui.actions_button.config(command=self.gui.actioncmd)
+ self.gui.install_icu_radio.config(
+ command=self.on_action_radio_clicked
+ )
+ self.gui.actions_button.config(command=self.run_action_cmd)
self.gui.loggingstatevar.set('Enable')
self.gui.logging_button.config(
@@ -593,11 +622,12 @@ def __init__(self, root, *args, **kwargs):
self.update_run_winetricks_button()
self.logging_q = Queue()
- self.root.bind('<>', self.initialize_logging_button)
- self.root.bind('<>', self.update_logging_button)
+ 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.root.bind('<>', self.update_status_text)
self.progress_q = Queue()
self.root.bind(
'<>',
@@ -624,16 +654,13 @@ def __init__(self, root, *args, **kwargs):
# Start function to determine app logging state.
if utils.app_is_installed():
- t = Thread(
- target=wine.get_app_logging_state,
- kwargs={'app': self, 'init': True}
- )
- t.start()
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.find_installed_product():
+ 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'])
@@ -641,39 +668,43 @@ def configure_app_button(self, evt=None):
self.gui.app_button.config(command=self.run_installer)
def run_installer(self, evt=None):
- classname = "LogosLinuxInstaller"
- self.new_win = Toplevel()
- InstallerWindow(self.new_win, self.root, class_=classname)
+ classname = config.name_binary
+ self.installer_win = Toplevel()
+ InstallerWindow(self.installer_win, self.root, class_=classname)
self.root.icon = config.LOGOS_ICON_URL
def run_logos(self, evt=None):
- t = Thread(target=wine.run_logos)
- t.start()
+ utils.start_thread(self.logos.start)
+
+ def run_action_cmd(self, evt=None):
+ 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():
self.gui.actions_button.state(['!disabled'])
if self.gui.actionsvar.get() == 'run-indexing':
- self.gui.actioncmd = self.run_indexing
+ self.actioncmd = self.run_indexing
elif self.gui.actionsvar.get() == 'remove-library-catalog':
- self.gui.actioncmd = self.remove_library_catalog
+ self.actioncmd = self.remove_library_catalog
elif self.gui.actionsvar.get() == 'remove-index-files':
- self.gui.actioncmd = self.remove_indexes
+ self.actioncmd = self.remove_indexes
+ elif self.gui.actionsvar.get() == 'install-icu':
+ self.actioncmd = self.install_icu
def run_indexing(self, evt=None):
- t = Thread(target=wine.run_indexing)
- t.start()
+ utils.start_thread(self.logos.index)
def remove_library_catalog(self, evt=None):
control.remove_library_catalog()
def remove_indexes(self, evt=None):
self.gui.statusvar.set("Removing indexes…")
- t = Thread(
- target=control.remove_all_index_files,
- kwargs={'app': self}
- )
- t.start()
+ utils.start_thread(control.remove_all_index_files, app=self)
+
+ def install_icu(self, evt=None):
+ self.gui.statusvar.set("Installing ICU files…")
+ utils.start_thread(wine.enforce_icu_data_files, app=self)
def run_backup(self, evt=None):
# Get backup folder.
@@ -691,17 +722,16 @@ def run_backup(self, evt=None):
self.gui.progress.config(mode='determinate')
self.gui.progressvar.set(0)
# Start backup thread.
- t = Thread(target=control.backup, args=[self], daemon=True)
- t.start()
+ utils.start_thread(control.backup, app=self)
def run_restore(self, evt=None):
# FIXME: Allow user to choose restore source?
# Start restore thread.
- t = Thread(target=control.restore, args=[self], daemon=True)
- t.start()
+ utils.start_thread(control.restore, app=self)
def install_deps(self, evt=None):
- utils.check_dependencies()
+ self.start_indeterminate_progress()
+ utils.start_thread(utils.install_dependencies)
def open_file_dialog(self, filetype_name, filetype_extension):
file_path = fd.askopenfilename(
@@ -715,71 +745,53 @@ def open_file_dialog(self, filetype_name, filetype_extension):
def update_to_latest_lli_release(self, evt=None):
self.start_indeterminate_progress()
- self.gui.statusvar.set("Updating to latest Logos Linux Installer version…") # noqa: E501
- t = Thread(
- target=utils.update_to_latest_lli_release,
- kwargs={'app': self},
- daemon=True,
- )
- t.start()
+ 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)
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…")
- t = Thread(
- target=utils.set_appimage_symlink,
- kwargs={'app': self},
- daemon=True,
- )
- t.start()
+ utils.start_thread(utils.set_appimage_symlink, app=self)
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
- t = Thread(
- target=utils.set_appimage_symlink,
- kwargs={'app': self},
- daemon=True,
- )
- t.start()
+ # config.SELECTED_APPIMAGE_FILENAME = appimage_filename
+ config.APPIMAGE_FILE_PATH = appimage_filename
+ utils.start_thread(utils.set_appimage_symlink, app=self)
def get_winetricks(self, evt=None):
+ # TODO: Separate as advanced feature.
self.gui.statusvar.set("Installing Winetricks…")
- t1 = Thread(
- target=utils.install_winetricks,
- args=[config.APPDIR_BINDIR],
- kwargs={'app': self},
- daemon=True,
+ utils.start_thread(
+ system.install_winetricks,
+ config.APPDIR_BINDIR,
+ app=self
)
- t1.start()
self.update_run_winetricks_button()
def launch_winetricks(self, evt=None):
self.gui.statusvar.set("Launching Winetricks…")
# Start winetricks in thread.
- t1 = Thread(target=wine.run_winetricks)
- t1.start()
+ utils.start_thread(wine.run_winetricks)
# Start thread to clear status after delay.
args = [12000, self.root.event_generate, '<>']
- t2 = Thread(target=self.root.after, args=args, daemon=True)
- t2.start()
+ utils.start_thread(self.root.after, *args)
def switch_logging(self, evt=None):
- prev_state = self.gui.loggingstatevar.get()
- new_state = 'Enable' if prev_state == 'Disable' else 'Disable'
- kwargs = {
- 'action': new_state.lower(),
- 'app': self,
- }
- self.gui.statusvar.set(f"Switching app logging to '{prev_state}d'…")
+ 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.gui.logging_button.state(['disabled'])
- t = Thread(target=wine.switch_logging, kwargs=kwargs)
- t.start()
+ utils.start_thread(
+ self.logos.switch_logging,
+ action=desired_state.lower()
+ )
def initialize_logging_button(self, evt=None):
self.gui.statusvar.set('')
@@ -793,8 +805,10 @@ def update_logging_button(self, evt=None):
self.gui.statusvar.set('')
self.gui.progress.stop()
self.gui.progress.state(['disabled'])
- state = self.logging_q.get()
- self.gui.loggingstatevar.set(state[:-1].title())
+ 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)
self.gui.logging_button.state(['!disabled'])
def update_app_button(self, evt=None):
@@ -802,21 +816,21 @@ def update_app_button(self, evt=None):
self.gui.app_buttonvar.set(f"Run {config.FLPRODUCT}")
self.configure_app_button()
self.update_run_winetricks_button()
+ self.gui.logging_button.state(['!disabled'])
def update_latest_lli_release_button(self, evt=None):
- status, reason = utils.compare_logos_linux_installer_version()
msg = None
- if utils.get_runmode() != 'binary':
+ if system.get_runmode() != 'binary':
state = 'disabled'
msg = "This button is disabled. Can't run self-update from script."
- elif status == 0:
+ elif config.logos_linux_installer_status == 0:
state = '!disabled'
- elif status == 1:
+ elif config.logos_linux_installer_status == 1:
state = 'disabled'
- msg = "This button is disabled. Logos Linux Installer is up-to-date." # noqa: E501
- elif status == 2:
+ msg = f"This button is disabled. {config.name_app} is up-to-date." # noqa: E501
+ elif config.logos_linux_installer_status == 2:
state = 'disabled'
- msg = "This button is disabled. Logos Linux Installer is newer than the latest release." # noqa: E501
+ msg = f"This button is disabled. {config.name_app} is newer than the latest release." # noqa: E501
if msg:
gui.ToolTip(self.gui.update_lli_button, msg)
self.clear_status_text()
@@ -867,10 +881,6 @@ def update_download_progress(self, evt=None):
self.gui.progressvar.set(int(d))
def update_progress(self, evt=None):
- if self.config_thread.is_alive():
- # Don't update config progress.
- self.gui.progressvar.set(0)
- return
progress = self.progress_q.get()
if not type(progress) is int:
return
@@ -881,7 +891,12 @@ def update_progress(self, evt=None):
self.gui.progressvar.set(progress)
def update_status_text(self, evt=None):
- self.gui.statusvar.set(self.status_q.get())
+ 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 start_indeterminate_progress(self, evt=None):
self.gui.progress.state(['!disabled'])
@@ -898,7 +913,7 @@ def stop_indeterminate_progress(self, evt=None):
def control_panel_app():
utils.set_debug()
- classname = "LogosLinuxControlPanel"
+ classname = config.name_binary
root = Root(className=classname)
ControlWindow(root, class_=classname)
root.mainloop()
diff --git a/ou_dedetai/img/icon.png b/ou_dedetai/img/icon.png
new file mode 100644
index 00000000..01261242
Binary files /dev/null and b/ou_dedetai/img/icon.png differ
diff --git a/ou_dedetai/img/icon.svg b/ou_dedetai/img/icon.svg
new file mode 100644
index 00000000..7ee9f42e
--- /dev/null
+++ b/ou_dedetai/img/icon.svg
@@ -0,0 +1,125 @@
+
+
+
+
diff --git a/img/logos4-128-icon.png b/ou_dedetai/img/logos4-128-icon.png
similarity index 100%
rename from img/logos4-128-icon.png
rename to ou_dedetai/img/logos4-128-icon.png
diff --git a/img/step_01.png b/ou_dedetai/img/step_01.png
similarity index 100%
rename from img/step_01.png
rename to ou_dedetai/img/step_01.png
diff --git a/img/step_02.png b/ou_dedetai/img/step_02.png
similarity index 100%
rename from img/step_02.png
rename to ou_dedetai/img/step_02.png
diff --git a/img/step_03.png b/ou_dedetai/img/step_03.png
similarity index 100%
rename from img/step_03.png
rename to ou_dedetai/img/step_03.png
diff --git a/img/step_04.png b/ou_dedetai/img/step_04.png
similarity index 100%
rename from img/step_04.png
rename to ou_dedetai/img/step_04.png
diff --git a/img/step_05.png b/ou_dedetai/img/step_05.png
similarity index 100%
rename from img/step_05.png
rename to ou_dedetai/img/step_05.png
diff --git a/img/step_06.png b/ou_dedetai/img/step_06.png
similarity index 100%
rename from img/step_06.png
rename to ou_dedetai/img/step_06.png
diff --git a/img/step_07.png b/ou_dedetai/img/step_07.png
similarity index 100%
rename from img/step_07.png
rename to ou_dedetai/img/step_07.png
diff --git a/img/step_08.png b/ou_dedetai/img/step_08.png
similarity index 100%
rename from img/step_08.png
rename to ou_dedetai/img/step_08.png
diff --git a/img/step_09.png b/ou_dedetai/img/step_09.png
similarity index 100%
rename from img/step_09.png
rename to ou_dedetai/img/step_09.png
diff --git a/img/step_10.png b/ou_dedetai/img/step_10.png
similarity index 100%
rename from img/step_10.png
rename to ou_dedetai/img/step_10.png
diff --git a/img/step_11.png b/ou_dedetai/img/step_11.png
similarity index 100%
rename from img/step_11.png
rename to ou_dedetai/img/step_11.png
diff --git a/img/step_12.png b/ou_dedetai/img/step_12.png
similarity index 100%
rename from img/step_12.png
rename to ou_dedetai/img/step_12.png
diff --git a/img/step_13.png b/ou_dedetai/img/step_13.png
similarity index 100%
rename from img/step_13.png
rename to ou_dedetai/img/step_13.png
diff --git a/img/step_14.png b/ou_dedetai/img/step_14.png
similarity index 100%
rename from img/step_14.png
rename to ou_dedetai/img/step_14.png
diff --git a/img/step_15.png b/ou_dedetai/img/step_15.png
similarity index 100%
rename from img/step_15.png
rename to ou_dedetai/img/step_15.png
diff --git a/img/step_16.png b/ou_dedetai/img/step_16.png
similarity index 100%
rename from img/step_16.png
rename to ou_dedetai/img/step_16.png
diff --git a/img/step_17.png b/ou_dedetai/img/step_17.png
similarity index 100%
rename from img/step_17.png
rename to ou_dedetai/img/step_17.png
diff --git a/img/step_18.png b/ou_dedetai/img/step_18.png
similarity index 100%
rename from img/step_18.png
rename to ou_dedetai/img/step_18.png
diff --git a/img/step_19.png b/ou_dedetai/img/step_19.png
similarity index 100%
rename from img/step_19.png
rename to ou_dedetai/img/step_19.png
diff --git a/img/step_20.png b/ou_dedetai/img/step_20.png
similarity index 100%
rename from img/step_20.png
rename to ou_dedetai/img/step_20.png
diff --git a/img/step_21.png b/ou_dedetai/img/step_21.png
similarity index 100%
rename from img/step_21.png
rename to ou_dedetai/img/step_21.png
diff --git a/img/step_22.png b/ou_dedetai/img/step_22.png
similarity index 100%
rename from img/step_22.png
rename to ou_dedetai/img/step_22.png
diff --git a/img/verbum-128-icon.png b/ou_dedetai/img/verbum-128-icon.png
similarity index 100%
rename from 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
new file mode 100644
index 00000000..756ece4d
--- /dev/null
+++ b/ou_dedetai/installer.py
@@ -0,0 +1,883 @@
+import logging
+import os
+import shutil
+import sys
+from pathlib import Path
+
+from . import config
+from . import msg
+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')
+ 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.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=}")
+
+ config.WINEPREFIX = f"{config.INSTALLDIR}/data/wine64_bottle"
+ wine_dir = Path(f"{config.WINEPREFIX}")
+ 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')
+
+
+def ensure_sys_deps(app=None):
+ config.INSTALL_STEPS_COUNT += 1
+ ensure_install_dirs(app=app)
+ config.INSTALL_STEP += 1
+ update_install_feedback("Ensuring system dependencies are met…", app=app)
+
+ if not config.SKIP_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
+ 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
+ return
+ update_install_feedback(
+ "Ensuring wine AppImage is downloaded…",
+ app=app
+ )
+
+ downloaded_file = None
+ filename = Path(config.SELECTED_APPIMAGE_FILENAME).name
+ downloaded_file = utils.get_downloaded_file_path(filename)
+ if not downloaded_file:
+ downloaded_file = Path(f"{config.MYDOWNLOADS}/{filename}")
+ network.logos_reuse_download(
+ config.RECOMMENDED_WINE64_APPIMAGE_URL,
+ filename,
+ config.MYDOWNLOADS,
+ 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
+ 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)
+
+ # Set WINESERVER_EXE.
+ config.WINESERVER_EXE = f"{config.APPDIR_BINDIR}/wineserver"
+
+ # 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")
+
+
+def ensure_winetricks_executable(app=None):
+ config.INSTALL_STEPS_COUNT += 1
+ ensure_wine_executables(app=app)
+ config.INSTALL_STEP += 1
+ update_install_feedback(
+ "Ensuring winetricks executable is available…",
+ app=app
+ )
+
+ 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)
+
+ logging.debug(f"> {config.WINETRICKSBIN} is executable?: {os.access(config.WINETRICKSBIN, os.X_OK)}") # noqa: E501
+ return 0
+
+
+def ensure_premade_winebottle_download(app=None):
+ config.INSTALL_STEPS_COUNT += 1
+ ensure_winetricks_executable(app=app)
+ config.INSTALL_STEP += 1
+ if config.TARGETVERSION != '9':
+ return
+ update_install_feedback(
+ f"Ensuring {config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME} bottle is downloaded…", # noqa: E501
+ app=app
+ )
+
+ downloaded_file = utils.get_downloaded_file_path(config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME) # noqa: E501
+ if not downloaded_file:
+ downloaded_file = Path(config.MYDOWNLOADS) / config.LOGOS_EXECUTABLE
+ network.logos_reuse_download(
+ config.LOGOS9_WINE64_BOTTLE_TARGZ_URL,
+ config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME,
+ config.MYDOWNLOADS,
+ app=app,
+ )
+ # Install bottle.
+ bottle = Path(f"{config.INSTALLDIR}/data/wine64_bottle")
+ if not bottle.is_dir():
+ utils.install_premade_wine_bottle(
+ config.MYDOWNLOADS,
+ f"{config.INSTALLDIR}/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
+ ensure_premade_winebottle_download(app=app)
+ config.INSTALL_STEP += 1
+ update_install_feedback(
+ f"Ensuring {config.FLPRODUCT} installer is downloaded…",
+ app=app
+ )
+
+ config.LOGOS_EXECUTABLE = f"{config.FLPRODUCT}_v{config.LOGOS_VERSION}-x64.msi" # noqa: E501
+ downloaded_file = utils.get_downloaded_file_path(config.LOGOS_EXECUTABLE)
+ if not downloaded_file:
+ downloaded_file = Path(config.MYDOWNLOADS) / config.LOGOS_EXECUTABLE
+ network.logos_reuse_download(
+ config.LOGOS64_URL,
+ config.LOGOS_EXECUTABLE,
+ config.MYDOWNLOADS,
+ app=app,
+ )
+ # Copy file into INSTALLDIR.
+ installer = Path(f"{config.INSTALLDIR}/data/{config.LOGOS_EXECUTABLE}")
+ 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
+ ensure_product_installer_download(app=app)
+ config.INSTALL_STEP += 1
+ update_install_feedback("Ensuring wineprefix is initialized…", app=app)
+
+ init_file = Path(f"{config.WINEPREFIX}/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':
+ utils.install_premade_wine_bottle(
+ config.MYDOWNLOADS,
+ f"{config.INSTALLDIR}/data",
+ )
+ else:
+ logging.debug("Initializing wineprefix.")
+ process = wine.initializeWineBottle()
+ wine.wait_pid(process)
+ # wine.light_wineserver_wait()
+ wine.wineserver_wait()
+ 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
+ ensure_wineprefix_init(app=app)
+ config.INSTALL_STEP += 1
+ status = "Ensuring winetricks & other settings are applied…"
+ update_install_feedback(status, app=app)
+ logging.debug('- disable winemenubuilder')
+ logging.debug('- settings renderer=gdi')
+ logging.debug('- corefonts')
+ logging.debug('- tahoma')
+ logging.debug('- settings fontsmooth=rgb')
+ logging.debug('- d3dcompiler_47')
+
+ if not config.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")
+
+ if not utils.grep(r'"winemenubuilder.exe"=""', usr_reg):
+ msg.status("Disabling winemenubuilder…", app)
+ wine.disable_winemenubuilder()
+
+ if not utils.grep(r'"renderer"="gdi"', usr_reg):
+ msg.status("Setting Renderer to GDI…", app)
+ wine.set_renderer("gdi")
+
+ if not utils.grep(r'"FontSmoothingType"=dword:00000002', usr_reg):
+ msg.status("Setting Font Smooting to RGB…", app)
+ wine.install_font_smoothing()
+
+ 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 utils.grep(r'"\*d3dcompiler_47"="native"', usr_reg):
+ msg.status("Installing D3D…", app)
+ wine.install_d3d_compiler()
+
+ 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")
+
+ # 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")
+ # wine.light_wineserver_wait()
+ wine.wineserver_wait()
+ logging.debug("> Done.")
+
+
+def ensure_icu_data_files(app=None):
+ config.INSTALL_STEPS_COUNT += 1
+ ensure_winetricks_applied(app=app)
+ config.INSTALL_STEP += 1
+ status = "Ensuring ICU data files are installed…"
+ update_install_feedback(status, app=app)
+ 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
+ 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
+
+ wine.set_logos_paths()
+
+ # Clean up temp files, etc.
+ utils.clean_all()
+
+ logging.debug(f"> Product path: {config.LOGOS_EXE}")
+
+
+def ensure_config_file(app=None):
+ config.INSTALL_STEPS_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')
+
+ logging.debug(f"> File exists?: {config.CONFIG_FILE}: {Path(config.CONFIG_FILE).is_file()}") # noqa: E501
+
+
+def ensure_launcher_executable(app=None):
+ config.INSTALL_STEPS_COUNT += 1
+ ensure_config_file(app=app)
+ config.INSTALL_STEP += 1
+ runmode = system.get_runmode()
+ if runmode == 'binary':
+ update_install_feedback(
+ f"Copying launcher to {config.INSTALLDIR}…",
+ app=app
+ )
+
+ # Copy executable to config.INSTALLDIR.
+ launcher_exe = Path(f"{config.INSTALLDIR}/{config.name_binary}")
+ if launcher_exe.is_file():
+ logging.debug("Removing existing launcher binary.")
+ launcher_exe.unlink()
+ logging.info(f"Creating launcher binary by copying this installer binary to {launcher_exe}.") # noqa: E501
+ 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
+ )
+
+
+def ensure_launcher_shortcuts(app=None):
+ config.INSTALL_STEPS_COUNT += 1
+ ensure_launcher_executable(app=app)
+ config.INSTALL_STEP += 1
+ update_install_feedback("Creating launcher shortcuts…", app=app)
+ runmode = system.get_runmode()
+ if runmode == 'binary':
+ update_install_feedback("Creating launcher shortcuts…", app=app)
+ create_launcher_shortcuts()
+ else:
+ update_install_feedback(
+ "Running from source. Skipping launcher creation.",
+ app=app
+ )
+
+ 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 get_progress_pct(current, total):
+ if total == 0:
+ logging.warning(f"Progress {total=}; can't divide by zero")
+ pct = 0
+ else:
+ pct = round(current * 100 / total)
+ if pct > 100:
+ logging.warning(f"Progress {pct=}; setting to \"100\"")
+ pct = 100
+ return pct
+
+
+def create_wine_appimage_symlinks(app=None):
+ appdir_bindir = Path(config.APPDIR_BINDIR)
+ os.environ['PATH'] = f"{config.APPDIR_BINDIR}:{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.unlink(missing_ok=True) # remove & replace
+ appimage_link.symlink_to(f"./{appimage_filename}")
+
+ # Ensure wine executables symlinks.
+ for name in ["wine", "wine64", "wineserver", "winetricks"]:
+ 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
+
+
+def create_desktop_file(name, contents):
+ launcher_path = Path(f"~/.local/share/applications/{name}").expanduser()
+ if launcher_path.is_file():
+ logging.info(f"Removing desktop launcher at {launcher_path}.")
+ launcher_path.unlink()
+ # Ensure the parent directory exists
+ launcher_path.parent.mkdir(parents=True, exist_ok=True)
+
+ logging.info(f"Creating desktop launcher at {launcher_path}.")
+ with launcher_path.open('w') as f:
+ f.write(contents)
+ os.chmod(launcher_path, 0o755)
+
+
+def create_launcher_shortcuts():
+ # 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
+ if not installdir.is_dir():
+ reason = "because the installation folder does not exist."
+ msg.logos_warning(f"{m} {reason}")
+ return
+ 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}"
+ else:
+ script = Path(sys.argv[0]).expanduser().resolve()
+ repo_dir = None
+ for p in script.parents:
+ for c in p.iterdir():
+ if c.name == '.git':
+ repo_dir = p
+ break
+ # 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
+ 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
+ if not path.is_file():
+ app_dir.mkdir(exist_ok=True)
+ shutil.copy(src, path)
+ else:
+ logging.info(f"Icon found at {path}.")
+
+ # Set launcher file names and content.
+ desktop_files = [
+ (
+ f"{flproduct}Bible.desktop",
+ f"""[Desktop Entry]
+Name={flproduct}Bible
+Comment=A Bible Study Library with Built-In Tools
+Exec={lli_executable} --run-installed-app
+Icon={logos_icon_path}
+Terminal=false
+Type=Application
+StartupWMClass={flproduct.lower()}.exe
+Categories=Education;
+Keywords={flproduct};Logos;Bible;Control;
+"""
+ ),
+ (
+ f"{config.name_binary}.desktop",
+ f"""[Desktop Entry]
+Name={config.name_app}
+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}
+Categories=Education;
+Keywords={flproduct};Logos;Bible;Control;
+"""
+ ),
+ ]
+
+ # Create the files.
+ for file_name, content in desktop_files:
+ create_desktop_file(file_name, content)
+ fpath = Path.home() / '.local' / 'share' / 'applications' / file_name
+ logging.debug(f"> File exists?: {fpath}: {fpath.is_file()}")
diff --git a/ou_dedetai/logos.py b/ou_dedetai/logos.py
new file mode 100644
index 00000000..6c6c9fe0
--- /dev/null
+++ b/ou_dedetai/logos.py
@@ -0,0 +1,268 @@
+import time
+from enum import Enum
+import logging
+import psutil
+import threading
+
+from . import config
+from . import main
+from . import msg
+from . import system
+from . import utils
+from . import wine
+
+
+class State(Enum):
+ RUNNING = 1
+ STOPPED = 2
+ STARTING = 3
+ STOPPING = 4
+
+
+class LogosManager:
+ def __init__(self, app=None):
+ self.logos_state = State.STOPPED
+ self.indexing_state = State.STOPPED
+ self.app = app
+
+ def monitor_indexing(self):
+ if config.logos_indexer_cmd in config.processes:
+ indexer = config.processes.get(config.logos_indexer_cmd)
+ 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_running = splash[0].is_running() if splash else False
+ login_running = login[0].is_running() if login else False
+ cef_running = cef[0].is_running() if cef else False
+ # logging.debug(f"{self.logos_state=}")
+ # logging.debug(f"{splash_running=}; {login_running=}; {cef_running=}")
+
+ if self.logos_state == State.STARTING:
+ if login_running or cef_running:
+ self.logos_state = State.RUNNING
+ elif self.logos_state == State.RUNNING:
+ if not any((splash_running, login_running, cef_running)):
+ self.stop()
+ elif self.logos_state == State.STOPPING:
+ pass
+ elif self.logos_state == State.STOPPED:
+ if splash_running:
+ self.logos_state = State.STARTING
+ if login_running:
+ self.logos_state = State.RUNNING
+ if cef_running:
+ self.logos_state = State.RUNNING
+
+ def monitor(self):
+ if utils.app_is_installed():
+ system.get_logos_pids()
+ try:
+ self.monitor_indexing()
+ self.monitor_logos()
+ except Exception as e:
+ # pass
+ logging.error(e)
+
+ def start(self):
+ self.logos_state = State.STARTING
+ wine_release, _ = wine.get_wine_release(str(utils.get_wine_exe_path()))
+
+ def run_logos():
+ wine.run_wine_proc(
+ str(utils.get_wine_exe_path()),
+ exe=config.LOGOS_EXE
+ )
+
+ # Ensure wine version is compatible with Logos release version.
+ good_wine, reason = wine.check_wine_rules(
+ wine_release,
+ config.current_logos_version
+ )
+ if not good_wine:
+ msg.logos_error(reason, app=self)
+ else:
+ if reason is not None:
+ logging.debug(f"Warning: Wine Check: {reason}")
+ wine.wineserver_kill()
+ app = self.app
+ if config.DIALOG == 'tk':
+ # 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)
+ # 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':
+ # run_logos()
+ # self.monitor()
+ # while config.processes.get(config.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 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.")
+ self.logos_state = State.STOPPED
+ wine.wineserver_wait()
+
+ 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
+ )
+
+ def check_if_indexing(process):
+ start_time = time.time()
+ last_time = start_time
+ update_send = 0
+ while process.poll() is None:
+ update, last_time = utils.stopwatch(last_time, 3)
+ if update:
+ update_send = update_send + 1
+ if update_send == 10:
+ total_elapsed_time = time.time() - start_time
+ 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
+ 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()
+
+ wine.wineserver_kill()
+ msg.status("Indexing has begun…", self.app)
+ index_thread = utils.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(
+ check_if_indexing,
+ process,
+ 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
+
+ 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])
+ else:
+ logging.debug(f"No LogosIndexer processes found for {process_name}.") # noqa: E501
+
+ if pids:
+ 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
+ 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()
+
+ def get_app_logging_state(self, init=False):
+ state = 'DISABLED'
+ current_value = wine.get_registry_value(
+ 'HKCU\\Software\\Logos4\\Logging',
+ 'Enabled'
+ )
+ 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):
+ state_disabled = 'DISABLED'
+ value_disabled = '0000'
+ state_enabled = 'ENABLED'
+ value_enabled = '0001'
+ if action == 'disable':
+ value = value_disabled
+ state = state_disabled
+ elif action == 'enable':
+ value = value_enabled
+ state = state_enabled
+ else:
+ current_state = self.get_app_logging_state()
+ logging.debug(f"app logging {current_state=}")
+ if current_state == state_enabled:
+ value = value_disabled
+ state = state_disabled
+ else:
+ value = value_enabled
+ state = state_enabled
+
+ logging.info(f"Setting app logging to '{state}'.")
+ exe_args = [
+ 'add', 'HKCU\\Software\\Logos4\\Logging', '/v', 'Enabled',
+ '/t', 'REG_DWORD', '/d', value, '/f'
+ ]
+ process = wine.run_wine_proc(
+ str(utils.get_wine_exe_path()),
+ 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)
diff --git a/LogosLinuxInstaller.py b/ou_dedetai/main.py
similarity index 58%
rename from LogosLinuxInstaller.py
rename to ou_dedetai/main.py
index e85e0411..996c4be1 100755
--- a/LogosLinuxInstaller.py
+++ b/ou_dedetai/main.py
@@ -1,20 +1,31 @@
#!/usr/bin/env python3
+import argparse
+import curses
+try:
+ import dialog # noqa: F401
+except ImportError:
+ pass
import logging
import os
-import argparse
+import shutil
+import sys
+
+from . import cli
+from . import config
+from . import control
+from . import gui_app
+from . import msg
+from . import network
+from . import system
+from . import tui_app
+from . import utils
+from . import wine
-import config
-import control
-import gui_app
-import installer
-import msg
-import tui_app
-import utils
-import wine
+from .config import processes, threads
def get_parser():
- desc = "Installs FaithLife Bible Software with Wine on Linux."
+ desc = "Installs FaithLife Bible Software with Wine."
parser = argparse.ArgumentParser(description=desc)
parser.add_argument(
'-v', '--version', action='version',
@@ -26,10 +37,6 @@ def get_parser():
# Define options that affect runtime config.
cfg = parser.add_argument_group(title="runtime config options")
- cfg.add_argument(
- '-F', '--skip-fonts', action='store_true',
- help='skip font installations',
- )
cfg.add_argument(
'-a', '--check-for-updates', action='store_true',
help='force a check for updates'
@@ -38,6 +45,14 @@ def get_parser():
'-K', '--skip-dependencies', action='store_true',
help='skip dependencies check and installation',
)
+ cfg.add_argument(
+ '-F', '--skip-fonts', action='store_true',
+ help='skip font installations',
+ )
+ cfg.add_argument(
+ '-W', '--skip-winetricks', action='store_true',
+ help='skip winetricks installations. For development purposes only!!!',
+ )
cfg.add_argument(
'-V', '--verbose', action='store_true',
help='enable verbose mode',
@@ -123,7 +138,7 @@ def get_parser():
)
cmd.add_argument(
'--update-self', '-u', action='store_true',
- help='Update Logos Linux Installer to the latest release.',
+ help=f'Update {config.name_app} to the latest release.',
)
cmd.add_argument(
'--update-latest-appimage', '-U', action='store_true',
@@ -141,6 +156,18 @@ def get_parser():
'--run-winetricks', action='store_true',
help='start Winetricks window',
)
+ cmd.add_argument(
+ '--install-d3d-compiler', action='store_true',
+ help='Install d3dcompiler through Winetricks',
+ )
+ cmd.add_argument(
+ '--install-fonts', action='store_true',
+ help='Install fonts through Winetricks',
+ )
+ cmd.add_argument(
+ '--install-icu', action='store_true',
+ help='Install ICU data files for Logos 30+',
+ )
cmd.add_argument(
'--toggle-app-logging', action='store_true',
help='enable/disable app logs',
@@ -163,6 +190,10 @@ def get_parser():
# help='check resources'
help=argparse.SUPPRESS,
)
+ cmd.add_argument(
+ '--winetricks', nargs='+',
+ help="run winetricks command",
+ )
return parser
@@ -180,10 +211,16 @@ def parse_args(args, parser):
if args.delete_log:
config.DELETE_LOG = True
+ if args.set_appimage:
+ config.APPIMAGE_FILE_PATH = args.set_appimage[0]
+
if args.skip_fonts:
config.SKIP_FONTS = True
- if args.check_for_updates:
+ if args.skip_winetricks:
+ config.SKIP_WINETRICKS = True
+
+ if network.check_for_updates:
config.CHECK_UPDATES = True
if args.skip_dependencies:
@@ -204,24 +241,27 @@ def parse_args(args, parser):
# Set ACTION function.
actions = {
- 'install_app': installer.ensure_launcher_shortcuts,
- 'run_installed_app': wine.run_logos,
- 'run_indexing': wine.run_indexing,
- 'remove_library_catalog': control.remove_library_catalog,
- 'remove_index_files': control.remove_all_index_files,
- 'edit_config': control.edit_config,
- 'install_dependencies': utils.check_dependencies,
- 'backup': control.backup,
- 'restore': control.restore,
- 'update_self': utils.update_to_latest_lli_release,
- 'update_latest_appimage': utils.update_to_latest_recommended_appimage,
- 'set_appimage': utils.set_appimage_symlink,
- # 'get_winetricks': control.get_winetricks,
- 'get_winetricks': control.set_winetricks,
- 'run_winetricks': wine.run_winetricks,
- 'toggle_app_logging': wine.switch_logging,
- 'create_shortcuts': installer.ensure_launcher_shortcuts,
- 'remove_install_dir': control.remove_install_dir,
+ '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
@@ -241,6 +281,8 @@ def parse_args(args, parser):
if not utils.check_appimage(config.APPIMAGE_FILE_PATH):
e = f"{config.APPIMAGE_FILE_PATH} is not an AppImage."
raise argparse.ArgumentTypeError(e)
+ if arg == 'winetricks':
+ config.winetricks_args = getattr(args, 'winetricks')
config.ACTION = action
break
if config.ACTION is None:
@@ -253,10 +295,26 @@ def run_control_panel():
if config.DIALOG is None or config.DIALOG == 'tk':
gui_app.control_panel_app()
else:
- tui_app.control_panel_app()
-
-
-def main():
+ try:
+ curses.wrapper(tui_app.control_panel_app)
+ except KeyboardInterrupt:
+ raise
+ except SystemExit:
+ logging.info("Caught SystemExit, exiting gracefully...")
+ try:
+ close()
+ except Exception as e:
+ raise e
+ raise
+ except curses.error as e:
+ logging.error(f"Curses error in run_control_panel(): {e}")
+ raise e
+ except Exception as e:
+ logging.error(f"An error occurred in run_control_panel(): {e}")
+ raise e
+
+
+def set_config():
parser = get_parser()
cli_args = parser.parse_args() # parsing early lets 'help' run immediately
@@ -269,9 +327,13 @@ def main():
utils.set_default_config()
# Update config from CONFIG_FILE.
- if not utils.file_exists(config.CONFIG_FILE) and utils.file_exists(config.LEGACY_CONFIG_FILE): # noqa: E501
- config.set_config_env(config.LEGACY_CONFIG_FILE)
- utils.write_config(config.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)
@@ -294,13 +356,92 @@ def main():
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:
- utils.get_dialog()
+ system.get_dialog()
else:
config.DIALOG = config.DIALOG.lower()
- if config.DIALOG == 'tk':
- config.GUI = True
+
+ 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
+
+ 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():
+ # 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 utils.app_is_installed():
+ # wine.set_logos_paths()
+ config.ACTION() # run control_panel right away
+ return
+
+ # Only control_panel ACTION uses TUI/GUI interface; all others are CLI.
+ config.DIALOG = 'cli'
+
+ install_required = [
+ 'backup',
+ 'create_shortcuts',
+ 'install_d3d_compiler',
+ 'install_fonts',
+ 'install_icu',
+ 'remove_index_files',
+ 'remove_library_catalog',
+ 'restore',
+ 'run_indexing',
+ 'run_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
+ # wine.set_logos_paths()
+ # Run the desired Logos action.
+ logging.info(f"Running function for {config.FLPRODUCT}: {config.ACTION.__name__}") # noqa: E501
+ config.ACTION()
+ else: # install_required, but app not installed
+ msg.logos_error("App not installed…")
+
+
+def main():
+ set_config()
+ set_dialog()
# Log persistent config.
utils.log_current_persistent_config()
@@ -311,7 +452,6 @@ def main():
if config.DELETE_LOG and os.path.isfile(config.LOGOS_LOG):
control.delete_log_file_contents()
- # Run desired action (requested function, defaulting to installer)
# Run safety checks.
# FIXME: Fix utils.die_if_running() for GUI; as it is, it breaks GUI
# self-update when updating LLI as it asks for a confirmation in the CLI.
@@ -324,32 +464,32 @@ def main():
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}")
- utils.check_for_updates()
+ check_incompatibilities()
- # Check if app is installed.
- install_required = [
- 'backup',
- 'create_shortcuts',
- 'remove_all_index_files',
- 'remove_library_catalog',
- 'restore',
- 'run_indexing',
- 'run_logos',
- 'switch_logging',
- ]
- if config.ACTION == "disabled":
- msg.logos_error("That option is disabled.", "info")
- elif config.ACTION.__name__ not in install_required:
- logging.info(f"Running function: {config.ACTION.__name__}")
- config.ACTION()
- elif utils.app_is_installed():
- # Run the desired Logos action.
- logging.info(f"Running function: {config.ACTION.__name__}")
- config.ACTION() # defaults to run_control_panel()
+ 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.info("Starting Control Panel")
- run_control_panel()
+ logging.debug("No extra processes found.")
+ logging.debug(f"Closing {config.name_app} finished.")
if __name__ == '__main__':
- main()
+ try:
+ main()
+ except KeyboardInterrupt:
+ close()
+
+ close()
diff --git a/ou_dedetai/msg.py b/ou_dedetai/msg.py
new file mode 100644
index 00000000..dd04458a
--- /dev/null
+++ b/ou_dedetai/msg.py
@@ -0,0 +1,346 @@
+import gzip
+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
+
+
+class GzippedRotatingFileHandler(RotatingFileHandler):
+ def doRollover(self):
+ super().doRollover()
+
+ if self.backupCount > 0:
+ for i in range(self.backupCount - 1, 0, -1):
+ source = f"{self.baseFilename}.{i}.gz"
+ destination = f"{self.baseFilename}.{i + 1}.gz"
+ if os.path.exists(source):
+ if os.path.exists(destination):
+ os.remove(destination)
+ os.rename(source, destination)
+
+ last_log = self.baseFilename + ".1"
+ gz_last_log = self.baseFilename + ".1.gz"
+
+ if os.path.exists(last_log) and os.path.getsize(last_log) > 0:
+ with open(last_log, 'rb') as f_in:
+ with gzip.open(gz_last_log, 'wb') as f_out:
+ shutil.copyfileobj(f_in, f_out)
+ os.remove(last_log)
+
+
+class DeduplicateFilter(logging.Filter):
+ def __init__(self):
+ super().__init__()
+ self.last_log = None
+
+ def filter(self, record):
+ current_message = record.getMessage()
+ if current_message == self.last_log:
+ return False
+ self.last_log = current_message
+ return True
+
+
+def get_log_level_name(level):
+ name = None
+ for k, v in config.LOG_LEVELS.items():
+ if level == v:
+ name = k
+ break
+ return name
+
+
+def initialize_logging(stderr_log_level):
+ '''
+ Log levels:
+ Level Value Description
+ CRITICAL 50 the program can't continue
+ ERROR 40 the program has not been able to do something
+ WARNING 30 something unexpected happened (maybe neg. effect)
+ INFO 20 confirmation that things are working as expected
+ DEBUG 10 detailed, dev-level information
+ NOTSET 0 all events are handled
+ '''
+
+ # Ensure log file parent folders exist.
+ log_parent = Path(config.LOGOS_LOG).parent
+ if not log_parent.is_dir():
+ log_parent.mkdir(parents=True)
+
+ # Define logging handlers.
+ file_h = GzippedRotatingFileHandler(
+ config.LOGOS_LOG,
+ maxBytes=10*1024*1024,
+ backupCount=5,
+ encoding='UTF8'
+ )
+ file_h.name = "logfile"
+ file_h.setLevel(logging.DEBUG)
+ file_h.addFilter(DeduplicateFilter())
+ # 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.addFilter(DeduplicateFilter())
+ handlers = [
+ file_h,
+ # stdout_h,
+ stderr_h,
+ ]
+
+ # Set initial config.
+ logging.basicConfig(
+ level=logging.DEBUG,
+ format='%(asctime)s %(levelname)s: %(message)s',
+ datefmt='%Y-%m-%d %H:%M:%S',
+ handlers=handlers,
+ )
+
+
+def initialize_tui_logging():
+ current_logger = logging.getLogger()
+ for h in current_logger.handlers:
+ if h.name == 'terminal':
+ current_logger.removeHandler(h)
+ break
+
+
+def update_log_level(new_level):
+ # Update logging level from config.
+ for h in logging.getLogger().handlers:
+ if isinstance(h, logging.StreamHandler):
+ h.setLevel(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)
diff --git a/ou_dedetai/network.py b/ou_dedetai/network.py
new file mode 100644
index 00000000..1b5cd068
--- /dev/null
+++ b/ou_dedetai/network.py
@@ -0,0 +1,597 @@
+import hashlib
+import json
+import logging
+import os
+import queue
+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
+
+from . import config
+from . import msg
+from . import utils
+
+
+class Props():
+ def __init__(self, uri=None):
+ self.path = None
+ self.size = None
+ self.md5 = None
+ if uri is not None:
+ self.path = uri
+
+
+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):
+ if self.path is None:
+ return
+ self.size = self.path.stat().st_size
+ return self.size
+
+ def get_md5(self):
+ if self.path is None:
+ return
+ 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
+
+
+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
+ 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
+ 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
+ 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
+ if self.headers.get('server') == 'AmazonS3':
+ content_md5 = self.headers.get('etag')
+ if content_md5 is not None:
+ # Convert from hex to base64
+ content_md5_hex = content_md5.strip('"').strip("'")
+ content_md5 = b64encode(bytes.fromhex(content_md5_hex)).decode() # noqa: E501
+ else:
+ content_md5 = self.headers.get('Content-MD5')
+ if content_md5 is not None:
+ content_md5 = content_md5.strip('"').strip("'")
+ logging.debug(f"{content_md5=}")
+ if content_md5 is not None:
+ self.md5 = content_md5
+ return self.md5
+
+
+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')
+
+
+def logos_reuse_download(
+ sourceurl,
+ file,
+ targetdir,
+ app=None,
+):
+ dirs = [
+ config.INSTALLDIR,
+ os.getcwd(),
+ config.MYDOWNLOADS,
+ ]
+ found = 1
+ for i in dirs:
+ if i is not None:
+ logging.debug(f"Checking {i} for {file}.")
+ file_path = Path(i) / file
+ if os.path.isfile(file_path):
+ logging.info(f"{file} exists in {i}. Verifying properties.")
+ if verify_downloaded_file(
+ sourceurl,
+ file_path,
+ app=app,
+ ):
+ logging.info(f"{file} properties match. Using it…")
+ msg.status(f"Copying {file} into {targetdir}")
+ try:
+ shutil.copy(os.path.join(i, file), targetdir)
+ except shutil.SameFileError:
+ pass
+ found = 0
+ break
+ 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(
+ sourceurl,
+ file_path,
+ app=app,
+ ):
+ msg.status(f"Copying: {file} into: {targetdir}")
+ try:
+ shutil.copy(os.path.join(config.MYDOWNLOADS, file), targetdir)
+ except shutil.SameFileError:
+ pass
+ else:
+ msg.logos_error(f"Bad file size or checksum: {file_path}")
+
+
+def net_get(url, target=None, app=None, evt=None, q=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('<>')
+ 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
+
+ # Initialize variables.
+ local_size = 0
+ total_size = url.size # None or int
+ logging.debug(f"File size on server: {total_size}")
+ percent = None
+ chunk_size = 100 * 1024 # 100 KB default
+ if type(total_size) is int:
+ # Use smaller of 2% of filesize or 2 MB for chunk_size.
+ chunk_size = min([int(total_size / 50), 2 * 1024 * 1024])
+ # Force non-compressed file transfer for accurate progress tracking.
+ headers = {'Accept-Encoding': 'identity'}
+ 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()
+ logging.info(f"Current downloaded size in bytes: {local_size}")
+ if url.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:
+ headers['Range'] = f'bytes={local_size}-{total_size}'
+ else:
+ headers['Range'] = f'bytes={local_size}-'
+
+ logging.debug(f"{chunk_size=}; {file_mode=}; {headers=}")
+
+ # Log download type.
+ if 'Range' in headers.keys():
+ message = f"Continuing download for {url.path}."
+ else:
+ message = f"Starting new download for {url.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:
+ if callable(r):
+ logging.error("Failed to retrieve data from the URL.")
+ return None
+
+ try:
+ r.raise_for_status()
+ except requests.exceptions.HTTPError as e:
+ if domain == "github.com":
+ if (
+ e.response.status_code == 403
+ or e.response.status_code == 429
+ ):
+ logging.error("GitHub API rate limit exceeded. Please wait before trying again.") # noqa: E501
+ else:
+ logging.error(f"HTTP error occurred: {e.response.status_code}") # noqa: E501
+ return 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:
+ if file_mode == 'wb':
+ mode_text = 'Writing'
+ else:
+ mode_text = 'Appending'
+ logging.debug(f"{mode_text} data to file {target.path}.")
+ for chunk in r.iter_content(chunk_size=chunk_size):
+ f.write(chunk)
+ local_size = target.get_size()
+ if type(total_size) is int:
+ percent = round(local_size / total_size * 100)
+ # 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)
+ 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}")
+ 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_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
+ 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
+ # 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":
+ 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]:
+ 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
+
+ # Parse XML
+ root = ET.fromstring(response_xml_bytes.decode('utf-8-sig'))
+
+ # Define namespaces
+ namespaces = {
+ 'ns0': 'http://www.w3.org/2005/Atom',
+ 'ns1': 'http://services.logos.com/update/v1/'
+ }
+
+ # Extract versions
+ releases = []
+ # Obtain all listed releases.
+ for entry in root.findall('.//ns1:version', namespaces):
+ release = entry.text
+ releases.append(release)
+ # if len(releases) == 5:
+ # break
+
+ # Disabled filtering: with Logos 30+, all versions are known to be working.
+ # Keeping code if it needs to be reactivated.
+ # filtered_releases = utils.filter_versions(releases, 36, 1)
+ # logging.debug(f"Available releases: {', '.join(releases)}")
+ # 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):
+ 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"
+ logging.debug(
+ f"Updating {config.name_app} 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
+ 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=app,
+ )
+ shutil.copy(lli_download_path, temp_path)
+ try:
+ shutil.move(temp_path, lli_file_path)
+ except Exception as e:
+ logging.error(f"Failed to replace the binary: {e}")
+ return
+
+ os.chmod(sys.argv[0], os.stat(sys.argv[0]).st_mode | 0o111)
+ logging.debug(f"Successfully updated {config.name_app}.")
+ utils.restart_lli()
diff --git a/ou_dedetai/system.py b/ou_dedetai/system.py
new file mode 100644
index 00000000..330187a6
--- /dev/null
+++ b/ou_dedetai/system.py
@@ -0,0 +1,890 @@
+from typing import Optional
+import distro
+import logging
+import os
+import psutil
+import platform
+import shutil
+import struct
+import subprocess
+import sys
+import time
+import zipfile
+from pathlib import Path
+
+
+from . import config
+from . import msg
+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
+ check = kwargs.get("check", True)
+ text = kwargs.get("text", True)
+ capture_output = kwargs.get("capture_output", True)
+ shell = kwargs.get("shell", False)
+ env = kwargs.get("env", None)
+ cwd = kwargs.get("cwd", None)
+ encoding = kwargs.get("encoding", None)
+ cmdinput = kwargs.get("input", None)
+ stdin = kwargs.get("stdin", None)
+ stdout = kwargs.get("stdout", None)
+ stderr = kwargs.get("stderr", None)
+ timeout = kwargs.get("timeout", None)
+ bufsize = kwargs.get("bufsize", -1)
+ executable = kwargs.get("executable", None)
+ pass_fds = kwargs.get("pass_fds", ())
+ errors = kwargs.get("errors", None)
+ preexec_fn = kwargs.get("preexec_fn", None)
+ close_fds = kwargs.get("close_fds", True)
+ universal_newlines = kwargs.get("universal_newlines", None)
+ startupinfo = kwargs.get("startupinfo", None)
+ creationflags = kwargs.get("creationflags", 0)
+ restore_signals = kwargs.get("restore_signals", True)
+ start_new_session = kwargs.get("start_new_session", False)
+ user = kwargs.get("user", None)
+ group = kwargs.get("group", None)
+ extra_groups = kwargs.get("extra_groups", None)
+ umask = kwargs.get("umask", -1)
+ pipesize = kwargs.get("pipesize", -1)
+ process_group = kwargs.get("process_group", None)
+
+ if retries < 1:
+ retries = 1
+
+ if isinstance(command, str) and not shell:
+ command = command.split()
+
+ for attempt in range(retries):
+ try:
+ result = subprocess.run(
+ command,
+ check=check,
+ text=text,
+ shell=shell,
+ capture_output=capture_output,
+ input=cmdinput,
+ stdin=stdin,
+ stdout=stdout,
+ stderr=stderr,
+ encoding=encoding,
+ cwd=cwd,
+ env=env,
+ timeout=timeout,
+ bufsize=bufsize,
+ executable=executable,
+ errors=errors,
+ pass_fds=pass_fds,
+ preexec_fn=preexec_fn,
+ close_fds=close_fds,
+ universal_newlines=universal_newlines,
+ startupinfo=startupinfo,
+ creationflags=creationflags,
+ restore_signals=restore_signals,
+ start_new_session=start_new_session,
+ user=user,
+ group=group,
+ extra_groups=extra_groups,
+ umask=umask,
+ pipesize=pipesize,
+ process_group=process_group
+ )
+ return result
+ except subprocess.CalledProcessError as e:
+ logging.error(f"Error occurred in run_command() while executing \"{command}\": {e}") # noqa: E501
+ if "lock" in str(e):
+ logging.debug(f"Database appears to be locked. Retrying in {delay} seconds…") # noqa: E501
+ time.sleep(delay)
+ else:
+ raise e
+ except Exception as e:
+ logging.error(f"An unexpected error occurred when running {command}: {e}") # noqa: E501
+ return None
+
+ logging.error(f"Failed to execute after {retries} attempts: '{command}'")
+ return None
+
+
+def popen_command(command, retries=1, delay=0, **kwargs):
+ shell = kwargs.get("shell", False)
+ env = kwargs.get("env", None)
+ cwd = kwargs.get("cwd", None)
+ stdin = kwargs.get("stdin", None)
+ stdout = kwargs.get("stdout", None)
+ stderr = kwargs.get("stderr", None)
+ bufsize = kwargs.get("bufsize", -1)
+ executable = kwargs.get("executable", None)
+ pass_fds = kwargs.get("pass_fds", ())
+ preexec_fn = kwargs.get("preexec_fn", None)
+ close_fds = kwargs.get("close_fds", True)
+ universal_newlines = kwargs.get("universal_newlines", None)
+ startupinfo = kwargs.get("startupinfo", None)
+ creationflags = kwargs.get("creationflags", 0)
+ restore_signals = kwargs.get("restore_signals", True)
+ start_new_session = kwargs.get("start_new_session", False)
+ user = kwargs.get("user", None)
+ group = kwargs.get("group", None)
+ extra_groups = kwargs.get("extra_groups", None)
+ umask = kwargs.get("umask", -1)
+ pipesize = kwargs.get("pipesize", -1)
+ 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
+
+ if isinstance(command, str) and not shell:
+ command = command.split()
+
+ for attempt in range(retries):
+ try:
+ process = subprocess.Popen(
+ command,
+ shell=shell,
+ env=env,
+ cwd=cwd,
+ stdin=stdin,
+ stdout=stdout,
+ stderr=stderr,
+ bufsize=bufsize,
+ executable=executable,
+ pass_fds=pass_fds,
+ preexec_fn=preexec_fn,
+ close_fds=close_fds,
+ universal_newlines=universal_newlines,
+ startupinfo=startupinfo,
+ creationflags=creationflags,
+ restore_signals=restore_signals,
+ start_new_session=start_new_session,
+ user=user,
+ group=group,
+ extra_groups=extra_groups,
+ umask=umask,
+ pipesize=pipesize,
+ process_group=process_group,
+ encoding=encoding,
+ errors=errors,
+ text=text
+ )
+ return process
+
+ except subprocess.CalledProcessError as e:
+ logging.error(f"Error occurred in popen_command() while executing \"{command}\": {e}") # noqa: E501
+ if "lock" in str(e):
+ logging.debug(f"Database appears to be locked. Retrying in {delay} seconds…") # noqa: E501
+ time.sleep(delay)
+ else:
+ raise e
+ except Exception as e:
+ logging.error(f"An unexpected error occurred when running {command}: {e}") # noqa: E501
+ return None
+
+ logging.error(f"Failed to execute after {retries} attempts: '{command}'")
+ return None
+
+
+def get_pids(query):
+ results = []
+ for process in psutil.process_iter(['pid', 'name', 'cmdline']):
+ try:
+ if process.info['cmdline'] is not None and query in process.info['cmdline']: # noqa: E501
+ results.append(process)
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): # noqa: E501
+ pass
+ 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():
+ logging.info("Rebooting system.")
+ command = f"{config.SUPERUSER_COMMAND} reboot now"
+ subprocess.run(
+ command,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ shell=True,
+ text=True
+ )
+ sys.exit(0)
+
+
+def get_dialog():
+ if not os.environ.get('DISPLAY'):
+ msg.logos_error("The installer does not work unless you are running a display") # noqa: E501
+
+ dialog = os.getenv('DIALOG')
+ # Set config.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'
+ else:
+ config.DIALOG = 'tk'
+
+
+def get_architecture():
+ machine = platform.machine().lower()
+ bits = struct.calcsize("P") * 8
+
+ if "x86_64" in machine or "amd64" in machine:
+ architecture = "x86_64"
+ elif "i386" in machine or "i686" in machine:
+ architecture = "x86_32"
+ elif "arm" in machine or "aarch64" in machine:
+ if bits == 64:
+ architecture = "ARM64"
+ else:
+ architecture = "ARM32"
+ elif "riscv" in machine or "riscv64" in machine:
+ if bits == 64:
+ architecture = "RISC-V 64"
+ else:
+ architecture = "RISC-V 32"
+ else:
+ architecture = "Unknown"
+
+ return architecture, bits
+
+
+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
+ # 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:
+ # if config.ELFPACKAGES is not None:
+ # utils.install_packages(config.ELFPACKAGES)
+ # else:
+ # logging.critical(f"ELFPACKAGES is not set.")
+ # sys.exit(1)
+ # else:
+ # logging.critical(f"ELF interpreter is not needed.")
+
+
+def check_architecture():
+ if "x86_64" in config.architecture:
+ pass
+ elif "ARM64" in config.architecture:
+ logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.")
+ 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.")
+ install_elf_interpreter()
+ elif "x86_32" in config.architecture:
+ logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.")
+ install_elf_interpreter()
+ elif "ARM32" in config.architecture:
+ logging.critical("Unsupported architecture. Requires box64 or FEX-EMU or Wine Hangover to be integrated.")
+ 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.")
+ install_elf_interpreter()
+ else:
+ logging.critical("System archictecture unknown.")
+
+
+def get_os():
+ # 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
+
+
+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
+ 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=}")
+
+
+def get_package_manager():
+ major_ver = distro.major_version()
+ logging.debug(f"{config.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.
+ 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 '
+ # Set default package list.
+ config.PACKAGES = (
+ "libfuse2 " # appimages
+ "binutils wget winbind " # wine
+ "p7zip-full " # winetricks
+ )
+ # NOTE: Package names changed together for Ubuntu 24+, Debian 13+, and
+ # derivatives. This does not include an exhaustive list of distros that
+ # use 'apt', so others will have to be added as users report issues.
+ # Ref:
+ # - https://askubuntu.com/a/445496
+ # - https://en.wikipedia.org/wiki/Linux_Mint
+ # - 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')
+ ):
+ config.PACKAGES = (
+ "libfuse3-3 " # appimages
+ "binutils wget winbind " # wine
+ "7zip " # winetricks
+ )
+ config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages
+ config.ELFPACKAGES = ""
+ config.BADPACKAGES = "" # 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"]
+ # 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"]
+ # 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 = (
+ "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 = (
+ "fuse2 " # appimages
+ "samba wget " # wine
+ "7zip " # winetricks
+ "curl gawk grep " # other
+ )
+ config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages
+ config.BADPACKAGES = "" # 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 = (
+ "bash bash-completion" # bash support
+ "gcompat" # musl to glibc
+ "fuse-common fuse" # appimages
+ "wget curl" # network
+ "7zip" # winetricks
+ "samba sed grep gawk bash bash-completion" # other
+ )
+ config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages
+ config.BADPACKAGES = "" # 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 = (
+ "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
+ 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
+ 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 = (
+ "fuse2 " # appimages
+ "binutils libwbclient samba wget " # wine
+ "p7zip " # winetricks
+ "openjpeg2 libxcomposite libxinerama " # display
+ "ocl-icd vulkan-icd-loader " # hardware
+ "alsa-plugins gst-plugins-base-libs libpulse openal " # audio
+ "libva mpg123 v4l-utils " # video
+ "libxslt sqlite " # misc
+ )
+ config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages
+ config.ELFPACKAGES = ""
+ config.BADPACKAGES = "" # 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
+
+ # 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=}")
+
+
+def get_runmode():
+ if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
+ return 'binary'
+ else:
+ return 'script'
+
+
+def query_packages(packages, mode="install", app=None):
+ result = ""
+ missing_packages = []
+ conflicting_packages = []
+ command = config.PACKAGE_MANAGER_COMMAND_QUERY
+
+ try:
+ result = run_command(command)
+ except Exception as e:
+ logging.error(f"Error occurred while executing command: {e}")
+ logging.error(e.output)
+ package_list = result.stdout
+
+ logging.debug(f"packages to check: {packages}")
+ status = {package: "Unchecked" for package in packages}
+
+ for p in packages:
+ logging.debug(f"Checking for: {p}")
+ l_num = 0
+ for line in package_list.split('\n'):
+ # logging.debug(f"{line=}")
+ l_num += 1
+ if config.PACKAGE_MANAGER_COMMAND_QUERY[0] == 'dpkg':
+ parts = line.strip().split()
+ if l_num < 6 or len(parts) < 2: # skip header, etc.
+ continue
+ state = parts[0]
+ pkg = parts[1].split(':')[0] # remove :arch if present
+ if pkg == p and state[1] == 'i':
+ if mode == 'install':
+ status[p] = "Installed"
+ elif mode == 'remove':
+ conflicting_packages.append(p)
+ status[p] = 'Conflicting'
+ break
+ else:
+ if line.strip().startswith(f"{config.QUERY_PREFIX}{p}") and mode == "install": # noqa: E501
+ logging.debug(f"'{p}' installed: {line}")
+ status[p] = "Installed"
+ break
+ elif line.strip().startswith(p) and mode == "remove":
+ conflicting_packages.append(p)
+ status[p] = "Conflicting"
+ break
+
+ if status[p] == "Unchecked":
+ if mode == "install":
+ missing_packages.append(p)
+ status[p] = "Missing"
+ elif mode == "remove":
+ status[p] = "Not Installed"
+ logging.debug(f"{p} status: {status.get(p)}")
+
+ logging.debug(f"Packages status: {status}")
+
+ if mode == "install":
+ if missing_packages:
+ txt = f"Missing packages: {' '.join(missing_packages)}"
+ logging.info(f"{txt}")
+ return missing_packages
+ elif mode == "remove":
+ if conflicting_packages:
+ txt = f"Conflicting packages: {' '.join(conflicting_packages)}"
+ logging.info(f"Conflicting packages: {txt}")
+ return conflicting_packages
+
+
+def have_dep(cmd):
+ if shutil.which(cmd) is not None:
+ return True
+ else:
+ return False
+
+
+def check_dialog_version():
+ if have_dep("dialog"):
+ try:
+ result = run_command(["dialog", "--version"])
+ if result is None:
+ print("Failed to run the 'dialog' command.") # noqa: E501
+ return None
+ version_info = result.stdout.strip()
+ if version_info.startswith("Version: "):
+ version_info = version_info[len("Version: "):]
+ return version_info
+ except subprocess.CalledProcessError as e:
+ print(f"Error running command: {e.stderr}")
+ except FileNotFoundError:
+ print("The 'dialog' command is not found. Please ensure it is installed and in your PATH.") # noqa: E501
+ return None
+
+
+def test_dialog_version():
+ version: Optional[str] = check_dialog_version()
+
+ def parse_date(version):
+ try:
+ return version.split('-')[1]
+ except IndexError:
+ return ''
+
+ minimum_version = "1.3-20201126-1"
+
+ logging.debug(f"Current dialog version: {version}")
+ if version is not None:
+ minimum_version = parse_date(minimum_version)
+ current_version = parse_date(version)
+ logging.debug(f"Minimum dialog version: {minimum_version}. Installed version: {current_version}.") # noqa: E501
+ return current_version > minimum_version
+ else:
+ return None
+
+
+def remove_appimagelauncher(app=None):
+ pkg = "appimagelauncher"
+ cmd = [config.SUPERUSER_COMMAND, *config.PACKAGE_MANAGER_COMMAND_REMOVE, pkg] # noqa: E501
+ msg.status("Removing AppImageLauncher…", app)
+ try:
+ logging.debug(f"Running command: {cmd}")
+ run_command(cmd)
+ except subprocess.CalledProcessError as e:
+ if e.returncode == 127:
+ logging.error("User cancelled appimagelauncher removal.")
+ 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)
+ logging.info("System reboot is required.")
+ sys.exit()
+
+
+def preinstall_dependencies_steamos():
+ 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",
+ ]
+ return command
+
+
+def postinstall_dependencies_steamos():
+ logging.debug("Updating DNS settings & locales, enabling services & read-only system…") # noqa: E501
+ command = [
+ config.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",
+ ]
+ return command
+
+
+def postinstall_dependencies_alpine():
+ 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",
+ ]
+ return command
+
+
+def preinstall_dependencies(app=None):
+ command = []
+ logging.debug("Performing pre-install dependencies…")
+ if config.OS_NAME == "Steam":
+ command = preinstall_dependencies_steamos()
+ else:
+ logging.debug("No pre-install dependencies required.")
+ return command
+
+
+def postinstall_dependencies(app=None):
+ 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()
+ 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:
+ return
+
+ install_deps_failed = False
+ manual_install_required = False
+ message = None
+ no_message = None
+ secondary = None
+ command = []
+ preinstall_command = []
+ install_command = []
+ remove_command = []
+ postinstall_command = []
+ 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()
+
+ if logos9_packages:
+ package_list.extend(logos9_packages.split())
+
+ 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 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
+
+ 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()
+
+ # 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
+
+ preinstall_command = preinstall_dependencies()
+
+ if missing_packages:
+ install_command = config.PACKAGE_MANAGER_COMMAND_INSTALL + missing_packages # noqa: E501
+ else:
+ 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 = 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
+
+ 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
+ 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")
+ else:
+ if app:
+ if config.DIALOG == "curses":
+ app.installdeps_e.set()
+
+
+def install_winetricks(
+ installdir,
+ app=None,
+ version=config.WINETRICKS_VERSION,
+):
+ msg.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=app,
+ )
+ wtzip = f"{config.MYDOWNLOADS}/{zip_name}"
+ logging.debug(f"Extracting winetricks script into {installdir}…")
+ with zipfile.ZipFile(wtzip) as z:
+ for zi in z.infolist():
+ if zi.is_dir():
+ continue
+ zi.filename = Path(zi.filename).name
+ if zi.filename == 'winetricks':
+ z.extract(zi, path=installdir)
+ break
+ os.chmod(f"{installdir}/winetricks", 0o755)
+ config.WINETRICKSBIN = f"{installdir}/winetricks"
+ logging.debug("Winetricks installed.")
diff --git a/ou_dedetai/tui_app.py b/ou_dedetai/tui_app.py
new file mode 100644
index 00000000..6a118ba4
--- /dev/null
+++ b/ou_dedetai/tui_app.py
@@ -0,0 +1,1140 @@
+import logging
+import os
+import signal
+import threading
+import time
+import curses
+from pathlib import Path
+from queue import Queue
+
+from . import config
+from . import control
+from . import installer
+from . import logos
+from . import msg
+from . import network
+from . import system
+from . import tui_curses
+from . import tui_screen
+from . import utils
+from . import wine
+
+console_message = ""
+
+
+# TODO: Fix hitting cancel in Dialog Screens; currently crashes program.
+class TUI:
+ def __init__(self, stdscr):
+ 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
+ # else:
+ # self.title = f"Welcome to {config.name_app} ({config.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 = ""
+
+ # 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_e = threading.Event()
+ self.progress_q = Queue()
+ self.progress_e = threading.Event()
+ self.todo_q = Queue()
+ self.todo_e = threading.Event()
+ self.screen_q = Queue()
+ self.choice_q = Queue()
+ self.switch_q = 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_e = threading.Event()
+ self.appimage_q = 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()
+
+ # 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.set_window_dimensions()
+
+ 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]))
+ 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.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 = 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)
+ resize_lines = tui_curses.wrap_text(self, "Screen too small.")
+ self.resize_window = curses.newwin(len(resize_lines) + 1, curses.COLS, 0, 0)
+
+ @staticmethod
+ 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_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)
+ curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_BLUE)
+ curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_BLUE)
+ 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 update_windows(self):
+ if isinstance(self.active_screen, tui_screen.CursesScreen):
+ self.main_window.erase()
+ 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()
+
+ def refresh(self):
+ self.main_window.noutrefresh()
+ self.menu_window.noutrefresh()
+ 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()
+
+ 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))
+ self.refresh()
+ except curses.error as e:
+ logging.error(f"Curses error in init_curses: {e}")
+ except Exception as e:
+ self.end_curses()
+ logging.error(f"An error occurred in init_curses(): {e}")
+ raise
+
+ def end_curses(self):
+ try:
+ self.stdscr.keypad(False)
+ curses.nocbreak()
+ curses.echo()
+ except curses.error as e:
+ logging.error(f"Curses error in end_curses: {e}")
+ raise
+ except Exception as e:
+ logging.error(f"An error occurred in end_curses(): {e}")
+ raise
+
+ def end(self, signal, frame):
+ logging.debug("Exiting…")
+ self.llirunning = False
+ curses.endwin()
+
+ 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.menu_screen.set_options(self.set_tui_menu_options(dialog=True))
+ self.switch_q.put(1)
+ self.refresh()
+
+ # ERR: On a sudden resize, the Curses menu is not properly resized,
+ # and we are not currently dynamically passing the menu options based
+ # on the current screen, but rather always passing the tui menu options.
+ # To replicate, open Terminator, run LLI full screen, then his Ctrl+A.
+ # The menu should survive, but the size does not resize to the new screen,
+ # even though the resize signal is sent. See tui_curses, line #251 and
+ # tui_screen, line #98.
+ def resize_curses(self):
+ config.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
+
+ 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":
+ self.choice_q.put("Return to Main Menu")
+ else:
+ if self.active_screen.get_screen_id == 14:
+ self.update_tty_dimensions()
+ if self.window_height > 9:
+ self.switch_q.put(1)
+ elif self.window_width > 34:
+ self.switch_q.put(1)
+
+ def draw_resize_screen(self):
+ self.clear()
+ if self.window_width > 10:
+ margin = config.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)
+ 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)
+ 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.update_windows()
+
+ self.active_screen.display()
+
+ if self.choice_q.qsize() > 0:
+ self.choice_processor(
+ self.menu_window,
+ self.active_screen.get_screen_id(),
+ self.choice_q.get())
+
+ if self.screen_q.qsize() > 0:
+ self.screen_q.get()
+ self.switch_q.put(1)
+
+ if self.switch_q.qsize() > 0:
+ self.switch_q.get()
+ self.switch_screen(config.use_python_dialog)
+
+ if len(self.tui_screens) == 0:
+ self.active_screen = self.menu_screen
+ else:
+ self.active_screen = self.tui_screens[-1]
+
+ if not isinstance(self.active_screen, tui_screen.DialogScreen):
+ run_monitor, last_time = utils.stopwatch(last_time, 2.5)
+ if run_monitor:
+ self.logos.monitor()
+ self.task_processor(self, task="PID")
+
+ 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
+ self.draw_resize_screen()
+ elif self.window_width < 10:
+ config.margin = 0 # Avoid drawing errors on very small screens
+
+ def run(self):
+ try:
+ self.init_curses()
+ self.display()
+ except KeyboardInterrupt:
+ self.end_curses()
+ signal.signal(signal.SIGINT, self.end)
+ finally:
+ 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 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,
+ 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
+ }
+
+ # Capture menu exiting before processing in the rest of the handler
+ if screen_id != 0 and (choice == "Return to Main Menu" or choice == "Exit"):
+ 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.
+ else:
+ action = screen_actions.get(screen_id)
+ if action:
+ action(choice)
+ else:
+ pass
+
+ def reset_screen(self):
+ self.active_screen.running = 0
+ self.active_screen.choice = "Processing"
+
+ def go_to_main_menu(self):
+ self.menu_screen.choice = "Processing"
+ self.choice_q.put("Return to Main Menu")
+
+ def main_menu_select(self, choice):
+ if choice is None or choice == "Exit":
+ msg.logos_warn("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,
+ 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}":
+ self.reset_screen()
+ self.logos.start()
+ self.menu_screen.set_options(self.set_tui_menu_options(dialog=False))
+ self.switch_q.put(1)
+ elif choice == f"Stop {config.FLPRODUCT}":
+ self.reset_screen()
+ self.logos.stop()
+ self.menu_screen.set_options(self.set_tui_menu_options(dialog=False))
+ self.switch_q.put(1)
+ elif choice == "Run Indexing":
+ self.active_screen.running = 0
+ self.active_screen.choice = "Processing"
+ self.logos.index()
+ elif choice == "Remove Library Catalog":
+ self.active_screen.running = 0
+ self.active_screen.choice = "Processing"
+ control.remove_library_catalog()
+ 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")
+ 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")
+ elif choice == "Change Color Scheme":
+ self.change_color_scheme()
+ msg.status("Changing color scheme", self)
+ self.reset_screen()
+ utils.write_config(config.CONFIG_FILE)
+
+ def winetricks_menu_select(self, choice):
+ if choice == "Download or Update Winetricks":
+ self.reset_screen()
+ control.set_winetricks()
+ self.go_to_main_menu()
+ elif choice == "Run Winetricks":
+ self.reset_screen()
+ wine.run_winetricks()
+ self.go_to_main_menu()
+ elif choice == "Install d3dcompiler":
+ self.reset_screen()
+ wine.install_d3d_compiler()
+ elif choice == "Install Fonts":
+ self.reset_screen()
+ wine.install_fonts()
+ 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.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.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.choice_q.put("0")
+
+ def utilities_menu_select(self, choice):
+ if choice == "Remove Library Catalog":
+ self.reset_screen()
+ control.remove_library_catalog()
+ self.go_to_main_menu()
+ elif choice == "Remove All Index Files":
+ self.reset_screen()
+ control.remove_all_index_files()
+ self.go_to_main_menu()
+ elif choice == "Edit Config":
+ self.reset_screen()
+ control.edit_config()
+ self.go_to_main_menu()
+ elif choice == "Change Logos Release Channel":
+ self.reset_screen()
+ utils.change_logos_release_channel()
+ self.update_main_window_contents()
+ self.go_to_main_menu()
+ elif choice == f"Change {config.name_app} Release Channel":
+ self.reset_screen()
+ utils.change_lli_release_channel()
+ network.set_logoslinuxinstaller_latest_release_config()
+ 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)
+ elif choice == "Restore Data":
+ self.reset_screen()
+ self.get_backup_path(mode="restore")
+ utils.start_thread(self.do_backup)
+ elif choice == "Update to Latest AppImage":
+ self.reset_screen()
+ utils.update_to_latest_recommended_appimage()
+ 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
+ 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))
+ elif choice == "Install ICU":
+ self.reset_screen()
+ wine.enforce_icu_data_files()
+ self.go_to_main_menu()
+ elif choice.endswith("Logging"):
+ self.reset_screen()
+ wine.switch_logging()
+ self.go_to_main_menu()
+
+ def custom_appimage_select(self, choice):
+ #FIXME
+ if choice == "Input Custom AppImage":
+ appimage_filename = tui_curses.get_user_input(self, "Enter AppImage filename: ", "")
+ else:
+ appimage_filename = choice
+ config.SELECTED_APPIMAGE_FILENAME = appimage_filename
+ utils.set_appimage_symlink()
+ self.menu_screen.choice = "Processing"
+ self.appimage_q.put(config.SELECTED_APPIMAGE_FILENAME)
+ 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
+
+ def waiting_finish(self, choice):
+ pass
+
+ def waiting_resize(self, choice):
+ pass
+
+ def password_prompt(self, choice):
+ if choice:
+ self.menu_screen.choice = "Processing"
+ 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.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.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.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:
+ self.tui_screens.pop(0)
+ if self.active_screen == self.menu_screen:
+ self.menu_screen.choice = "Processing"
+ self.menu_screen.running = 0
+ 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)
+ )
+ 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 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()
+
+ def confirm_restore_dir(self, choice):
+ if choice:
+ if choice == "Yes":
+ self.tmp = "Yes"
+ else:
+ self.tmp = "No"
+ self.todo_e.set()
+
+ def choose_restore_dir(self, choice):
+ if choice:
+ self.tmp = choice
+ self.todo_e.set()
+
+ def do_backup(self):
+ self.todo_e.wait()
+ self.todo_e.clear()
+ 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 which_dialog_options(self, labels, dialog=False):
+ options = []
+ option_number = 1
+ for label in labels:
+ if dialog:
+ options.append((str(option_number), label))
+ option_number += 1
+ else:
+ options.append(label)
+ return options
+
+ def set_tui_menu_options(self, dialog=False):
+ 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:
+ # logging.debug("Logos Linux Installer is up-to-date.")
+ pass
+ elif status == 2:
+ # logging.debug("Logos Linux Installer is newer than the latest release.") # noqa: E501
+ pass
+ else:
+ logging.error(f"{error_message}")
+
+ if utils.app_is_installed():
+ if self.logos.logos_state in [logos.State.STARTING, logos.State.RUNNING]: # noqa: E501
+ run = f"Stop {config.FLPRODUCT}"
+ elif self.logos.logos_state in [logos.State.STOPPING, logos.State.STOPPED]: # noqa: E501
+ run = f"Run {config.FLPRODUCT}"
+
+ 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
+ ]
+ else:
+ labels_default = ["Install Logos Bible Software"]
+ labels.extend(labels_default)
+
+ labels_support = [
+ "Utilities →",
+ "Winetricks →"
+ ]
+ labels.extend(labels_support)
+
+ labels_options = [
+ "Change Color Scheme"
+ ]
+ labels.extend(labels_options)
+
+ labels.append("Exit")
+
+ options = self.which_dialog_options(labels, dialog=False)
+
+ return options
+
+ def set_winetricks_menu_options(self, dialog=False):
+ labels = []
+ labels_support = [
+ "Download or Update Winetricks",
+ "Run Winetricks",
+ "Install d3dcompiler",
+ "Install Fonts",
+ "Set Renderer",
+ "Set Windows Version for Logos",
+ "Set Windows Version for Indexer"
+ ]
+ labels.extend(labels_support)
+
+ labels.append("Return to Main Menu")
+
+ options = self.which_dialog_options(labels, dialog=False)
+
+ return options
+
+ def set_renderer_menu_options(self, dialog=False):
+ labels = []
+ labels_support = [
+ "gdi",
+ "gl",
+ "vulkan"
+ ]
+ labels.extend(labels_support)
+
+ labels.append("Return to Main Menu")
+
+ options = self.which_dialog_options(labels, dialog=False)
+
+ return options
+
+ def set_win_ver_menu_options(self, dialog=False):
+ labels = []
+ 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)
+
+ return options
+
+ def set_utilities_menu_options(self, dialog=False):
+ labels = []
+ if utils.file_exists(config.LOGOS_EXE):
+ labels_catalog = [
+ "Remove Library Catalog",
+ "Remove All Index Files",
+ "Install ICU"
+ ]
+ labels.extend(labels_catalog)
+
+ labels_utilities = [
+ "Install Dependencies",
+ "Edit Config"
+ ]
+ labels.extend(labels_utilities)
+
+ if utils.file_exists(config.LOGOS_EXE):
+ labels_utils_installed = [
+ "Change Logos Release Channel",
+ f"Change {config.name_app} Release Channel",
+ # "Back Up Data",
+ # "Restore Data"
+ ]
+ labels.extend(labels_utils_installed)
+
+ label = "Enable Logging" if config.LOGS == "DISABLED" else "Disable Logging"
+ labels.append(label)
+
+ labels.append("Return to Main Menu")
+
+ options = self.which_dialog_options(labels, dialog=False)
+
+ 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))
+ 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))
+ 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))
+ 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:
+ 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))
+ 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))
+ 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):
+ 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))
+ else:
+ #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))
+ 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))
+ else:
+ # TODO
+ pass
+
+ 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()
diff --git a/ou_dedetai/tui_curses.py b/ou_dedetai/tui_curses.py
new file mode 100644
index 00000000..26fdf00e
--- /dev/null
+++ b/ou_dedetai/tui_curses.py
@@ -0,0 +1,326 @@
+import curses
+import signal
+import textwrap
+
+from . import config
+from . import msg
+from . import utils
+
+
+def wrap_text(app, text):
+ # 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)
+ else:
+ wrapped_text = textwrap.fill(text, app.window_width - (config.margin * 2))
+ lines = wrapped_text.split('\n')
+ return lines
+
+
+def write_line(app, stdscr, start_y, start_x, text, char_limit, attributes=curses.A_NORMAL):
+ try:
+ stdscr.addnstr(start_y, start_x, text, char_limit, attributes)
+ except curses.error:
+ signal.signal(signal.SIGWINCH, app.signal_resize)
+
+
+def title(app, title_text, title_start_y_adj):
+ stdscr = app.get_main_window()
+ title_lines = wrap_text(app, title_text)
+ 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)
+ last_index = i
+
+ return last_index
+
+
+def text_centered(app, text, start_y=0):
+ stdscr = app.get_menu_window()
+ if "\n" in text:
+ text_lines = wrap_text(app, text).splitlines()
+ else:
+ 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)
+
+ return text_start_y, text_lines
+
+
+def spinner(app, index, start_y=0):
+ spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"]
+ i = index
+ text_centered(app, spinner_chars[i], start_y)
+ i = (i + 1) % len(spinner_chars)
+ return i
+
+
+#FIXME: Display flickers.
+def confirm(app, question_text, height=None, width=None):
+ stdscr = app.get_menu_window()
+ question_text = question_text + " [Y/n]: "
+ question_start_y, question_lines = text_centered(app, question_text)
+
+ y = question_start_y + len(question_lines) + 2
+
+ while True:
+ key = stdscr.getch()
+ key = chr(key)
+
+ if key.lower() == 'y' or key == '\n': # '\n' for Enter key, defaults to "Yes"
+ return True
+ elif key.lower() == 'n':
+ return False
+
+ write_line(app, stdscr, y, 0, "Type Y[es] or N[o]. ", app.window_width, curses.A_BOLD)
+
+
+class CursesDialog:
+ def __init__(self, app):
+ self.app = app
+ self.stdscr = self.app.get_menu_window()
+
+ def __str__(self):
+ return f"Curses Dialog"
+
+ def draw(self):
+ pass
+
+ def input(self):
+ pass
+
+ def run(self):
+ pass
+
+
+class UserInputDialog(CursesDialog):
+ def __init__(self, app, question_text, default_text):
+ 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
+
+ def __str__(self):
+ return f"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.input()
+ curses.curs_set(0)
+ curses.noecho()
+ self.stdscr.refresh()
+
+ 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))
+
+ 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]
+ else:
+ self.user_input += chr(key)
+ except KeyboardInterrupt:
+ signal.signal(signal.SIGINT, self.app.end)
+
+ 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
+
+
+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)
+
+
+class MenuDialog(CursesDialog):
+ def __init__(self, app, question_text, options):
+ super().__init__(app)
+ self.user_input = "Processing"
+ self.submit = False
+ self.question_text = question_text
+ self.options = options
+ self.question_start_y = None
+ self.question_lines = None
+
+ def __str__(self):
+ return f"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)
+ # 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
+ if index < len(self.options):
+ option = self.options[index]
+ if type(option) is list:
+ option_lines = []
+ wine_binary_code = option[0]
+ if wine_binary_code != "Exit":
+ 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)
+ option_lines.extend(wine_binary_path_wrapped)
+ wine_binary_desc_wrapped = textwrap.wrap(
+ f"Description: {wine_binary_description}", self.app.window_width - 4)
+ option_lines.extend(wine_binary_desc_wrapped)
+ else:
+ wine_binary_path = option[1]
+ wine_binary_description = option[2]
+ wine_binary_path_wrapped = textwrap.wrap(
+ f"{wine_binary_path}", self.app.window_width - 4)
+ option_lines.extend(wine_binary_path_wrapped)
+ wine_binary_desc_wrapped = textwrap.wrap(
+ f"{wine_binary_description}", self.app.window_width - 4)
+ option_lines.extend(wine_binary_desc_wrapped)
+ else:
+ option_lines = textwrap.wrap(option, self.app.window_width - 4)
+
+ for j, line in enumerate(option_lines):
+ 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)
+ else:
+ write_line(self.app, self.stdscr, y, x, line, self.app.window_width)
+ 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)
+
+ def do_menu_up(self):
+ if config.current_option == config.current_page * config.options_per_page and config.current_page > 0:
+ # 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
+ else:
+ config.current_page = config.total_pages - 1
+ config.current_option = len(self.app.menu_options) - 1
+ else:
+ config.current_option = max(0, config.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:
+ # 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
+ else:
+ config.current_option = min(len(self.app.menu_options) - 1, config.current_option + 1)
+
+ def input(self):
+ if len(self.app.tui_screens) > 0:
+ self.stdscr = self.app.tui_screens[-1].get_stdscr()
+ else:
+ self.stdscr = self.app.menu_screen.get_stdscr()
+ key = self.stdscr.getch()
+
+ 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.
+ next_key = self.stdscr.getch()
+ if next_key == 91:
+ final_key = self.stdscr.getch()
+ if final_key == 65:
+ self.do_menu_up()
+ 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]
+ 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)
+
+ self.stdscr.noutrefresh()
+
+ def run(self):
+ #thread = utils.start_thread(self.input, daemon_bool=False)
+ #thread.join()
+ self.draw()
+ self.input()
+ return self.user_input
+
+ def set_options(self, new_options):
+ self.options = new_options
+ self.app.menu_options = new_options
diff --git a/ou_dedetai/tui_dialog.py b/ou_dedetai/tui_dialog.py
new file mode 100644
index 00000000..44a838dc
--- /dev/null
+++ b/ou_dedetai/tui_dialog.py
@@ -0,0 +1,211 @@
+import curses
+import logging
+try:
+ from dialog import Dialog
+except ImportError:
+ pass
+
+
+
+def text(screen, text, height=None, width=None, title=None, backtitle=None, colors=True):
+ dialog = Dialog()
+ dialog.autowidgetsize = True
+ options = {'colors': colors}
+ if height is not None:
+ options['height'] = height
+ if width is not None:
+ options['width'] = width
+ if title is not None:
+ options['title'] = title
+ if backtitle is not None:
+ options['backtitle'] = backtitle
+ dialog.infobox(text, **options)
+
+
+def progress_bar(screen, text, percent, height=None, width=None, title=None, backtitle=None, colors=True):
+ screen.dialog = Dialog()
+ screen.dialog.autowidgetsize = True
+ options = {'colors': colors}
+ if height is not None:
+ options['height'] = height
+ if width is not None:
+ options['width'] = width
+ if title is not None:
+ options['title'] = title
+ if backtitle is not None:
+ options['backtitle'] = backtitle
+
+ screen.dialog.gauge_start(text=text, percent=percent, **options)
+
+
+#FIXME: Not working. See tui_screen.py#262.
+def update_progress_bar(screen, percent, text='', update_text=False):
+ screen.dialog.autowidgetsize = True
+ screen.dialog.gauge_update(percent, text, update_text)
+
+
+def stop_progress_bar(screen):
+ screen.dialog.autowidgetsize = True
+ screen.dialog.gauge_stop()
+
+
+def tasklist_progress_bar(screen, text, percent, elements, height=None, width=None, title=None, backtitle=None, colors=None):
+ dialog = Dialog()
+ dialog.autowidgetsize = True
+ options = {'colors': colors}
+ if height is not None:
+ options['height'] = height
+ if width is not None:
+ options['width'] = width
+ if title is not None:
+ options['title'] = title
+ if backtitle is not None:
+ options['backtitle'] = backtitle
+
+ if elements is None:
+ elements = {}
+
+ elements_list = [(k, v) for k, v in elements.items()]
+ try:
+ dialog.mixedgauge(text=text, percent=percent, elements=elements_list, **options)
+ except Exception as e:
+ logging.debug(f"Error in mixedgauge: {e}")
+ raise
+
+
+def input(screen, question_text, height=None, width=None, init="", title=None, backtitle=None, colors=True):
+ dialog = Dialog()
+ dialog.autowidgetsize = True
+ options = {'colors': colors}
+ if height is not None:
+ options['height'] = height
+ if width is not None:
+ options['width'] = width
+ if title is not None:
+ options['title'] = title
+ if backtitle is not None:
+ options['backtitle'] = backtitle
+ code, input = dialog.inputbox(question_text, init=init, **options)
+ return code, input
+
+
+def password(screen, question_text, height=None, width=None, init="", title=None, backtitle=None, colors=True):
+ dialog = Dialog()
+ dialog.autowidgetsize = True
+ options = {'colors': colors}
+ if height is not None:
+ options['height'] = height
+ if width is not None:
+ options['width'] = width
+ if title is not None:
+ options['title'] = title
+ if backtitle is not None:
+ options['backtitle'] = backtitle
+ code, password = dialog.passwordbox(question_text, init=init, insecure=True, **options)
+ return code, password
+
+
+def confirm(screen, question_text, yes_label="Yes", no_label="No",
+ height=None, width=None, title=None, backtitle=None, colors=True):
+ dialog = Dialog()
+ dialog.autowidgetsize = True
+ options = {'colors': colors}
+ if height is not None:
+ options['height'] = height
+ if width is not None:
+ options['width'] = width
+ if title is not None:
+ 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)
+ return check # Returns "ok" or "cancel"
+
+
+def directory_picker(screen, path_dir, height=None, width=None, title=None, backtitle=None, colors=True):
+ str_dir = str(path_dir)
+
+ try:
+ dialog = Dialog()
+ dialog.autowidgetsize = True
+ options = {'colors': colors}
+ if height is not None:
+ options['height'] = height
+ if width is not None:
+ options['width'] = width
+ if title is not None:
+ options['title'] = title
+ if backtitle is not None:
+ options['backtitle'] = backtitle
+ curses.curs_set(1)
+ _, path = dialog.dselect(str_dir, **options)
+ curses.curs_set(0)
+ except Exception as e:
+ logging.error("An error occurred:", e)
+ curses.endwin()
+
+ return path
+
+
+def menu(screen, question_text, choices, height=None, width=None, menu_height=8, title=None, backtitle=None, colors=True):
+ tag_to_description = {tag: description for tag, description in choices}
+ dialog = Dialog(dialog="dialog")
+ dialog.autowidgetsize = True
+ options = {'colors': colors}
+ if title is not None:
+ options['title'] = title
+ if backtitle is not None:
+ 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)
+ selected_description = tag_to_description.get(tag)
+
+ if code == dialog.OK:
+ return code, tag, selected_description
+ elif code == dialog.CANCEL:
+ 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):
+ # items is an interable of (tag, item, status)
+ dialog = Dialog(dialog="dialog")
+ dialog.autowidgetsize = True
+ options = {'colors': colors}
+ if height is not None:
+ options['height'] = height
+ if width is not None:
+ options['width'] = width
+ if title is not None:
+ options['title'] = title
+ if backtitle is not None:
+ options['backtitle'] = backtitle
+
+ code, tags = dialog.buildlist(text, list_height=list_height, items=items, **options)
+
+ if code == dialog.OK:
+ return code, tags
+ elif code == dialog.CANCEL:
+ return None
+
+
+def checklist(screen, text, items=[], height=None, width=None, list_height=None, title=None, backtitle=None, colors=True):
+ # items is an iterable of (tag, item, status)
+ dialog = Dialog(dialog="dialog")
+ dialog.autowidgetsize = True
+ options = {'colors': colors}
+ if height is not None:
+ options['height'] = height
+ if width is not None:
+ options['width'] = width
+ if title is not None:
+ options['title'] = title
+ if backtitle is not None:
+ options['backtitle'] = backtitle
+
+ code, tags = dialog.checklist(text, choices=items, list_height=list_height, **options)
+
+ if code == dialog.OK:
+ return code, tags
+ elif code == dialog.Cancel:
+ return None
diff --git a/ou_dedetai/tui_screen.py b/ou_dedetai/tui_screen.py
new file mode 100644
index 00000000..b9b3b1a7
--- /dev/null
+++ b/ou_dedetai/tui_screen.py
@@ -0,0 +1,486 @@
+import curses
+import logging
+import time
+from pathlib import Path
+
+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 = ""
+ self.screen_id = screen_id
+ self.choice = "Processing"
+ self.queue = queue
+ self.event = 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.
+ # 0 = not submitted or not started
+ # 1 = submitted or started
+ # 2 = none or finished
+ self.running = 0
+
+ def __str__(self):
+ return f"Curses Screen"
+
+ def display(self):
+ pass
+
+ def get_stdscr(self):
+ 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()
+
+ def is_set(self):
+ return self.event.is_set()
+
+
+class CursesScreen(Screen):
+ def submit_choice_to_queue(self):
+ if self.running == 0 and self.choice != "Processing":
+ self.app.choice_q.put(self.choice)
+ self.running = 1
+
+
+class DialogScreen(Screen):
+ def submit_choice_to_queue(self):
+ if self.running == 1 and self.choice != "Processing":
+ self.app.choice_q.put(self.choice)
+ self.running = 2
+
+
+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.title = title
+ self.subtitle = subtitle
+ self.title_start_y = title_start_y
+
+ def __str__(self):
+ return f"Curses Console Screen"
+
+ def display(self):
+ 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:]
+ 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))
+
+ 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):
+ super().__init__(app, screen_id, queue, event)
+ self.stdscr = self.app.get_menu_window()
+ self.question = question
+ self.options = options
+ self.height = height
+ self.width = width
+ self.menu_height = menu_height
+
+ def __str__(self):
+ return f"Curses Menu Screen"
+
+ def display(self):
+ 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
+ self.submit_choice_to_queue()
+ self.stdscr.noutrefresh()
+ curses.doupdate()
+
+ def get_question(self):
+ return self.question
+
+ def set_options(self, new_options):
+ self.options = new_options
+ self.app.menu_options = new_options
+
+
+class ConfirmScreen(MenuScreen):
+ def __init__(self, app, screen_id, queue, event, question, no_text, secondary, options=["Yes", "No"]):
+ 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"
+
+ def display(self):
+ 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 == "No":
+ logging.critical(self.no_text)
+ self.submit_choice_to_queue()
+ self.stdscr.noutrefresh()
+ curses.doupdate()
+
+
+class InputScreen(CursesScreen):
+ def __init__(self, app, screen_id, queue, event, question, default):
+ super().__init__(app, screen_id, queue, event)
+ self.stdscr = self.app.get_menu_window()
+ self.question = question
+ self.default = default
+ self.dialog = tui_curses.UserInputDialog(
+ self.app,
+ self.question,
+ self.default
+ )
+
+ def __str__(self):
+ return f"Curses Input Screen"
+
+ def display(self):
+ self.stdscr.erase()
+ self.choice = self.dialog.run()
+ if not self.choice == "Processing":
+ self.submit_choice_to_queue()
+ self.stdscr.noutrefresh()
+ curses.doupdate()
+
+ def get_question(self):
+ return self.question
+
+ def get_default(self):
+ return self.default
+
+
+class PasswordScreen(InputScreen):
+ def __init__(self, app, screen_id, queue, event, question, default):
+ super().__init__(app, screen_id, queue, event, question, default)
+ self.dialog = tui_curses.PasswordDialog(
+ self.app,
+ self.question,
+ self.default
+ )
+
+ def __str__(self):
+ return f"Curses Password Screen"
+
+ def display(self):
+ 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.stdscr.noutrefresh()
+ curses.doupdate()
+
+
+class TextScreen(CursesScreen):
+ def __init__(self, app, screen_id, queue, event, text, wait):
+ super().__init__(app, screen_id, queue, event)
+ self.stdscr = self.app.get_menu_window()
+ self.text = text
+ self.wait = wait
+ self.spinner_index = 0
+
+ def __str__(self):
+ return f"Curses Text Screen"
+
+ def display(self):
+ 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)
+ time.sleep(0.1)
+ self.stdscr.noutrefresh()
+ curses.doupdate()
+
+ def get_text(self):
+ return self.text
+
+
+class MenuDialog(DialogScreen):
+ def __init__(self, app, screen_id, queue, event, question, options, height=None, width=None, menu_height=8):
+ super().__init__(app, screen_id, queue, event)
+ self.stdscr = self.app.get_menu_window()
+ self.question = question
+ self.options = options
+ self.height = height
+ self.width = width
+ self.menu_height = menu_height
+
+ def __str__(self):
+ return f"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.submit_choice_to_queue()
+
+ def get_question(self):
+ return self.question
+
+ def set_options(self, new_options):
+ self.options = new_options
+
+
+class InputDialog(DialogScreen):
+ def __init__(self, app, screen_id, queue, event, question, default):
+ super().__init__(app, screen_id, queue, event)
+ self.stdscr = self.app.get_menu_window()
+ self.question = question
+ self.default = default
+
+ def __str__(self):
+ return f"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)
+ self.submit_choice_to_queue()
+
+ def get_question(self):
+ return self.question
+
+ def get_default(self):
+ return self.default
+
+
+class PasswordDialog(InputDialog):
+ def __init__(self, app, screen_id, queue, event, question, default):
+ super().__init__(app, screen_id, queue, event, question, default)
+
+ def __str__(self):
+ return f"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.submit_choice_to_queue()
+ utils.send_task(self.app, "INSTALLING_PW")
+
+
+class ConfirmDialog(DialogScreen):
+ def __init__(self, app, screen_id, queue, event, question, no_text, secondary, yes_label="Yes", no_label="No"):
+ super().__init__(app, screen_id, queue, event)
+ self.stdscr = self.app.get_menu_window()
+ self.question = question
+ self.no_text = no_text
+ self.secondary = secondary
+ self.yes_label = yes_label
+ self.no_label = no_label
+
+ def __str__(self):
+ return f"PyDialog Confirm Screen"
+
+ def display(self):
+ if self.running == 0:
+ self.running = 1
+ self.choice = tui_dialog.confirm(self.app, self.secondary + self.question,
+ self.yes_label, self.no_label)
+ if self.choice == "cancel":
+ self.choice = self.no_label
+ logging.critical(self.no_text)
+ else:
+ self.choice = self.yes_label
+ self.submit_choice_to_queue()
+
+ def get_question(self):
+ return self.question
+
+
+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):
+ super().__init__(app, screen_id, queue, event)
+ self.stdscr = self.app.get_menu_window()
+ self.text = text
+ self.percent = percent
+ self.wait = wait
+ self.height = height
+ self.width = width
+ self.title = title
+ self.backtitle = backtitle
+ self.colors = colors
+ self.lastpercent = 0
+ self.dialog = ""
+
+ def __str__(self):
+ return f"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)
+ else:
+ self.percent = 0
+
+ tui_dialog.progress_bar(self, self.text, self.percent)
+ self.lastpercent = self.percent
+ else:
+ tui_dialog.text(self, self.text)
+ 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)
+ else:
+ self.percent = 0
+
+ if self.lastpercent != self.percent:
+ self.lastpercent = self.percent
+ tui_dialog.update_progress_bar(self, self.percent, self.text, True)
+ #tui_dialog.progress_bar(self, self.text, self.percent)
+
+ if self.percent == 100:
+ tui_dialog.stop_progress_bar(self)
+ self.running = 2
+ self.wait = False
+
+ def get_text(self):
+ return self.text
+
+
+class TaskListDialog(DialogScreen):
+ def __init__(self, app, screen_id, queue, event, text, elements, percent,
+ 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
+ self.elements = elements if elements is not None else {}
+ self.percent = percent
+ self.height = height
+ self.width = width
+ self.title = title
+ self.backtitle = backtitle
+ self.colors = colors
+ self.updated = False
+
+ def __str__(self):
+ return f"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)
+ 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)
+ else:
+ pass
+
+ time.sleep(0.1)
+
+ def set_text(self, text):
+ self.text = text
+ self.updated = True
+
+ def set_percent(self, percent):
+ self.percent = percent
+ self.updated = True
+
+ 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):
+ super().__init__(app, screen_id, queue, event)
+ self.stdscr = self.app.get_menu_window()
+ self.question = question
+ self.options = options
+ self.height = height
+ self.width = width
+ self.list_height = list_height
+
+ def __str__(self):
+ return f"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)
+ self.running = 2
+
+ def get_question(self):
+ return self.question
+
+ def set_options(self, new_options):
+ self.options = new_options
+
+
+class CheckListDialog(DialogScreen):
+ def __init__(self, app, screen_id, queue, event, question, options, list_height=None, height=None, width=None):
+ super().__init__(app, screen_id, queue, event)
+ self.stdscr = self.app.get_menu_window()
+ self.question = question
+ self.options = options
+ self.height = height
+ self.width = width
+ self.list_height = list_height
+
+ def __str__(self):
+ return f"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)
+ 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
new file mode 100644
index 00000000..e51ac665
--- /dev/null
+++ b/ou_dedetai/utils.py
@@ -0,0 +1,1008 @@
+import atexit
+import glob
+import inspect
+import json
+import logging
+import os
+import psutil
+import re
+import shutil
+import signal
+import stat
+import subprocess
+import sys
+import tarfile
+import threading
+import time
+import tkinter as tk
+from packaging import version
+from pathlib import Path
+from typing import List, Optional, Union
+
+from . import config
+from . import msg
+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:
+ stack = inspect.stack()
+ caller_frame = stack[1]
+ caller_name = caller_frame.function
+ return caller_name
+ else:
+ return "Inspect Not Enabled"
+
+
+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()
+
+
+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 remove_pid_file():
+ if os.path.exists(config.pid_file):
+ os.remove(config.pid_file)
+
+ if os.path.isfile(config.pid_file):
+ with open(config.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:
+ os.kill(int(pid), signal.SIGKILL)
+
+ atexit.register(remove_pid_file)
+ with open(config.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)
+ 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*")
+ logging.info("done")
+
+
+def get_user_downloads_dir():
+ home = Path.home()
+ xdg_config = Path(os.getenv('XDG_CONFIG_HOME', home / '.config'))
+ user_dirs_file = xdg_config / 'user-dirs.dirs'
+ downloads_path = str(home / 'Downloads')
+ if user_dirs_file.is_file():
+ with user_dirs_file.open() as f:
+ for line in f.readlines():
+ if 'DOWNLOAD' in line:
+ downloads_path = line.rstrip().split('=')[1].replace(
+ '$HOME',
+ str(home)
+ ).strip('"')
+ break
+ return downloads_path
+
+
+def delete_symlink(symlink_path):
+ symlink_path = Path(symlink_path)
+ if symlink_path.is_symlink():
+ try:
+ symlink_path.unlink()
+ logging.info(f"Symlink at {symlink_path} removed successfully.")
+ except Exception as e:
+ logging.error(f"Error removing symlink: {e}")
+
+
+def install_dependencies(app=None):
+ if config.TARGETVERSION:
+ targetversion = int(config.TARGETVERSION)
+ else:
+ targetversion = 10
+ msg.status(f"Checking Logos {str(targetversion)} dependencies…", app)
+
+ if targetversion == 10:
+ system.install_dependencies(config.PACKAGES, config.BADPACKAGES, app=app) # noqa: E501
+ elif targetversion == 9:
+ system.install_dependencies(
+ config.PACKAGES,
+ config.BADPACKAGES,
+ config.L9PACKAGES,
+ app=app
+ )
+ else:
+ logging.error(f"TARGETVERSION not found: {config.TARGETVERSION}.")
+
+ if config.DIALOG == "tk":
+ # FIXME: This should get moved to gui_app.
+ app.root.event_generate('<>')
+
+
+def file_exists(file_path):
+ if file_path is not None:
+ expanded_path = os.path.expanduser(file_path)
+ return os.path.isfile(expanded_path)
+ else:
+ 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
+ file_paths = glob.glob(path_regex)
+ logos_version_number = None
+ if file_paths:
+ logos_version_file = file_paths[0]
+ with open(logos_version_file, 'r') as json_file:
+ json_data = json.load(json_file)
+ for key in json_data.get('libraries', dict()):
+ if key.startswith('Logos') and '/' in key:
+ logos_version_number = key.split('/')[1]
+
+ logging.debug(f"{logos_version_number=}")
+ if logos_version_number is not None:
+ return logos_version_number
+ else:
+ logging.debug("Couldn't determine installed 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
+
+
+def check_logos_release_version(version, threshold, check_version_part):
+ if version is not None:
+ version_parts = list(map(int, version.split('.')))
+ return version_parts[check_version_part - 1] < threshold
+ else:
+ 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):
+ # Set binary code, description, and path based on path
+ codes = {
+ "Recommended": "Use the recommended AppImage",
+ "AppImage": "AppImage of Wine64",
+ "System": "Use the system binary (i.e., /usr/bin/wine64). WINE must be 7.18-staging or later, or 8.16-devel or later, and cannot be version 8.0.", # noqa: E501
+ "Proton": "Install using the Steam Proton fork of WINE.",
+ "PlayOnLinux": "Install using a PlayOnLinux WINE64 binary.",
+ "Custom": "Use a WINE64 binary from another directory.",
+ }
+ # TODO: The GUI currently cannot distinguish between the recommended
+ # AppImage and another on the system. We need to add some manner of making
+ # this distinction in the GUI, which is why the wine binary codes exist.
+ # Currently the GUI only accept an array with a single element, the binary
+ # itself; this will need to be modified to a two variable array, at the
+ # least, even if we hide the wine binary code, but it might be useful to
+ # tell the GUI user that a particular AppImage/binary is recommended.
+ # Below is my best guess for how to do this with the single element array…
+ # Does it work?
+ if isinstance(binary, Path):
+ binary = str(binary)
+ if binary == f"{config.APPDIR_BINDIR}/{config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME}": # noqa: E501
+ code = "Recommended"
+ elif binary.lower().endswith('.appimage'):
+ code = "AppImage"
+ elif "/usr/bin/" in binary:
+ code = "System"
+ elif "Proton" in binary:
+ code = "Proton"
+ elif "PlayOnLinux" in binary:
+ code = "PlayOnLinux"
+ else:
+ code = "Custom"
+ desc = codes.get(code)
+ logging.debug(f"{binary} code & desc: {code}; {desc}")
+ return code, desc
+
+
+def get_wine_options(appimages, binaries, app=None) -> Union[List[List[str]], List[str]]: # noqa: E501
+ logging.debug(f"{appimages=}")
+ logging.debug(f"{binaries=}")
+ wine_binary_options = []
+
+ # 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)
+
+ 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
+
+ # 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."])
+
+ 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():
+ local_winetricks_path = shutil.which('winetricks')
+ winetricks_options = ['Download', 'Return to Main Menu']
+ 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:
+ winetricks_options.insert(0, local_winetricks_path)
+ else:
+ logging.info("Local winetricks is too old.")
+ else:
+ logging.info("Local winetricks not found.")
+ return winetricks_options
+
+
+def get_procs_using_file(file_path, mode=None):
+ 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()]
+ if len(paths) > 0 and file_path in paths:
+ procs.add(proc.pid)
+ except psutil.AccessDenied:
+ pass
+ return procs
+
+
+# def get_pids_using_file(file_path, mode=None):
+# pids = set()
+# for proc in psutil.process_iter(['pid', 'open_files']):
+# 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()]
+# if len(paths) > 0 and file_path in paths:
+# pids.add(proc.pid)
+# except psutil.AccessDenied:
+# pass
+# return pids
+
+
+# def wait_process_using_dir(directory):
+# logging.info(f"* Starting wait_process_using_dir for {directory}…")
+
+# # Get pids and wait for them to finish.
+# pids = get_pids_using_file(directory)
+# for pid in pids:
+# logging.info(f"wait_process_using_dir PID: {pid}")
+# psutil.wait(pid)
+
+# 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
+ 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
+
+
+def enough_disk_space(dest_dir, bytes_required):
+ free_bytes = shutil.disk_usage(dest_dir).free
+ logging.debug(f"{free_bytes=}; {bytes_required=}")
+ return free_bytes > bytes_required
+
+
+def get_path_size(file_path):
+ file_path = Path(file_path)
+ if not file_path.exists():
+ path_size = None
+ else:
+ path_size = sum(f.stat().st_size for f in file_path.rglob('*')) + file_path.stat().st_size # noqa: E501
+ return path_size
+
+
+def get_folder_group_size(src_dirs, q):
+ src_size = 0
+ for d in src_dirs:
+ if not d.is_dir():
+ continue
+ src_size += get_path_size(d)
+ 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:
+ logging.warning(f"No folders found in {folder_path}")
+ return None
+ folders.sort()
+ logging.info(f"Found {len(folders)} backup folders.")
+ latest = folders[-1]
+ logging.info(f"Latest folder: {latest}")
+ return latest
+
+
+def install_premade_wine_bottle(srcdir, appdir):
+ msg.status(f"Extracting: '{config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME}' into: {appdir}") # noqa: E501
+ shutil.unpack_archive(
+ f"{srcdir}/{config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME}",
+ appdir
+ )
+
+
+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
+
+ # 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
+
+ status = None
+ message = None
+ if current is not None and latest is not None:
+ if version.parse(current) < version.parse(latest):
+ # Current release is older than recommended.
+ status = 0
+ elif version.parse(current) == version.parse(latest):
+ # Current release is latest.
+ status = 1
+ elif version.parse(current) > version.parse(latest):
+ # 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}"
+ else:
+ status = False
+ message = "config.WINE_EXE is not set."
+
+ logging.debug(f"{status=}; {message=}")
+ return status, message
+
+
+def get_lli_release_version(lli_binary):
+ lli_version = None
+ # Ensure user-executable by adding 0o001.
+ st = lli_binary.stat()
+ os.chmod(lli_binary, mode=st.st_mode | stat.S_IXUSR)
+ # Get version number.
+ cmd = [lli_binary, '--version']
+ vstr = subprocess.check_output(cmd, text=True)
+ m = re.search(r'\d+\.\d+\.\d+(-[a-z]+\.\d+)?', vstr)
+ if m:
+ lli_version = m[0]
+ return lli_version
+
+
+def is_appimage(file_path):
+ # Ref:
+ # - https://cgit.freedesktop.org/xdg/shared-mime-info/commit/?id=c643cab25b8a4ea17e73eae5bc318c840f0e3d4b # noqa: E501
+ # - https://github.com/AppImage/AppImageSpec/blob/master/draft.md#image-format # noqa: E501
+ # Note:
+ # result is a tuple: (is AppImage: True|False, AppImage type: 1|2|None)
+ # result = (False, None)
+ expanded_path = Path(file_path).expanduser().resolve()
+ logging.debug(f"Converting path to expanded_path: {expanded_path}")
+ if file_exists(expanded_path):
+ logging.debug(f"{expanded_path} exists!")
+ with file_path.open('rb') as f:
+ f.seek(1)
+ elf_sig = f.read(3)
+ f.seek(8)
+ ai_sig = f.read(2)
+ f.seek(10)
+ v_sig = f.read(1)
+
+ appimage_check = elf_sig == b'ELF' and ai_sig == b'AI'
+ appimage_type = int.from_bytes(v_sig)
+
+ return appimage_check, appimage_type
+ else:
+ logging.error(f"File does not exist: {expanded_path}")
+ return False, None
+
+
+def check_appimage(filestr):
+ logging.debug(f"Checking if {filestr} is a usable AppImage.")
+ if filestr is None:
+ logging.error("check_appimage: received None for file.")
+ return False
+
+ file_path = Path(filestr)
+
+ appimage, appimage_type = is_appimage(file_path)
+ if appimage:
+ logging.debug("It is an AppImage!")
+ if appimage_type == 1:
+ logging.error(f"{file_path}: Can't handle AppImage version {str(appimage_type)} yet.") # noqa: E501
+ return False
+ else:
+ logging.debug("It is a usable AppImage!")
+ return True
+ else:
+ logging.debug("It is not an AppImage!")
+ return False
+
+
+def find_appimage_files(release_version, app=None):
+ appimages = []
+ directories = [
+ os.path.expanduser("~") + "/bin",
+ config.APPDIR_BINDIR,
+ config.MYDOWNLOADS
+ ]
+ if config.CUSTOMBINPATH is not None:
+ directories.append(config.CUSTOMBINPATH)
+
+ if sys.version_info < (3, 12):
+ raise RuntimeError("Python 3.12 or higher is required for .rglob() flag `case-sensitive` ") # noqa: E501
+
+ for d in directories:
+ appimage_paths = Path(d).glob('wine*.appimage', case_sensitive=False)
+ for p in appimage_paths:
+ if p is not None and check_appimage(p):
+ output1, output2 = wine.check_wine_version_and_branch(
+ release_version,
+ p,
+ )
+ 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):
+ wine_binary_path_list = [
+ "/usr/local/bin",
+ os.path.expanduser("~") + "/bin",
+ os.path.expanduser("~") + "/PlayOnLinux/wine/linux-amd64/*/bin",
+ 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)
+
+ # 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
+
+ # Check each directory in PATH for wine64; add to list
+ binaries = []
+ paths = os.environ["PATH"].split(":")
+ for path in paths:
+ binary_path = os.path.join(path, "wine64")
+ if os.path.exists(binary_path) and os.access(binary_path, os.X_OK):
+ binaries.append(binary_path)
+
+ for binary in binaries[:]:
+ output1, output2 = wine.check_wine_version_and_branch(
+ release_version,
+ binary,
+ )
+ if output1 is not None and output1:
+ continue
+ else:
+ binaries.remove(binary)
+ logging.info(f"Removing binary: {binary} because: {output2}")
+
+ return binaries
+
+
+def set_appimage_symlink(app=None):
+ # 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
+ # 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}")
+ 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}.")
+
+ # 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
+
+ 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
+
+ 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()
+
+ if system.get_runmode() != 'binary':
+ logging.error(f"Can't update {config.name_app} when run as a script.")
+ elif status == 0:
+ 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()
+ if status == 0:
+ set_appimage_symlink()
+ 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):
+ dirs = [
+ config.MYDOWNLOADS,
+ Path.home(),
+ Path.cwd(),
+ ]
+ for d in dirs:
+ file_path = Path(d) / filename
+ if file_path.is_file():
+ logging.info(f"'{filename}' exists in {str(d)}.")
+ return str(file_path)
+ 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)
+ if not fp.is_file():
+ return None
+ found = False
+ ct = 0
+ if fp.exists():
+ with fp.open() as f:
+ for line in f:
+ ct += 1
+ text = line.rstrip()
+ if re.search(regexp, text):
+ logging.debug(f"{filepath}:{ct}:{text}")
+ found = True
+ 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)
+
+ try:
+ with tarfile.open(file_path, 'r:gz') as tar:
+ tar.extractall(path=output_dir)
+ logging.debug(f"Successfully extracted '{file_path}' to '{output_dir}'") # noqa: E501
+ except tarfile.TarError as e:
+ logging.error(f"Error extracting '{file_path}': {e}")
+
+
+def is_relative_path(path):
+ if isinstance(path, str):
+ path = Path(path)
+ return not path.is_absolute()
+
+
+def get_relative_path(path, base_path):
+ if is_relative_path(path):
+ return path
+ else:
+ if isinstance(path, Path):
+ path = str(path)
+ base_path = str(base_path)
+ if path.startswith(base_path):
+ return path[len(base_path):].lstrip(os.sep)
+ else:
+ 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()
+
+ current_time = time.time()
+ elapsed_time = current_time - start_time
+
+ if elapsed_time >= interval:
+ last_log_time = current_time
+ return True, last_log_time
+ else:
+ return False, start_time
diff --git a/ou_dedetai/wine.py b/ou_dedetai/wine.py
new file mode 100644
index 00000000..844b1d83
--- /dev/null
+++ b/ou_dedetai/wine.py
@@ -0,0 +1,598 @@
+import logging
+import os
+import re
+import shutil
+import signal
+import subprocess
+from pathlib import Path
+from typing import Optional
+
+from . import config
+from . import msg
+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():
+ try:
+ process = run_wine_proc(config.WINESERVER, exe_args=["-p"])
+ 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_wait():
+ if check_wineserver():
+ process = run_wine_proc(config.WINESERVER_EXE, exe_args=["-w"])
+ wait_pid(process)
+
+
+# def light_wineserver_wait():
+# command = [f"{config.WINESERVER_EXE}", "-w"]
+# system.wait_on(command)
+
+
+# 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):
+ cmd = [binary, "--version"]
+ try:
+ version_string = subprocess.check_output(cmd, encoding='utf-8').strip()
+ logging.debug(f"Version string: {str(version_string)}")
+ try:
+ version, release = version_string.split()
+ except ValueError:
+ # Neither "Devel" nor "Stable" release is noted in version output
+ version = version_string
+ release = get_wine_branch(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]
+ release = release.lstrip('(').rstrip(')').lower() # remove parens
+ else:
+ ver_major = 0
+ ver_minor = 0
+
+ wine_release = [int(ver_major), int(ver_minor), release]
+ logging.debug(f"Wine release of {binary}: {str(wine_release)}")
+
+ if ver_major == 0:
+ return False, "Couldn't determine wine version."
+ else:
+ return wine_release, "yes"
+
+ except subprocess.CalledProcessError as e:
+ return False, f"Error running command: {e}"
+
+ except ValueError as e:
+ return False, f"Error parsing version: {e}"
+
+ except Exception as e:
+ return False, f"Error: {e}"
+
+
+def check_wine_rules(wine_release, release_version):
+ # 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):
+ required_wine_minimum = [7, 18]
+ else:
+ required_wine_minimum = [9, 10]
+ elif config.TARGETVERSION == "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"],
+ },
+ ]
+
+ major_min, minor_min = required_wine_minimum
+ if wine_release:
+ major, minor, release_type = wine_release
+ result = True, "None" # Whether the release is allowed; error message
+ for rule in rules:
+ 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 ["staging", "devel"]:
+ result = (
+ False,
+ (
+ f"Wine release needs to be devel or staging. "
+ f"Current release: {release_type}."
+ )
+ )
+ break
+ else:
+ result = (
+ False,
+ (
+ 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", []):
+ result = False, f"Wine version {major}.{minor} will not work."
+ break
+ if major < major_min:
+ result = (
+ False,
+ (
+ f"Wine version {major}.{minor} is "
+ f"below minimum required ({major_min}.{minor_min}).")
+ )
+ break
+ elif major == major_min and minor < minor_min:
+ if not rule["proton"]:
+ result = (
+ False,
+ (
+ f"Wine version {major}.{minor} is "
+ f"below minimum required ({major_min}.{minor_min}).") # noqa: E501
+ )
+ break
+ logging.debug(f"Result: {result}")
+ return result
+ else:
+ return True, "Default to trusting user override"
+
+
+def check_wine_version_and_branch(release_version, test_binary):
+ if not os.path.exists(test_binary):
+ reason = "Binary does not exist."
+ return False, reason
+
+ if not os.access(test_binary, os.X_OK):
+ reason = "Binary is not executable."
+ return False, reason
+
+ wine_release, error_message = get_wine_release(test_binary)
+
+ if wine_release is False and error_message is not None:
+ return False, error_message
+
+ result, message = check_wine_rules(wine_release, release_version)
+ if not result:
+ return result, message
+
+ if wine_release[0] > 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=}")
+ # Avoid wine-mono window
+ orig_overrides = config.WINEDLLOVERRIDES
+ config.WINEDLLOVERRIDES = f"{config.WINEDLLOVERRIDES};mscoree="
+ logging.debug(f"Running: {wine_exe} wineboot --init")
+ process = run_wine_proc(
+ wine_exe,
+ exe='wineboot',
+ exe_args=['--init'],
+ init=True
+ )
+ config.WINEDLLOVERRIDES = orig_overrides
+ return process
+
+
+def wine_reg_install(reg_file):
+ reg_file = str(reg_file)
+ msg.status(f"Installing registry file: {reg_file}")
+ process = run_wine_proc(
+ str(utils.get_wine_exe_path().parent / 'wine64'),
+ exe="regedit.exe",
+ exe_args=[reg_file]
+ )
+ # NOTE: For some reason wait_pid results in the reg install failing.
+ # wait_pid(process)
+ 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}")
+ elif process.returncode == 0:
+ logging.info(f"{reg_file} installed.")
+ # light_wineserver_wait()
+ wineserver_wait()
+
+
+def disable_winemenubuilder():
+ reg_file = Path(config.WORKDIR) / 'disable-winemenubuilder.reg'
+ reg_file.write_text(r'''REGEDIT4
+
+[HKEY_CURRENT_USER\Software\Wine\DllOverrides]
+"winemenubuilder.exe"=""
+''')
+ wine_reg_install(reg_file)
+
+
+def install_msi(app=None):
+ msg.status(f"Running MSI installer: {config.LOGOS_EXECUTABLE}.", app)
+ # 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:
+ 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)
+ return process
+
+
+def wait_pid(process):
+ os.waitpid(-process.pid, 0)
+
+
+def run_wine_proc(winecmd, exe=None, exe_args=list(), init=False):
+ 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)
+ if isinstance(winecmd, Path):
+ winecmd = str(winecmd)
+ logging.debug(f"run_wine_proc: {winecmd}; {exe=}; {exe_args=}")
+
+ command = [winecmd]
+ if exe is not None:
+ command.append(exe)
+ if exe_args:
+ 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:
+ process = system.popen_command(
+ command,
+ stdout=wine_log,
+ stderr=wine_log,
+ env=env,
+ 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''):
+ if winecmd.endswith('winetricks'):
+ logging.debug(line.decode('cp437').rstrip())
+ else:
+ 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
+ else:
+ logging.error("wine.run_wine_proc: Error while decoding: WINECMD_ENCODING is None.") # noqa: E501
+ return process
+ else:
+ return None
+
+ except subprocess.CalledProcessError as e:
+ logging.error(f"Exception running '{' '.join(command)}': {e}")
+
+ 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):
+ cmd = [*args]
+ msg.status(f"Running winetricks \"{args[-1]}\"")
+ logging.info(f"running \"winetricks {' '.join(cmd)}\"")
+ process = run_wine_proc(config.WINETRICKSBIN, exe_args=cmd)
+ 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):
+ logging.debug(f"{proc=}")
+ else:
+ logging.debug('')
+
+
+def install_d3d_compiler():
+ cmd = ['d3dcompiler_47']
+ if config.WINETRICKS_UNATTENDED is None:
+ cmd.insert(0, '-q')
+ run_winetricks_cmd(*cmd)
+
+
+def install_fonts():
+ msg.status("Configuring fonts…")
+ fonts = ['corefonts', 'tahoma']
+ if not config.SKIP_FONTS:
+ for f in fonts:
+ args = [f]
+ if config.WINETRICKS_UNATTENDED:
+ args.insert(0, '-q')
+ run_winetricks_cmd(*args)
+
+
+def install_font_smoothing():
+ msg.status("Setting font smoothing…")
+ args = ['settings', 'fontsmooth=rgb']
+ if config.WINETRICKS_UNATTENDED:
+ args.insert(0, '-q')
+ run_winetricks_cmd(*args)
+
+
+def set_renderer(renderer):
+ run_winetricks_cmd("-q", "settings", f"renderer={renderer}")
+
+
+def set_win_version(exe, windows_version):
+ if exe == "logos":
+ run_winetricks_cmd('-q', 'settings', f'{windows_version}')
+ elif exe == "indexer":
+ reg = f"HKCU\\Software\\Wine\\AppDefaults\\{config.FLPRODUCT}Indexer.exe" # noqa: E501
+ exe_args = [
+ 'add',
+ reg,
+ "/v", "Version",
+ "/t", "REG_SZ",
+ "/d", f"{windows_version}", "/f",
+ ]
+ process = run_wine_proc(
+ str(utils.get_wine_exe_path()),
+ 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 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=app
+ )
+ drive_c = f"{config.INSTALLDIR}/data/wine64_bottle/drive_c"
+ utils.untar_file(f"{config.MYDOWNLOADS}/{icu_filename}", drive_c)
+
+ # Ensure the target directory exists
+ icu_win_dir = f"{drive_c}/icu-win/windows"
+ if not os.path.exists(icu_win_dir):
+ 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)
+
+ if app:
+ if config.DIALOG == "curses":
+ app.install_icu_e.set()
+
+
+def get_registry_value(reg_path, name):
+ logging.debug(f"Get value for: {reg_path=}; {name=}")
+ # NOTE: Can't use run_wine_proc here because of infinite recursion while
+ # trying to determine WINECMD_ENCODING.
+ value = None
+ env = get_wine_env()
+
+ cmd = [
+ str(utils.get_wine_exe_path().parent / 'wine64'),
+ 'reg', 'query', reg_path, '/v', name,
+ ]
+ err_msg = f"Failed to get registry value: {reg_path}\\{name}"
+ encoding = config.WINECMD_ENCODING
+ if encoding is None:
+ encoding = 'UTF-8'
+ try:
+ result = system.run_command(
+ cmd,
+ encoding=encoding,
+ env=env
+ )
+ except subprocess.CalledProcessError as e:
+ if 'non-zero exit status' in str(e):
+ logging.warning(err_msg)
+ return None
+ if result.stdout is not None:
+ for line in result.stdout.splitlines():
+ if line.strip().startswith(name):
+ value = line.split()[-1].strip()
+ logging.debug(f"Registry value: {value}")
+ break
+ else:
+ logging.critical(err_msg)
+ return value
+
+
+def get_mscoree_winebranch(mscoree_file):
+ try:
+ with mscoree_file.open('rb') as f:
+ for line in f:
+ m = re.search(rb'wine-[a-z]+', line)
+ if m is not None:
+ return m[0].decode().lstrip('wine-')
+ except FileNotFoundError as e:
+ logging.error(e)
+
+
+def get_wine_branch(binary):
+ logging.info(f"Determining wine branch of '{binary}'")
+ binary_obj = Path(binary).expanduser().resolve()
+ if utils.check_appimage(binary_obj):
+ logging.debug(f"Mounting AppImage: {binary_obj}")
+ # Mount appimage to inspect files.
+ p = subprocess.Popen(
+ [binary_obj, '--appimage-mount'],
+ stdout=subprocess.PIPE,
+ encoding='UTF8'
+ )
+ branch = None
+ while p.returncode is None:
+ for line in p.stdout:
+ if line.startswith('/tmp'):
+ tmp_dir = Path(line.rstrip())
+ for f in tmp_dir.glob('org.winehq.wine.desktop'):
+ if not branch:
+ for dline in f.read_text().splitlines():
+ try:
+ k, v = dline.split('=')
+ except ValueError: # not a key=value line
+ continue
+ if k == 'X-AppImage-Version':
+ branch = v.split('_')[0]
+ logging.debug(f"{branch=}")
+ break
+ p.send_signal(signal.SIGINT)
+ p.poll()
+ return branch
+ else:
+ logging.debug("Binary object is not an AppImage.")
+ logging.info(f"'{binary}' resolved to '{binary_obj}'")
+ mscoree64 = binary_obj.parents[1] / 'lib64' / 'wine' / 'x86_64-windows' / 'mscoree.dll' # noqa: E501
+ if not mscoree64.exists(): #alpine
+ mscoree64 = binary_obj.parents[1] / 'lib' / 'wine' / 'x86_64-windows' / 'mscoree.dll' # noqa: E501
+ return get_mscoree_winebranch(mscoree64)
+
+
+def get_wine_env():
+ wine_env = os.environ.copy()
+ winepath = utils.get_wine_exe_path()
+ 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'
+ wine_env_defaults = {
+ 'WINE': str(winepath),
+ 'WINEDEBUG': config.WINEDEBUG,
+ 'WINEDLLOVERRIDES': config.WINEDLLOVERRIDES,
+ 'WINELOADER': str(winepath),
+ 'WINEPREFIX': config.WINEPREFIX,
+ 'WINESERVER': config.WINESERVER_EXE,
+ # 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.
+ # 'WINETRICKS_SUPER_QUIET': '',
+ }
+ 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
+
+ updated_env = {k: wine_env.get(k) for k in wine_env_defaults.keys()}
+ logging.debug(f"Wine env: {updated_env}")
+ return wine_env
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..43ab4a14
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,38 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+dependencies = [
+# "altgraph",
+# "certifi",
+# "charset-normalizer",
+ "distro",
+# "idna",
+ "packaging",
+ "psutil",
+ "pythondialog",
+ "requests",
+# "tkinter", # actually provided by a system package, not a python package
+# "urllib3",
+]
+name = "ou_dedetai"
+dynamic = ["readme", "version"]
+requires-python = ">=3.12"
+
+[project.optional-dependencies]
+build = ["pyinstaller"]
+test = ["coverage"]
+
+[project.scripts]
+oudedetai = "ou_dedetai.main:main"
+
+[tool.setuptools.dynamic]
+readme = {file = ["README.md"], content-type = "text/plain"}
+version = {attr = "ou_dedetai.config.LLI_CURRENT_VERSION"}
+
+[tool.setuptools.packages.find]
+where = ["."]
+
+[tool.setuptools.package-data]
+"ou_dedetai.img" = ["*icon.png"]
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index e60d46c3..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-altgraph==0.17.4
-certifi==2023.11.17
-charset-normalizer==3.3.2
-distro==1.9.0
-idna==3.6
-packaging==23.2
-psutil==5.9.7
-requests==2.31.0
-urllib3==2.1.0
diff --git a/scripts/build-binary.sh b/scripts/build-binary.sh
index 27bb1623..f9b10ca9 100755
--- a/scripts/build-binary.sh
+++ b/scripts/build-binary.sh
@@ -1,17 +1,20 @@
#!/usr/bin/env bash
+start_dir="$PWD"
script_dir="$(dirname "$0")"
repo_root="$(dirname "$script_dir")"
+cd "$repo_root"
if ! python -c 'import coverage' >/dev/null 2>&1; then
echo "Error: Need to install coverage; e.g. 'pip install coverage'"
exit 1
fi
-if ! python -c 'import PyInstaller' >/dev/null 2>&1; then
- echo "Error: Need to install pyinstaller; e.g. 'pip install pyinstaller'"
- exit 1
+if ! which pyinstaller >/dev/null 2>&1 || ! which oudedetai >/dev/null; then
+ # Install build deps.
+ python3 -m pip install .[build]
fi
if ! python -m coverage run -m unittest -b; then
echo "Error: Must pass unittests before building"
echo "Run 'python -m coverage run -m unittest -b -v' to see which test is failing"
exit 1
fi
-python -m PyInstaller --clean "${repo_root}/LogosLinuxInstaller.spec"
+pyinstaller --clean --log-level DEBUG ou_dedetai.spec
+cd "$start_dir"
diff --git a/scripts/distro-info.sh b/scripts/distro-info.sh
new file mode 100755
index 00000000..586cff77
--- /dev/null
+++ b/scripts/distro-info.sh
@@ -0,0 +1,49 @@
+#!/bin/sh
+# Creates a text file at ~/distro-info.log with output from python's distro
+# module and from various known text files that give distro details. This is
+# is intended to cover as many Linux distributions as possible.
+
+# Initialize log file.
+logfile=~/distro-info.log
+date --utc > "$logfile"
+echo >> "$logfile"
+
+# Determine python executable.
+python=python
+if which python3 >/dev/null; then
+ python=python3
+fi
+
+# Record python's distro output.
+if which $python >/dev/null && $python -c 'import distro' 2>/dev/null; then
+ $python -c 'import distro; print(f"{distro.info()=}")' >> "$logfile"
+ $python -c 'import distro; print(f"{distro.os_release_info()=}")' >> "$logfile"
+ $python -c 'import distro; print(f"{distro.lsb_release_info()=}")' >> "$logfile"
+ $python -c 'import distro; print(f"{distro.distro_release_info()=}")' >> "$logfile"
+ $python -c 'import distro; print(f"{distro.uname_info()=}")' >> "$logfile"
+ echo >> "$logfile"
+fi
+
+# As a fallback, gather [mostly] the same info that python.distro gathers.
+# Ref: https://distro.readthedocs.io/en/latest/#data-sources
+if [ -r /etc/os-release ]; then
+ echo /etc/os-release >> "$logfile"
+ cat /etc/os-release >> "$logfile"
+ echo >> "$logfile"
+elif [ -r /usr/lib/os-release ]; then
+ echo /usr/lib/os-release >> "$logfile"
+ cat /usr/lib/os-release >> "$logfile"
+ echo >> "$logfile"
+fi
+
+if which lsb_release >/dev/null 2>&1; then
+ echo "lsb_release -a" >> "$logfile"
+ lsb_release -a >> "$logfile"
+ echo >> "$logfile"
+fi
+
+if which uname >/dev/null; then
+ echo "uname -rs" >> "$logfile"
+ uname -rs >> "$logfile"
+ echo >> "$logfile"
+fi
diff --git a/scripts/ensure-python.sh b/scripts/ensure-python.sh
index 11dcce2e..92d23289 100755
--- a/scripts/ensure-python.sh
+++ b/scripts/ensure-python.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
-python_ver='3.12.1'
+python_ver=$(wget -qO- https://www.python.org/ftp/python/ | grep -oE '3\.12\.[0-9]+' | sort -u | tail -n1)
prefix=/opt
# Derived vars.
@@ -18,9 +18,10 @@ fi
# Warn about build deps.
echo "Warning: You will likely need to install build dependencies for your system."
-echo "e.g. Ubuntu requires: build-essential libreadline-dev libsqlite3-dev tk-dev tcl-dev"
-read -r -p "Continue? [y/N]: " ans
-if [[ ${ans,,} != 'y' ]]; then
+echo "e.g. Debian 12 requires:"
+echo "build-essential gdb lcov pkg-config libbz2-dev libffi-dev libgdbm-dev libgdbm-compat-dev liblzma-dev libncurses5-dev libreadline6-dev libsqlite3-dev libssl-dev lzma lzma-dev tk-dev uuid-dev zlib1g-dev wget"
+read -r -p "Continue? [y/N] " ans
+if [[ ${ans,,} != 'y' && $ans != '' ]]; then
exit 1
fi
@@ -55,6 +56,7 @@ sudo make install
# Check install.
if [[ ! -x "$python_exec_path" ]]; then
echo "Error: Executable not found: $python_exec_path"
+ cd ~
exit 1
fi
echo "Python $python_ver has been installed into $prefix"
@@ -63,3 +65,4 @@ if [[ "$prefix" == '/opt' ]]; then
echo "Running Python $python_ver directly requires LD_LIBRARY_PATH:"
echo "LD_LIBRARY_PATH=${prefix}/lib $python_exec_path"
fi
+cd ~
diff --git a/scripts/ensure-venv.sh b/scripts/ensure-venv.sh
index f21cf289..ec41e306 100755
--- a/scripts/ensure-venv.sh
+++ b/scripts/ensure-venv.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
-python_ver='3.12.1'
+python_ver=$(wget -qO- https://www.python.org/ftp/python/ | grep -oE '3\.12\.[0-9]+' | sort -u | tail -n1)
prefix=/opt
venv=./env
@@ -28,6 +28,9 @@ if [[ -d "$venv" ]]; then
fi
# Initialize venv.
+if [[ $prefix == '/opt' ]]; then
+ export LD_LIBRARY_PATH=${prefix}/lib
+fi
"$python_exec_path" -m venv "$venv"
echo "LD_LIBRARY_PATH=${prefix}/lib" >> "${venv}/bin/activate"
echo 'export LD_LIBRARY_PATH' >> "${venv}/bin/activate"
diff --git a/scripts/run_app.py b/scripts/run_app.py
new file mode 100755
index 00000000..1247ef89
--- /dev/null
+++ b/scripts/run_app.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python3
+"""
+This script is needed so that PyInstaller can refer to a script that does not
+use relative imports.
+https://github.com/pyinstaller/pyinstaller/issues/2560
+"""
+import re
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parents[1]))
+import ou_dedetai.main # noqa: E402
+if __name__ == '__main__':
+ sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+ sys.exit(ou_dedetai.main.main())
diff --git a/tests/test_LLI.py b/tests/test_LLI.py
index 00dc7ed1..e32c8e0d 100644
--- a/tests/test_LLI.py
+++ b/tests/test_LLI.py
@@ -4,8 +4,8 @@
from pathlib import Path
from unittest.mock import patch
-import config
-import LogosLinuxInstaller as LLI
+import ou_dedetai.config as config
+import ou_dedetai.main as LLI
class TestLLICli(unittest.TestCase):
@@ -34,7 +34,7 @@ def test_parse_args_config_file(self):
def test_parse_args_create_shortcuts(self):
cli_args = self.parser.parse_args(args=['--create-shortcuts'])
LLI.parse_args(cli_args, self.parser)
- self.assertEqual('ensure_launcher_shortcuts', config.ACTION.__name__)
+ self.assertEqual('create_shortcuts', config.ACTION.__name__)
def test_parse_args_custom_binary_path_good(self):
user_path = str(Path.home())
@@ -74,12 +74,12 @@ def test_parse_args_force_root(self):
def test_parse_args_install_app(self):
cli_args = self.parser.parse_args(args=['--install-app'])
LLI.parse_args(cli_args, self.parser)
- self.assertEqual('ensure_launcher_shortcuts', config.ACTION.__name__)
+ self.assertEqual('install_app', config.ACTION.__name__)
def test_parse_args_install_dependencies(self):
cli_args = self.parser.parse_args(args=['--install-dependencies'])
LLI.parse_args(cli_args, self.parser)
- self.assertEqual('check_dependencies', config.ACTION.__name__)
+ self.assertEqual('install_dependencies', config.ACTION.__name__)
def test_parse_args_passive(self):
cli_args = self.parser.parse_args(args=['--passive'])
@@ -89,7 +89,7 @@ def test_parse_args_passive(self):
def test_parse_args_remove_index_files(self):
cli_args = self.parser.parse_args(args=['--remove-index-files'])
LLI.parse_args(cli_args, self.parser)
- self.assertEqual('remove_all_index_files', config.ACTION.__name__)
+ self.assertEqual('remove_index_files', config.ACTION.__name__)
def test_parse_args_remove_install_dir(self):
cli_args = self.parser.parse_args(args=['--remove-install-dir'])
@@ -114,7 +114,7 @@ def test_parse_args_run_indexing(self):
def test_parse_args_run_installed_app(self):
cli_args = self.parser.parse_args(args=['--run-installed-app'])
LLI.parse_args(cli_args, self.parser)
- self.assertEqual('run_logos', config.ACTION.__name__)
+ self.assertEqual('run_installed_app', config.ACTION.__name__)
def test_parse_args_run_winetricks(self):
cli_args = self.parser.parse_args(args=['--run-winetricks'])
@@ -153,7 +153,7 @@ def test_parse_args_skip_fonts(self):
def test_parse_args_toggle_app_logging(self):
cli_args = self.parser.parse_args(args=['--toggle-app-logging'])
LLI.parse_args(cli_args, self.parser)
- self.assertEqual('switch_logging', config.ACTION.__name__)
+ self.assertEqual('toggle_app_logging', config.ACTION.__name__)
def test_parse_args_update_latest_appimage_disabled(self):
config.WINEBIN_CODE = None
@@ -166,7 +166,7 @@ def test_parse_args_update_latest_appimage_enabled(self):
cli_args = self.parser.parse_args(args=['--update-latest-appimage'])
LLI.parse_args(cli_args, self.parser)
self.assertEqual(
- 'update_to_latest_recommended_appimage',
+ 'update_latest_appimage',
config.ACTION.__name__
)
@@ -174,7 +174,7 @@ def test_parse_args_update_self(self):
cli_args = self.parser.parse_args(args=['--update-self'])
LLI.parse_args(cli_args, self.parser)
self.assertEqual(
- 'update_to_latest_lli_release',
+ 'update_self',
config.ACTION.__name__
)
@@ -185,22 +185,21 @@ def test_parse_args_verbose(self):
class TestLLI(unittest.TestCase):
- @patch('tui_app.control_panel_app')
- @patch('gui_app.control_panel_app')
- def test_run_control_panel_curses(self, mock_gui, mock_tui):
+ @patch('curses.wrapper')
+ def test_run_control_panel_curses(self, mock_curses):
config.DIALOG = 'curses'
LLI.run_control_panel()
- self.assertTrue(mock_tui.called)
+ self.assertTrue(mock_curses.called)
- @patch('tui_app.control_panel_app')
- @patch('gui_app.control_panel_app')
+ @patch('ou_dedetai.tui_app.control_panel_app')
+ @patch('ou_dedetai.gui_app.control_panel_app')
def test_run_control_panel_none(self, mock_gui, mock_tui):
config.DIALOG = None
LLI.run_control_panel()
self.assertTrue(mock_gui.called)
- @patch('tui_app.control_panel_app')
- @patch('gui_app.control_panel_app')
+ @patch('ou_dedetai.tui_app.control_panel_app')
+ @patch('ou_dedetai.gui_app.control_panel_app')
def test_run_control_panel_tk(self, mock_gui, mock_tui):
config.DIALOG = 'tk'
LLI.run_control_panel()
diff --git a/tests/test_config.py b/tests/test_config.py
index 0bd27c82..811844e0 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -3,7 +3,7 @@
import unittest
from pathlib import Path
-import config
+import ou_dedetai.config as config
class TestConfig(unittest.TestCase):
diff --git a/tests/test_installer.py b/tests/test_installer.py
index 6f7e8f84..3f73a279 100644
--- a/tests/test_installer.py
+++ b/tests/test_installer.py
@@ -1,7 +1,7 @@
import unittest
from pathlib import Path
-import installer
+import ou_dedetai.installer as installer
class TestInstaller(unittest.TestCase):
@@ -19,12 +19,3 @@ def test_get_progress_pct_normal(self):
def test_get_progress_pct_over100(self):
pct = installer.get_progress_pct(15, 10)
self.assertEqual(100, pct)
-
- def test_grep_found(self):
- self.assertTrue(installer.grep(r'LOGOS_DIR', self.grepfile))
-
- def test_grep_nofile(self):
- self.assertIsNone(installer.grep(r'test', 'thisfiledoesnotexist'))
-
- def test_grep_notfound(self):
- self.assertFalse(installer.grep(r'TEST_NOT_IN_FILE', self.grepfile))
diff --git a/tests/test_msg.py b/tests/test_msg.py
index 0df2248f..f927eb05 100644
--- a/tests/test_msg.py
+++ b/tests/test_msg.py
@@ -1,9 +1,9 @@
import unittest
from unittest.mock import patch
-import config
+import ou_dedetai.config as config
import logging
-import msg
+import ou_dedetai.msg as msg
class TestMsg(unittest.TestCase):
@@ -20,69 +20,69 @@ def test_update_log_level(self):
level = h.level
self.assertEqual(level, new_level)
- @patch('msg.input', create=True)
+ @patch('ou_dedetai.msg.input', create=True)
def test_cli_acknowledge_question_empty(self, mocked_input):
mocked_input.side_effect = ['']
result = msg.cli_acknowledge_question('test', 'no')
self.assertTrue(result)
- @patch('msg.input', create=True)
+ @patch('ou_dedetai.msg.input', create=True)
def test_cli_acknowledge_question_no(self, mocked_input):
mocked_input.side_effect = ['N']
result = msg.cli_acknowledge_question('test', 'no')
self.assertFalse(result)
- @patch('msg.input', create=True)
+ @patch('ou_dedetai.msg.input', create=True)
def test_cli_acknowledge_question_yes(self, mocked_input):
mocked_input.side_effect = ['Y']
result = msg.cli_acknowledge_question('test', 'no')
self.assertTrue(result)
- @patch('msg.input', create=True)
+ @patch('ou_dedetai.msg.input', create=True)
def test_cli_ask_filepath(self, mocked_input):
path = "/home/user/Directory"
mocked_input.side_effect = [f"\"{path}\""]
result = msg.cli_ask_filepath('test')
self.assertEqual(path, result)
- @patch('msg.input', create=True)
+ @patch('ou_dedetai.msg.input', create=True)
def test_cli_continue_question_yes(self, mocked_input):
mocked_input.side_effect = ['Y']
result = msg.cli_continue_question('test', 'no', None)
self.assertIsNone(result)
- @patch('msg.input', create=True)
+ @patch('ou_dedetai.msg.input', create=True)
def test_cli_question_empty(self, mocked_input):
mocked_input.side_effect = ['']
self.assertTrue(msg.cli_question('test'))
- @patch('msg.input', create=True)
+ @patch('ou_dedetai.msg.input', create=True)
def test_cli_question_no(self, mocked_input):
mocked_input.side_effect = ['N']
self.assertFalse(msg.cli_question('test'))
- @patch('msg.input', create=True)
+ @patch('ou_dedetai.msg.input', create=True)
def test_cli_question_yes(self, mocked_input):
mocked_input.side_effect = ['Y']
self.assertTrue(msg.cli_question('test'))
- @patch('msg.input', create=True)
- def test_logos_acknowledge_question_empty(self, mocked_input):
- config.DIALOG = 'curses'
- mocked_input.side_effect = ['']
- result = msg.logos_acknowledge_question('test', 'no')
- self.assertTrue(result)
+ # @patch('ou_dedetai.msg.input', create=True)
+ # def test_logos_acknowledge_question_empty(self, mocked_input):
+ # config.DIALOG = 'curses'
+ # mocked_input.side_effect = ['']
+ # result = msg.logos_acknowledge_question('test', 'no')
+ # self.assertTrue(result)
- @patch('msg.input', create=True)
- def test_logos_acknowledge_question_no(self, mocked_input):
- config.DIALOG = 'curses'
- mocked_input.side_effect = ['N']
- result = msg.logos_acknowledge_question('test', 'no')
- self.assertFalse(result)
+ # @patch('ou_dedetai.msg.input', create=True)
+ # def test_logos_acknowledge_question_no(self, mocked_input):
+ # config.DIALOG = 'curses'
+ # mocked_input.side_effect = ['N']
+ # result = msg.logos_acknowledge_question('test', 'no')
+ # self.assertFalse(result)
- @patch('msg.input', create=True)
- def test_logos_acknowledge_question_yes(self, mocked_input):
- config.DIALOG = 'curses'
- mocked_input.side_effect = ['Y']
- result = msg.logos_acknowledge_question('test', 'no')
- self.assertTrue(result)
+ # @patch('ou_dedetai.msg.input', create=True)
+ # def test_logos_acknowledge_question_yes(self, mocked_input):
+ # config.DIALOG = 'curses'
+ # mocked_input.side_effect = ['Y']
+ # result = msg.logos_acknowledge_question('test', 'no')
+ # self.assertTrue(result)
diff --git a/tests/test_tui.py b/tests/test_tui.py
deleted file mode 100644
index 3c8b89e4..00000000
--- a/tests/test_tui.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import unittest
-
-import tui
-
-
-class TestTui(unittest.TestCase):
- def test_convert_yes_no_Y(self):
- self.assertIs(tui.convert_yes_no('Y'), True)
-
- def test_convert_yes_no_y(self):
- self.assertIs(tui.convert_yes_no('y'), True)
-
- def test_convert_yes_no_n(self):
- self.assertIs(tui.convert_yes_no('n'), False)
-
- def test_convert_yes_no_N(self):
- self.assertIs(tui.convert_yes_no('N'), False)
-
- def test_convert_yes_no_enter(self):
- self.assertIs(tui.convert_yes_no('\n'), True)
-
- def test_convert_yes_no_other(self):
- self.assertIs(tui.convert_yes_no('other'), None)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 0b9d09fe..f68aadeb 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -4,8 +4,8 @@
import unittest
from pathlib import Path
-import config
-import utils
+import ou_dedetai.config as config
+import ou_dedetai.utils as utils
URLOBJ = utils.UrlProps('http://ip.me')
@@ -159,6 +159,16 @@ def test_urlprops_get_size_none(self):
def test_urlprops_get_md5(self):
self.assertIsNone(URLOBJ.md5)
+ def test_grep_found(self):
+ self.assertTrue(utils.grep(r'LOGOS_DIR', self.grepfile))
+
+ def test_grep_nofile(self):
+ self.assertIsNone(utils.grep(r'test', 'thisfiledoesnotexist'))
+
+ def test_grep_notfound(self):
+ self.assertFalse(utils.grep(r'TEST_NOT_IN_FILE', self.grepfile))
+
+
class TestUtilsConfigFile(unittest.TestCase):
def setUp(self):
diff --git a/tui.py b/tui.py
deleted file mode 100644
index 861a5a5c..00000000
--- a/tui.py
+++ /dev/null
@@ -1,195 +0,0 @@
-import textwrap
-import curses
-import logging
-
-
-def get_user_input(question_text):
- logging.debug(f"tui.get_user_input: {question_text}")
- return curses.wrapper(_get_user_input, question_text)
-
-
-def _get_user_input(stdscr, question_text):
- curses.echo()
- stdscr.addstr(0, 0, question_text)
- stdscr.refresh()
- user_input = stdscr.getstr(1, 0, 50).decode('utf-8')
- curses.noecho()
- return user_input
-
-
-def confirm(title, question_text):
- logging.debug(f"tui.confirm: {question_text}")
- return curses.wrapper(_confirm, title, question_text)
-
-
-def convert_yes_no(key):
- if key.lower() == 'y' or key == '\n':
- # '\n' for Enter key, defaults to "Yes"
- return True
- elif key.lower() == 'n':
- return False
-
-
-def _confirm(stdscr, title, question_text):
- curses.curs_set(0) # Hide the cursor
-
- stdscr.clear()
- window_height, window_width = stdscr.getmaxyx()
-
- # Wrap the title and question text
- wrapped_title = textwrap.fill(title, window_width - 4)
- wrapped_question = textwrap.fill(question_text + " [Y/n]: ", window_width - 4) # noqa: E501
-
- # Display the wrapped title text, line by line, centered
- title_lines = wrapped_title.split('\n')
- title_start_y = max(0, window_height // 2 - len(title_lines) // 2)
- for i, line in enumerate(title_lines):
- if i < window_height:
- stdscr.addstr(i, 2, line, curses.A_BOLD)
-
- # Display the wrapped question text, line by line, centered
- question_lines = wrapped_question.split('\n')
- question_start_y = title_start_y + len(title_lines) - 4
- question_width = max(len(line) for line in question_lines)
- for i, line in enumerate(question_lines):
- if question_start_y + i < window_height:
- x = window_width // 2 - question_width // 2
- stdscr.addstr(question_start_y + i, x, line)
-
- y = question_start_y + len(question_lines) + 2
-
- while True:
- key = stdscr.getch()
- key = chr(key)
-
- value = convert_yes_no(key)
- if value is not None:
- return value
-
- stdscr.addstr(y, 0, "Type Y[es] or N[o]. ")
-
-
-def menu(options, title, question_text):
- logging.debug(f"tui.menu: {question_text}")
- return curses.wrapper(_menu, options, title, question_text)
-
-
-def _menu(stdscr, options, title, question_text):
- # Set up the screen
- curses.curs_set(0)
-
- current_option = 0
- current_page = 0
-
- while True:
- stdscr.clear()
- window_height, window_width = stdscr.getmaxyx()
-
- # Wrap the title and question text
- wrapped_title = textwrap.fill(title, window_width - 4)
- wrapped_question = textwrap.fill(question_text, window_width - 4)
-
- # Display the wrapped title text, line by line, centered
- title_lines = wrapped_title.split('\n')
- title_start_y = max(0, window_height // 2 - len(title_lines) // 2)
- for i, line in enumerate(title_lines):
- if i < window_height:
- stdscr.addstr(i, 2, line, curses.A_BOLD)
-
- # Display the wrapped question text, line by line, centered
- question_lines = wrapped_question.split('\n')
- question_start_y = title_start_y + len(title_lines) - 4
- question_width = max(len(line) for line in question_lines)
- for i, line in enumerate(question_lines):
- if question_start_y + i < window_height:
- x = window_width // 2 - question_width // 2
- stdscr.addstr(question_start_y + i, x, line)
-
- # Display the options, centered
- options_start_y = question_start_y + len(question_lines) + 2
- options_per_page = max(window_height - options_start_y - 4, 3)
- total_pages = (len(options) - 1) // options_per_page + 1
- for i in range(options_per_page):
- index = current_page * options_per_page + i
- if index < len(options):
- option = options[index]
- if type(option) is list:
- option_lines = []
- wine_binary_code = option[0]
- if wine_binary_code != "Exit":
- wine_binary_path = option[1]
- wine_binary_description = option[2]
- wine_binary_path_wrapped = textwrap.wrap(
- f"Binary Path: {wine_binary_path}", window_width - 4) # noqa: E501
- option_lines.extend(wine_binary_path_wrapped)
- wine_binary_desc_wrapped = textwrap.wrap(
- f"Description: {wine_binary_description}", window_width - 4) # noqa: E501
- option_lines.extend(wine_binary_desc_wrapped)
- else:
- wine_binary_path = option[1]
- wine_binary_description = option[2]
- wine_binary_path_wrapped = textwrap.wrap(
- f"{wine_binary_path}", window_width - 4)
- option_lines.extend(wine_binary_path_wrapped)
- wine_binary_desc_wrapped = textwrap.wrap(
- f"{wine_binary_description}", window_width - 4)
- option_lines.extend(wine_binary_desc_wrapped)
- else:
- option_lines = textwrap.wrap(option, window_width - 4)
-
- for j, line in enumerate(option_lines):
- y = options_start_y + i + j
- x = max(0, window_width // 2 - len(line) // 2)
- if y < window_height:
- if index == current_option:
- stdscr.addstr(y, x, line, curses.A_REVERSE)
- else:
- stdscr.addstr(y, x, line)
-
- if type(option) is list:
- options_start_y += (len(option_lines))
-
- # Display pagination information
- page_info = f"Page {current_page + 1}/{total_pages} | Selected Option: {current_option + 1}/{len(options)}" # noqa: E501
- stdscr.addstr(window_height - 1, 2, page_info, curses.A_BOLD)
-
- # Refresh the windows
- stdscr.refresh()
-
- # Get user input
- key = stdscr.getch()
-
- if key == 65 or key == 259: # Up arrow
- if current_option == current_page * options_per_page and current_page > 0: # noqa: E501
- # Move to the previous page
- current_page -= 1
- current_option = min(
- len(options) - 1,
- (current_page + 1) * options_per_page - 1
- )
- elif current_option == 0:
- if total_pages == 1:
- current_option = len(options) - 1
- else:
- current_page = total_pages - 1
- current_option = len(options) - 1
- else:
- current_option = max(0, current_option - 1)
- elif key == 66 or key == 258: # Down arrow
- if current_option == (current_page + 1) * options_per_page - 1 and current_page < total_pages - 1: # noqa: E501
- # Move to the next page
- current_page += 1
- current_option = min(
- len(options) - 1,
- current_page * options_per_page
- )
- elif current_option == len(options) - 1:
- current_page = 0
- current_option = 0
- else:
- current_option = min(len(options) - 1, current_option + 1)
- elif key == ord('\n'): # Enter key
- choice = options[current_option]
- break
-
- return choice
diff --git a/tui_app.py b/tui_app.py
deleted file mode 100644
index 40dd4cf1..00000000
--- a/tui_app.py
+++ /dev/null
@@ -1,130 +0,0 @@
-import logging
-import sys
-
-import config
-import control
-import tui
-import installer
-import msg
-import utils
-import wine
-
-
-def set_appimage():
- # TODO: Allow specifying the AppImage File
- appimages = utils.find_appimage_files()
- appimage_choices = [["AppImage", filename, "AppImage of Wine64"] for filename in appimages] # noqa: E501
- appimage_choices.extend(["Input Custom AppImage", "Return to Main Menu"])
- sai_choice = tui.menu(
- appimage_choices,
- "AppImage Updater",
- "Which AppImage should be used?"
- )
- if sai_choice == "Return to Main Menu":
- pass # Do nothing.
- elif sai_choice == "Input Custom AppImage":
- appimage_filename = tui.get_user_input("Enter AppImage filename: ")
- config.SELECTED_APPIMAGE_FILENAME = appimage_filename
- utils.set_appimage_symlink()
- else:
- appimage_filename = sai_choice
- config.SELECTED_APPIMAGE_FILENAME = appimage_filename
- utils.set_appimage_symlink()
-
-
-def control_panel_app():
- # Run TUI.
- while True:
- options_first = []
- options_default = ["Install Logos Bible Software"]
- options_main = [
- "Install Dependencies",
- ]
- options_installed = [
- f"Run {config.FLPRODUCT}",
- "Run Indexing",
- "Remove Library Catalog",
- "Remove All Index Files",
- "Run Winetricks",
- "Download or Update Winetricks",
- "Edit Config",
- "Back up Data",
- "Restore Data",
- ]
- options_exit = ["Exit"]
- if utils.file_exists(config.LOGOS_EXE):
- if config.LLI_LATEST_VERSION and utils.get_runmode() == 'binary':
- logging.debug("Checking if Logos Linux Installers needs updated.") # noqa: E501
- status, error_message = utils.compare_logos_linux_installer_version() # noqa: E501
- if status == 0:
- options_first.append("Update Logos Linux Installer")
- elif status == 1:
- logging.warning("Logos Linux Installer is up-to-date.")
- elif status == 2:
- logging.warning("Logos Linux Installer is newer than the latest release.") # noqa: E501
- else:
- logging.error(f"{error_message}")
-
- if config.WINEBIN_CODE == "AppImage" or config.WINEBIN_CODE == "Recommended": # noqa: E501
- logging.debug("Checking if the AppImage needs updated.")
- status, error_message = utils.compare_recommended_appimage_version() # noqa: E501
- if status == 0:
- options_main.insert(0, "Update to Latest AppImage")
- elif status == 1:
- logging.warning("The AppImage is already set to the latest recommended.") # noqa: E501
- elif status == 2:
- logging.warning("The AppImage version is newer than the latest recommended.") # noqa: E501
- else:
- logging.error(f"{error_message}")
-
- options_main.insert(1, "Set AppImage")
-
- if config.LOGS == "DISABLED":
- options_installed.append("Enable Logging")
- else:
- options_installed.append("Disable Logging")
-
- options = options_first + options_installed + options_main + options_default + options_exit # noqa: E501
- else:
- options = options_first + options_default + options_main + options_exit # noqa: E501
-
- choice = tui.menu(
- options,
- f"Welcome to Logos on Linux ({config.LLI_CURRENT_VERSION})",
- "What would you like to do?"
- )
-
- if choice is None or choice == "Exit":
- sys.exit(0)
- elif choice.startswith("Install"):
- installer.ensure_launcher_shortcuts()
- elif choice.startswith("Update Logos Linux Installer"):
- utils.update_to_latest_lli_release()
- elif choice == f"Run {config.FLPRODUCT}":
- wine.run_logos()
- elif choice == "Run Indexing":
- wine.run_indexing()
- elif choice == "Remove Library Catalog":
- control.remove_library_catalog()
- elif choice == "Remove All Index Files":
- control.remove_all_index_files()
- elif choice == "Edit Config":
- control.edit_config()
- elif choice == "Install Dependencies":
- utils.check_dependencies()
- elif choice == "Back up Data":
- control.backup()
- elif choice == "Restore Data":
- control.restore()
- elif choice == "Update to Latest AppImage":
- utils.update_to_latest_recommended_appimage()
- elif choice == "Set AppImage":
- set_appimage()
- elif choice == "Download or Update Winetricks":
- control.set_winetricks()
- elif choice == "Run Winetricks":
- wine.run_winetricks()
- elif choice.endswith("Logging"):
- wine.switch_logging()
- else:
- msg.logos_error("Unknown menu choice.")
diff --git a/utils.py b/utils.py
deleted file mode 100644
index 83e52e09..00000000
--- a/utils.py
+++ /dev/null
@@ -1,1661 +0,0 @@
-import atexit
-import distro
-import hashlib
-import json
-import logging
-import os
-import psutil
-import queue
-import re
-import requests
-import shutil
-import signal
-import stat
-import subprocess
-import sys
-import threading
-import tkinter as tk
-import zipfile
-from base64 import b64encode
-from datetime import datetime, timedelta
-from packaging import version
-from pathlib import Path
-from typing import List, Union
-from urllib.parse import urlparse
-from xml.etree import ElementTree as ET
-
-import config
-import msg
-import wine
-import tui
-
-
-class Props():
- def __init__(self, uri=None):
- self.path = None
- self.size = None
- self.md5 = None
- if uri is not None:
- self.path = uri
-
-
-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):
- if self.path is None:
- return
- self.size = self.path.stat().st_size
- return self.size
-
- def get_md5(self):
- if self.path is None:
- return
- md5 = hashlib.md5()
- with self.path.open('rb') as f:
- for chunk in iter(lambda: f.read(4096), b''):
- md5.update(chunk)
- self.md5 = b64encode(md5.digest()).decode('utf-8')
- logging.debug(f"{str(self.path)} MD5: {self.md5}")
- return self.md5
-
-
-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
- 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
- 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
- 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
- if self.headers.get('server') == 'AmazonS3':
- content_md5 = self.headers.get('etag')
- if content_md5 is not None:
- # Convert from hex to base64
- content_md5_hex = content_md5.strip('"').strip("'")
- content_md5 = b64encode(bytes.fromhex(content_md5_hex)).decode() # noqa: E501
- else:
- content_md5 = self.headers.get('Content-MD5')
- if content_md5 is not None:
- content_md5 = content_md5.strip('"').strip("'")
- logging.debug(f"{content_md5=}")
- if content_md5 is not None:
- self.md5 = content_md5
- return self.md5
-
-
-# Set "global" variables.
-def set_default_config():
- get_os()
- 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 config.WINE_EXE and not config.WINESERVER_EXE:
- bin_dir = Path(config.WINE_EXE).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()
-
-
-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:
- 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():
- PIDF = '/tmp/LogosLinuxInstaller.pid'
-
- def remove_pid_file():
- if os.path.exists(PIDF):
- os.remove(PIDF)
-
- if os.path.isfile(PIDF):
- with open(PIDF, '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:
- os.kill(int(pid), signal.SIGKILL)
-
- atexit.register(remove_pid_file)
- with open(PIDF, '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 reboot():
- logging.info("Rebooting system.")
- command = f"{config.SUPERUSER_COMMAND} reboot now"
- subprocess.run(
- command,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- shell=True,
- text=True
- )
- sys.exit(0)
-
-
-def restart_lli():
- logging.debug("Restarting Logos Linux Installer.")
- pidfile = Path('/tmp/LogosLinuxInstaller.pid')
- 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 t(command):
- if shutil.which(command) is not None:
- return True
- else:
- return False
-
-
-def tl(library):
- try:
- __import__(library)
- return True
- except ImportError:
- return False
-
-
-def get_dialog():
- if not os.environ.get('DISPLAY'):
- msg.logos_error("The installer does not work unless you are running a display") # noqa: E501
-
- DIALOG = os.getenv('DIALOG')
- config.GUI = False
- # Set config.DIALOG.
- if DIALOG is not None:
- DIALOG = DIALOG.lower()
- if DIALOG not in ['curses', 'tk']:
- msg.logos_error("Valid values for DIALOG are 'curses' or 'tk'.")
- config.DIALOG = DIALOG
- elif sys.__stdin__.isatty():
- config.DIALOG = 'curses'
- else:
- config.DIALOG = 'tk'
- # Set config.GUI.
- if config.DIALOG == 'tk':
- config.GUI = True
-
-
-def get_os():
- # TODO: Remove if we can verify these are no longer needed commented code.
-
- # Try reading /etc/os-release
- # try:
- # with open('/etc/os-release', 'r') as f:
- # os_release_content = f.read()
- # match = re.search(
- # r'^ID=(\S+).*?VERSION_ID=(\S+)',
- # os_release_content, re.MULTILINE
- # )
- # if match:
- # config.OS_NAME = match.group(1)
- # config.OS_RELEASE = match.group(2)
- # return config.OS_NAME, config.OS_RELEASE
- # except FileNotFoundError:
- # pass
-
- # Try using lsb_release command
- # try:
- # config.OS_NAME = platform.linux_distribution()[0]
- # config.OS_RELEASE = platform.linux_distribution()[1]
- # return config.OS_NAME, config.OS_RELEASE
- # except AttributeError:
- # pass
-
- # Try reading /etc/lsb-release
- # try:
- # with open('/etc/lsb-release', 'r') as f:
- # lsb_release_content = f.read()
- # match = re.search(
- # r'^DISTRIB_ID=(\S+).*?DISTRIB_RELEASE=(\S+)',
- # lsb_release_content,
- # re.MULTILINE
- # )
- # if match:
- # config.OS_NAME = match.group(1)
- # config.OS_RELEASE = match.group(2)
- # return config.OS_NAME, config.OS_RELEASE
- # except FileNotFoundError:
- # pass
-
- # Try reading /etc/debian_version
- # try:
- # with open('/etc/debian_version', 'r') as f:
- # config.OS_NAME = 'Debian'
- # config.OS_RELEASE = f.read().strip()
- # return config.OS_NAME, config.OS_RELEASE
- # except FileNotFoundError:
- # pass
-
- # Add more conditions for other distributions as needed
-
- # Fallback to platform module
- config.OS_NAME = distro.id() # FIXME: Not working. Returns "Linux".
- 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
-
-
-def get_package_manager():
- # Check for superuser command
- if shutil.which('sudo') is not None:
- config.SUPERUSER_COMMAND = "sudo"
- elif shutil.which('doas') is not None:
- config.SUPERUSER_COMMAND = "doas"
-
- # Check for package manager and associated packages
- if shutil.which('apt') is not None: # debian, ubuntu
- config.PACKAGE_MANAGER_COMMAND_INSTALL = "apt install -y"
- config.PACKAGE_MANAGER_COMMAND_REMOVE = "apt remove -y"
- # IDEA: Switch to Python APT library?
- # See https://github.com/FaithLife-Community/LogosLinuxInstaller/pull/33#discussion_r1443623996 # noqa: E501
- config.PACKAGE_MANAGER_COMMAND_QUERY = "dpkg -l | grep -E '^.i '"
- config.PACKAGES = "binutils cabextract fuse wget winbind"
- config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages
- config.BADPACKAGES = "appimagelauncher"
- elif shutil.which('dnf') is not None: # rhel, fedora
- config.PACKAGE_MANAGER_COMMAND_INSTALL = "dnf install -y"
- config.PACKAGE_MANAGER_COMMAND_REMOVE = "dnf remove -y"
- config.PACKAGE_MANAGER_COMMAND_QUERY = "dnf list installed | grep -E ^"
- config.PACKAGES = "patch mod_auth_ntlm_winbind samba-winbind samba-winbind-clients cabextract bc libxml2 curl" # noqa: E501
- config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages
- config.BADPACKAGES = "appiamgelauncher"
- 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_REMOVE = "pamac remove --no-confirm"
- config.PACKAGE_MANAGER_COMMAND_QUERY = "pamac list -i | grep -E ^"
- config.PACKAGES = "patch wget sed grep gawk cabextract samba bc libxml2 curl" # noqa: E501
- config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages
- config.BADPACKAGES = "appimagelauncher"
- elif shutil.which('pacman') is not None: # arch, steamOS
- config.PACKAGE_MANAGER_COMMAND_INSTALL = r"pacman -Syu --overwrite * --noconfirm --needed" # noqa: E501
- config.PACKAGE_MANAGER_COMMAND_REMOVE = r"pacman -R --no-confirm"
- config.PACKAGE_MANAGER_COMMAND_QUERY = "pacman -Q | grep -E ^"
- 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 cabextract appmenu-gtk-module patch bc 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
- config.L9PACKAGES = "" # FIXME: Missing Logos 9 Packages
- config.BADPACKAGES = "appimagelauncher"
- # Add more conditions for other package managers as needed
-
- # Add logging output.
- logging.debug(f"{config.SUPERUSER_COMMAND=}")
- 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=}")
-
-
-def get_runmode():
- if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
- return 'binary'
- else:
- return 'script'
-
-
-def query_packages(packages, mode="install"):
- if config.SKIP_DEPENDENCIES:
- return
-
- missing_packages = []
- conflicting_packages = []
-
- for p in packages:
- command = f"{config.PACKAGE_MANAGER_COMMAND_QUERY}{p}"
- logging.debug(f"pkg query command: \"{command}\"")
- result = subprocess.run(
- command,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- shell=True,
- text=True
- )
- logging.debug(f"pkg query result: {result.returncode}")
- if result.returncode != 0 and mode == "install":
- missing_packages.append(p)
- elif result.returncode == 0 and mode == "remove":
- conflicting_packages.append(p)
-
- msg = 'None'
- if mode == "install":
- if missing_packages:
- msg = f"Missing packages: {' '.join(missing_packages)}"
- logging.info(f"Missing packages: {msg}")
- return missing_packages
- if mode == "remove":
- if conflicting_packages:
- msg = f"Conflicting packages: {' '.join(conflicting_packages)}"
- logging.info(f"Conflicting packages: {msg}")
- return conflicting_packages
-
-
-def install_packages(packages):
- if config.SKIP_DEPENDENCIES:
- return
-
- if packages:
- command = f"{config.SUPERUSER_COMMAND} {config.PACKAGE_MANAGER_COMMAND_INSTALL} {' '.join(packages)}" # noqa: E501
- logging.debug(f"install_packages cmd: {command}")
- subprocess.run(command, shell=True, check=True)
-
-
-def remove_packages(packages):
- if config.SKIP_DEPENDENCIES:
- return
-
- if packages:
- command = f"{config.SUPERUSER_COMMAND} {config.PACKAGE_MANAGER_COMMAND_REMOVE} {' '.join(packages)}" # noqa: E501
- logging.debug(f"remove_packages cmd: {command}")
- subprocess.run(command, shell=True, check=True)
-
-
-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*")
- logging.info("done")
-
-
-def get_user_downloads_dir():
- home = Path.home()
- xdg_config = Path(os.getenv('XDG_CONFIG_HOME', home / '.config'))
- user_dirs_file = xdg_config / 'user-dirs.dirs'
- downloads_path = str(home / 'Downloads')
- if user_dirs_file.is_file():
- with user_dirs_file.open() as f:
- for line in f.readlines():
- if 'DOWNLOAD' in line:
- downloads_path = line.rstrip().split('=')[1].replace(
- '$HOME',
- str(home)
- ).strip('"')
- break
- return downloads_path
-
-
-def cli_download(uri, destination):
- message = f"Downloading '{uri}' to '{destination}'"
- logging.info(message)
- msg.cli_msg(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()
- args = [uri]
- kwargs = {'q': cli_queue, 'target': target}
- t = threading.Thread(target=net_get, args=args, kwargs=kwargs, daemon=True)
- t.start()
- try:
- while t.is_alive():
- if cli_queue.empty():
- continue
- write_progress_bar(cli_queue.get())
- print()
- except KeyboardInterrupt:
- print()
- msg.logos_error('Interrupted with Ctrl+C')
-
-
-def logos_reuse_download(
- SOURCEURL,
- FILE,
- TARGETDIR,
- app=None,
-):
- DIRS = [
- config.INSTALLDIR,
- os.getcwd(),
- config.MYDOWNLOADS,
- ]
- FOUND = 1
- for i in DIRS:
- if i is not None:
- logging.debug(f"Checking {i} for {FILE}.")
- file_path = Path(i) / FILE
- if os.path.isfile(file_path):
- logging.info(f"{FILE} exists in {i}. Verifying properties.")
- if verify_downloaded_file(
- SOURCEURL,
- file_path,
- app=app,
- ):
- logging.info(f"{FILE} properties match. Using it…")
- msg.cli_msg(f"Copying {FILE} into {TARGETDIR}")
- try:
- shutil.copy(os.path.join(i, FILE), TARGETDIR)
- except shutil.SameFileError:
- pass
- FOUND = 0
- break
- 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)
- if verify_downloaded_file(
- SOURCEURL,
- file_path,
- app=app,
- ):
- msg.cli_msg(f"Copying: {FILE} into: {TARGETDIR}")
- try:
- shutil.copy(os.path.join(config.MYDOWNLOADS, FILE), TARGETDIR)
- except shutil.SameFileError:
- pass
- else:
- msg.logos_error(f"Bad file size or checksum: {file_path}")
-
-
-def delete_symlink(symlink_path):
- symlink_path = Path(symlink_path)
- if symlink_path.is_symlink():
- try:
- symlink_path.unlink()
- logging.info(f"Symlink at {symlink_path} removed successfully.")
- except Exception as e:
- logging.error(f"Error removing symlink: {e}")
-
-
-def steam_preinstall_dependencies():
- subprocess.run(
- [config.SUPERUSER_COMMAND, "steamos-readonly", "disable"],
- check=True
- )
- subprocess.run(
- [config.SUPERUSER_COMMAND, "pacman-key", "--init"],
- check=True
- )
- subprocess.run(
- [config.SUPERUSER_COMMAND, "pacman-key", "--populate", "archlinux"],
- check=True
- )
-
-
-def steam_postinstall_dependencies():
- subprocess.run(
- [
- config.SUPERUSER_COMMAND,
- "sed", '-i',
- 's/mymachines resolve/mymachines mdns_minimal [NOTFOUND=return] resolve/', # noqa: E501
- '/etc/nsswitch.conf'
- ],
- check=True
- )
- subprocess.run(
- [config.SUPERUSER_COMMAND, "locale-gen"],
- check=True
- )
- subprocess.run(
- [
- config.SUPERUSER_COMMAND,
- "systemctl",
- "enable",
- "--now",
- "avahi-daemon"
- ],
- check=True
- )
- subprocess.run(
- [config.SUPERUSER_COMMAND, "systemctl", "enable", "--now", "cups"],
- check=True
- )
- subprocess.run(
- [config.SUPERUSER_COMMAND, "steamos-readonly", "enable"],
- check=True
- )
-
-
-def install_dependencies(packages, badpackages, logos9_packages=None):
- missing_packages = []
- conflicting_packages = []
- package_list = []
- if packages:
- package_list = packages.split()
- bad_package_list = []
- if badpackages:
- bad_package_list = badpackages.split()
- if logos9_packages:
- package_list.extend(logos9_packages.split())
-
- if config.PACKAGE_MANAGER_COMMAND_QUERY:
- missing_packages = query_packages(package_list)
- conflicting_packages = query_packages(bad_package_list, "remove")
-
- if config.PACKAGE_MANAGER_COMMAND_INSTALL:
- if missing_packages and conflicting_packages:
- message = f"Your {config.OS_NAME} computer requires installing and removing some software. To continue, the program will attempt to install the package(s): {missing_packages} by using ({config.PACKAGE_MANAGER_COMMAND_INSTALL}) and will remove the package(s): {conflicting_packages} by using ({config.PACKAGE_MANAGER_COMMAND_REMOVE}). Proceed?" # noqa: E501
- logging.critical(message)
- elif missing_packages:
- message = f"Your {config.OS_NAME} computer requires installing some software. To continue, the program will attempt to install the package(s): {missing_packages} by using ({config.PACKAGE_MANAGER_COMMAND_INSTALL}). Proceed?" # noqa: E501
- logging.critical(message)
- elif conflicting_packages:
- message = f"Your {config.OS_NAME} computer requires removing some software. To continue, the program will attempt to remove the package(s): {conflicting_packages} by using ({config.PACKAGE_MANAGER_COMMAND_REMOVE}). Proceed?" # noqa: E501
- logging.critical(message)
- else:
- logging.debug("No missing or conflicting dependencies found.")
-
- # 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 config.OS_NAME == "Steam":
- steam_preinstall_dependencies()
-
- # libfuse: for AppImage use. This is the only known needed library.
- check_libs(["libfuse"])
-
- if missing_packages:
- install_packages(missing_packages)
-
- if conflicting_packages:
- # AppImage Launcher is the only known conflicting package.
- remove_packages(conflicting_packages)
- config.REBOOT_REQUIRED = True
-
- if config.OS_NAME == "Steam":
- steam_postinstall_dependencies()
-
- if config.REBOOT_REQUIRED:
- # TODO: Add resumable install functionality to speed up running the
- # program after reboot. See #19.
- reboot()
-
- else:
- msg.logos_error(
- f"The script could not determine your {config.OS_NAME} install's package manager or it is unsupported. Your computer is missing the command(s) {missing_packages}. Please install your distro's package(s) associated with {missing_packages} for {config.OS_NAME}.") # noqa: E501
-
-
-def have_lib(library, ld_library_path):
- roots = ['/usr/lib', '/lib']
- if ld_library_path is not None:
- roots = [*ld_library_path.split(':'), *roots]
- for root in roots:
- libs = [lib for lib in Path(root).rglob(f"{library}*")]
- if len(libs) > 0:
- logging.debug(f"'{library}' found at '{libs[0]}'")
- return True
- return False
-
-
-def check_libs(libraries):
- ld_library_path = os.environ.get('LD_LIBRARY_PATH', '')
- for library in libraries:
- have_lib_result = have_lib(library, ld_library_path)
- if have_lib_result:
- logging.info(f"* {library} is installed!")
- else:
- if config.PACKAGE_MANAGER_COMMAND_INSTALL:
- message = f"Your {config.OS_NAME} install is missing the library: {library}. To continue, the script will attempt to install the library by using {config.PACKAGE_MANAGER_COMMAND_INSTALL}. Proceed?" # noqa: E501
- if msg.cli_continue_question(message, "", ""):
- install_packages(config.PACKAGES)
- else:
- msg.logos_error(
- f"The script could not determine your {config.OS_NAME} install's package manager or it is unsupported. Your computer is missing the library: {library}. Please install the package associated with {library} for {config.OS_NAME}.") # noqa: E501
-
-
-def check_dependencies():
- if config.TARGETVERSION:
- targetversion = int(config.TARGETVERSION)
- else:
- targetversion = 10
- logging.info(f"Checking Logos {str(targetversion)} dependencies…")
- if targetversion == 10:
- install_dependencies(config.PACKAGES, config.BADPACKAGES)
- elif targetversion == 9:
- install_dependencies(
- config.PACKAGES,
- config.BADPACKAGES,
- config.L9PACKAGES
- )
- else:
- logging.error(f"TARGETVERSION not found: {config.TARGETVERSION}.")
-
-
-def file_exists(file_path):
- if file_path is not None:
- expanded_path = os.path.expanduser(file_path)
- return os.path.isfile(expanded_path)
- else:
- return False
-
-
-def check_logos_release_version(version, threshold, check_version_part):
- version_parts = list(map(int, version.split('.')))
- return version_parts[check_version_part - 1] < threshold
-
-
-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_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.cli_msg(f"Downloading release list for {config.FLPRODUCT} {config.TARGETVERSION}…") # noqa: E501
- # NOTE: This assumes that Verbum release numbers continue to mirror Logos.
- url = f"https://clientservices.logos.com/update/v1/feed/logos{config.TARGETVERSION}/stable.xml" # noqa: E501
-
- response_xml = net_get(url)
- # if response_xml is None and None not in [q, app]:
- if response_xml is None:
- if app:
- app.releases_q.put(None)
- app.root.event_generate(app.release_evt)
- return None
-
- # Parse XML
- root = ET.fromstring(response_xml)
-
- # Define namespaces
- namespaces = {
- 'ns0': 'http://www.w3.org/2005/Atom',
- 'ns1': 'http://services.logos.com/update/v1/'
- }
-
- # Extract versions
- releases = []
- # Obtain all listed releases.
- for entry in root.findall('.//ns1:version', namespaces):
- release = entry.text
- releases.append(release)
- # if len(releases) == 5:
- # break
-
- filtered_releases = filter_versions(releases, 30, 1)
- logging.debug(f"Available releases: {', '.join(releases)}")
- logging.debug(f"Filtered releases: {', '.join(filtered_releases)}")
-
- if app:
- app.releases_q.put(filtered_releases)
- app.root.event_generate(app.release_evt)
- return filtered_releases
-
-
-def get_winebin_code_and_desc(binary):
- # Set binary code, description, and path based on path
- codes = {
- "Recommended": "Use the recommended AppImage",
- "AppImage": "AppImage of Wine64",
- "System": "Use the system binary (i.e., /usr/bin/wine64). WINE must be 7.18-staging or later, or 8.16-devel or later, and cannot be version 8.0.", # noqa: E501
- "Proton": "Install using the Steam Proton fork of WINE.",
- "PlayOnLinux": "Install using a PlayOnLinux WINE64 binary.",
- "Custom": "Use a WINE64 binary from another directory.",
- }
- # TODO: The GUI currently cannot distinguish between the recommended
- # AppImage and another on the system. We need to add some manner of making
- # this distinction in the GUI, which is why the wine binary codes exist.
- # Currently the GUI only accept an array with a single element, the binary
- # itself; this will need to be modified to a two variable array, at the
- # least, even if we hide the wine binary code, but it might be useful to
- # tell the GUI user that a particular AppImage/binary is recommended.
- # Below is my best guess for how to do this with the single element array…
- # Does it work?
- if binary == f"{config.APPDIR_BINDIR}/{config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME}": # noqa: E501
- code = "Recommended"
- elif binary.lower().endswith('.appimage'):
- code = "AppImage"
- elif "/usr/bin/" in binary:
- code = "System"
- elif "Proton" in binary:
- code = "Proton"
- elif "PlayOnLinux" in binary:
- code = "PlayOnLinux"
- else:
- code = "Custom"
- desc = codes.get(code)
- logging.debug(f"{binary} code & desc: {code}; {desc}")
- return code, desc
-
-
-def get_wine_options(appimages, binaries, app=None) -> Union[List[List[str]], List[str]]: # noqa: E501
- logging.debug(f"{appimages=}")
- logging.debug(f"{binaries=}")
- wine_binary_options = []
-
- # 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)
-
- 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
-
- # 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."])
-
- logging.debug(f"{wine_binary_options=}")
- if app:
- app.wines_q.put(wine_binary_options)
- app.root.event_generate(app.wine_evt)
- return wine_binary_options
-
-
-def get_winetricks_options():
- local_winetricks_path = shutil.which('winetricks')
- 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) >= "20220411":
- winetricks_options.insert(0, local_winetricks_path)
- else:
- logging.info("Local winetricks is too old.")
- else:
- logging.info("Local winetricks not found.")
- return winetricks_options
-
-
-def install_winetricks(
- installdir,
- app=None,
- version=config.WINETRICKS_VERSION,
-):
- msg.cli_msg(f"Installing winetricks v{version}…")
- base_url = "https://codeload.github.com/Winetricks/winetricks/zip/refs/tags" # noqa: E501
- zip_name = f"{version}.zip"
- logos_reuse_download(
- f"{base_url}/{version}",
- zip_name,
- config.MYDOWNLOADS,
- app=app,
- )
- wtzip = f"{config.MYDOWNLOADS}/{zip_name}"
- logging.debug(f"Extracting winetricks script into {installdir}…")
- with zipfile.ZipFile(wtzip) as z:
- for zi in z.infolist():
- if zi.is_dir():
- continue
- zi.filename = Path(zi.filename).name
- if zi.filename == 'winetricks':
- z.extract(zi, path=installdir)
- break
- os.chmod(f"{installdir}/winetricks", 0o755)
- logging.debug("Winetricks installed.")
-
-
-def get_pids_using_file(file_path, mode=None):
- pids = set()
- for proc in psutil.process_iter(['pid', 'open_files']):
- 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()]
- if len(paths) > 0 and file_path in paths:
- pids.add(proc.pid)
- except psutil.AccessDenied:
- pass
- return pids
-
-
-def wait_process_using_dir(directory):
- logging.info(f"* Starting wait_process_using_dir for {directory}…")
-
- # Get pids and wait for them to finish.
- pids = get_pids_using_file(directory)
- for pid in pids:
- logging.info(f"wait_process_using_dir PID: {pid}")
- psutil.wait(pid)
-
- logging.info("* End of wait_process_using_dir.")
-
-
-def net_get(url, target=None, app=None, evt=None, q=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('<>')
- 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
-
- # Initialize variables.
- local_size = 0
- total_size = url.size # None or int
- logging.debug(f"File size on server: {total_size}")
- percent = None
- chunk_size = 100 * 1024 # 100 KB default
- if type(total_size) is int:
- # Use smaller of 2% of filesize or 2 MB for chunk_size.
- chunk_size = min([int(total_size / 50), 2 * 1024 * 1024])
- # Force non-compressed file transfer for accurate progress tracking.
- headers = {'Accept-Encoding': 'identity'}
- 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()
- logging.info(f"Current downloaded size in bytes: {local_size}")
- if url.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:
- headers['Range'] = f'bytes={local_size}-{total_size}'
- else:
- headers['Range'] = f'bytes={local_size}-'
-
- logging.debug(f"{chunk_size=}; {file_mode=}; {headers=}")
-
- # Log download type.
- if 'Range' in headers.keys():
- message = f"Continuing download for {url.path}."
- else:
- message = f"Starting new download for {url.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:
- if callable(r):
- logging.error("Failed to retrieve data from the URL.")
- return None
-
- try:
- r.raise_for_status()
- except requests.exceptions.HTTPError as e:
- if domain == "github.com":
- if (
- e.response.status_code == 403
- or e.response.status_code == 429
- ):
- logging.error("GitHub API rate limit exceeded. Please wait before trying again.") # noqa: E501
- else:
- logging.error(f"HTTP error occurred: {e.response.status_code}") # noqa: E501
- return None
-
- return r.text
- 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:
- if file_mode == 'wb':
- mode_text = 'Writing'
- else:
- mode_text = 'Appending'
- logging.debug(f"{mode_text} data to file {target.path}.")
- for chunk in r.iter_content(chunk_size=chunk_size):
- f.write(chunk)
- local_size = target.get_size()
- if type(total_size) is int:
- percent = round(local_size / total_size * 100)
- # 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)
- 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:
- app.root.event_generate('<>')
- app.status_q.put(f"Verifying {file_path}…")
- app.root.event_generate('<>')
- res = False
- msg = f"{file_path} is the wrong size."
- right_size = same_size(url, file_path)
- if right_size:
- msg = f"{file_path} has the wrong MD5 sum."
- right_md5 = same_md5(url, file_path)
- if right_md5:
- msg = f"{file_path} is verified."
- res = True
- logging.info(msg)
- if app:
- 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 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
- 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():
- if config.FLPRODUCT and config.WINEPREFIX:
- drive_c = Path(f"{config.WINEPREFIX}/drive_c/")
- name = config.FLPRODUCT
- 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
-
-
-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 enough_disk_space(dest_dir, bytes_required):
- free_bytes = shutil.disk_usage(dest_dir).free
- logging.debug(f"{free_bytes=}; {bytes_required=}")
- return free_bytes > bytes_required
-
-
-def get_path_size(file_path):
- file_path = Path(file_path)
- if not file_path.exists():
- path_size = None
- else:
- path_size = sum(f.stat().st_size for f in file_path.rglob('*')) + file_path.stat().st_size # noqa: E501
- return path_size
-
-
-def get_folder_group_size(src_dirs, q):
- src_size = 0
- for d in src_dirs:
- if not d.is_dir():
- continue
- src_size += get_path_size(d)
- 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:
- logging.warning(f"No folders found in {folder_path}")
- return None
- folders.sort()
- logging.info(f"Found {len(folders)} backup folders.")
- latest = folders[-1]
- logging.info(f"Latest folder: {latest}")
- return latest
-
-
-def get_latest_release_data(releases_url):
- data = net_get(releases_url)
- if data:
- try:
- json_data = json.loads(data)
- logging.debug(f"{json_data=}")
- except json.JSONDecodeError as e:
- logging.error(f"Error decoding JSON response: {e}")
- return None
-
- if not isinstance(json_data, list) or len(json_data) == 0:
- logging.error("Invalid or empty JSON response.")
- return None
- else:
- return json_data
- else:
- logging.critical("Could not get latest release URL.")
- return None
-
-
-def get_latest_release_url(json_data):
- release_url = None
- if json_data:
- release_url = json_data[0].get('assets')[0].get('browser_download_url') # noqa: E501
- logging.info(f"Release URL: {release_url}")
- return release_url
-
-
-def get_latest_release_version_tag_name(json_data):
- release_tag_name = None
- if json_data:
- release_tag_name = json_data[0].get('tag_name') # noqa: E501
- logging.info(f"Release URL Tag Name: {release_tag_name}")
- return release_tag_name
-
-
-def set_logoslinuxinstaller_latest_release_config():
- releases_url = "https://api.github.com/repos/FaithLife-Community/LogosLinuxInstaller/releases" # noqa: E501
- json_data = get_latest_release_data(releases_url)
- logoslinuxinstaller_url = get_latest_release_url(json_data)
- logoslinuxinstaller_tag_name = get_latest_release_version_tag_name(json_data) # noqa: E501
- if logoslinuxinstaller_url is None:
- logging.critical("Unable to set LogosLinuxInstaller 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 = logoslinuxinstaller_tag_name.lstrip('v')
- logging.info(f"{config.LLI_LATEST_VERSION}")
-
-
-def set_recommended_appimage_config():
- releases_url = "https://api.github.com/repos/FaithLife-Community/wine-appimages/releases" # noqa: E501
- if not config.RECOMMENDED_WINE64_APPIMAGE_URL:
- json_data = get_latest_release_data(releases_url)
- appimage_url = get_latest_release_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.
-
- 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()
- set_recommended_appimage_config()
-
- config.LAST_UPDATED = now.isoformat()
- write_config(config.CONFIG_FILE)
- else:
- logging.debug("Skipping self-update.")
-
-
-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
- 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 install_premade_wine_bottle(srcdir, appdir):
- msg.cli_msg(f"Extracting: '{config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME}' into: {appdir}") # noqa: E501
- shutil.unpack_archive(
- f"{srcdir}/{config.LOGOS9_WINE64_BOTTLE_TARGZ_NAME}",
- appdir
- )
-
-
-def compare_logos_linux_installer_version():
- if (
- config.LLI_CURRENT_VERSION is not None
- and config.LLI_LATEST_VERSION is not None
- ):
- logging.debug(f"{config.LLI_CURRENT_VERSION=}; {config.LLI_LATEST_VERSION=}") # noqa: E501
- if (
- version.parse(config.LLI_CURRENT_VERSION)
- < version.parse(config.LLI_LATEST_VERSION)
- ):
- # Current release is older than recommended.
- status = 0
- message = "yes"
- elif (
- version.parse(config.LLI_CURRENT_VERSION)
- == version.parse(config.LLI_LATEST_VERSION)
- ):
- # Current release is latest.
- status = 1
- message = "uptodate"
- elif (
- version.parse(config.LLI_CURRENT_VERSION)
- > version.parse(config.LLI_LATEST_VERSION)
- ):
- # Installed version is custom.
- status = 2
- message = "no"
- else:
- status = False
- message = "config.LLI_CURRENT_VERSION or config.LLI_LATEST_VERSION is not set." # noqa: E501
-
- logging.debug(f"{status=}; {message=}")
- return status, message
-
-
-def compare_recommended_appimage_version():
- wine_release = []
- if config.WINE_EXE is not None:
- wine_release, error_message = wine.get_wine_release(config.WINE_EXE)
- 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}"
- else:
- status = False
- message = "config.WINE_EXE is not set."
-
- logging.debug(f"{status=}; {message=}")
- return status, message
-
-
-def update_lli_binary(app=None):
- lli_file_path = os.path.realpath(sys.argv[0])
- lli_download_path = Path(config.MYDOWNLOADS) / "LogosLinuxInstaller"
- temp_path = Path(config.MYDOWNLOADS) / "LogosLinuxInstaller.tmp"
- logging.debug(f"Updating Logos Linux Installer 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 = get_lli_release_version(lli_download_path)
- if not lli_download_ver or lli_download_ver != config.LLI_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,
- "LogosLinuxInstaller",
- config.MYDOWNLOADS,
- app=app,
- )
- shutil.copy(lli_download_path, temp_path)
- try:
- shutil.move(temp_path, lli_file_path)
- except Exception as e:
- logging.error(f"Failed to replace the binary: {e}")
- return
-
- os.chmod(sys.argv[0], os.stat(sys.argv[0]).st_mode | 0o111)
- logging.debug("Successfully updated Logos Linux Installer.")
- restart_lli()
-
-
-def get_lli_release_version(lli_binary):
- lli_version = None
- # Ensure user-executable by adding 0o001.
- st = lli_binary.stat()
- os.chmod(lli_binary, mode=st.st_mode | stat.S_IXUSR)
- # Get version number.
- cmd = [lli_binary, '--version']
- vstr = subprocess.check_output(cmd, text=True)
- m = re.search(r'\d+\.\d+\.\d+(-[a-z]+\.\d+)?', vstr)
- if m:
- lli_version = m[0]
- return lli_version
-
-
-def is_appimage(file_path):
- # Ref:
- # - https://cgit.freedesktop.org/xdg/shared-mime-info/commit/?id=c643cab25b8a4ea17e73eae5bc318c840f0e3d4b # noqa: E501
- # - https://github.com/AppImage/AppImageSpec/blob/master/draft.md#image-format # noqa: E501
- # Note:
- # result is a tuple: (is AppImage: True|False, AppImage type: 1|2|None)
- # result = (False, None)
- expanded_path = Path(file_path).expanduser().resolve()
- logging.debug(f"Converting path to expanded_path: {expanded_path}")
- if file_exists(expanded_path):
- logging.debug(f"{expanded_path} exists!")
- with file_path.open('rb') as f:
- f.seek(1)
- elf_sig = f.read(3)
- f.seek(8)
- ai_sig = f.read(2)
- f.seek(10)
- v_sig = f.read(1)
-
- appimage_check = elf_sig == b'ELF' and ai_sig == b'AI'
- appimage_type = int.from_bytes(v_sig)
-
- return (appimage_check, appimage_type)
- else:
- return (False, None)
-
-
-def check_appimage(filestr):
- logging.debug(f"Checking if {filestr} is a usable AppImage.")
- if filestr is None:
- logging.error("check_appimage: received None for file.")
- return False
-
- file_path = Path(filestr)
-
- appimage, appimage_type = is_appimage(file_path)
- if appimage:
- logging.debug("It is an AppImage!")
- if appimage_type == 1:
- logging.error(f"{file_path}: Can't handle AppImage version {str(appimage_type)} yet.") # noqa: E501
- return False
- else:
- logging.debug("It is a usable AppImage!")
- return True
- else:
- logging.debug("It is not an AppImage!")
- return False
-
-
-def find_appimage_files(app=None):
- appimages = []
- directories = [
- os.path.expanduser("~") + "/bin",
- config.APPDIR_BINDIR,
- config.MYDOWNLOADS
- ]
- if config.CUSTOMBINPATH is not None:
- directories.append(config.CUSTOMBINPATH)
-
- if sys.version_info < (3, 12):
- raise RuntimeError("Python 3.12 or higher is required for .rglob() flag `case-sensitive` ") # noqa: E501
-
- for d in directories:
- appimage_paths = Path(d).rglob('wine*.appimage', case_sensitive=False)
- for p in appimage_paths:
- if p is not None and check_appimage(p):
- output1, output2 = wine.check_wine_version_and_branch(p)
- 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():
- wine_binary_path_list = [
- "/usr/local/bin",
- os.path.expanduser("~") + "/bin",
- os.path.expanduser("~") + "/PlayOnLinux/wine/linux-amd64/*/bin",
- 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)
-
- # 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
-
- # Check each directory in PATH for wine64; add to list
- binaries = []
- paths = os.environ["PATH"].split(":")
- for path in paths:
- binary_path = os.path.join(path, "wine64")
- if os.path.exists(binary_path) and os.access(binary_path, os.X_OK):
- binaries.append(binary_path)
-
- for binary in binaries[:]:
- output1, output2 = wine.check_wine_version_and_branch(binary)
- if output1 is not None and output1:
- continue
- else:
- binaries.remove(binary)
- logging.info(f"Removing binary: {binary} because: {output2}")
-
- return binaries
-
-
-def set_appimage_symlink(app=None):
- # 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=}")
- # if config.APPIMAGE_FILE_PATH == config.RECOMMENDED_WINE64_APPIMAGE_FULL_FILENAME: # noqa: E501
- # get_recommended_appimage()
- # selected_appimage_file_path = Path(config.APPDIR_BINDIR) / config.APPIMAGE_FILE_PATH # noqa: E501
- # else:
- # selected_appimage_file_path = Path(config.APPIMAGE_FILE_PATH)
- selected_appimage_file_path = Path(config.SELECTED_APPIMAGE_FILENAME)
-
- if not check_appimage(selected_appimage_file_path):
- logging.warning(f"Cannot use {selected_appimage_file_path}.")
- return
-
- copy_message = (
- f"Should the program copy {selected_appimage_file_path} to the"
- f" {config.APPDIR_BINDIR} directory?"
- )
-
- # Determine if user wants their AppImage in the Logos on Linux bin dir.
- if selected_appimage_file_path.exists():
- confirm = False
- else:
- 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()
- else:
- confirm = tui.confirm("Confirmation", copy_message)
- # FIXME: What if user cancels the confirmation dialog?
-
- appimage_symlink_path = Path(f"{config.APPDIR_BINDIR}/{config.APPIMAGE_LINK_SELECTION_NAME}") # noqa: E501
- delete_symlink(appimage_symlink_path)
-
- # FIXME: confirm is always False b/c appimage_filepath always exists b/c
- # it's copied in place via logos_reuse_download function above in
- # get_recommended_appimage.
- appimage_filename = selected_appimage_file_path.name
- if confirm is True or confirm == 'yes':
- logging.info(f"Copying {selected_appimage_file_path} to {config.APPDIR_BINDIR}.") # noqa: E501
- shutil.copy(selected_appimage_file_path, f"{config.APPDIR_BINDIR}")
- os.symlink(selected_appimage_file_path, appimage_symlink_path)
- config.SELECTED_APPIMAGE_FILENAME = f"{appimage_filename}"
- # If not, use the selected AppImage's full path for link creation.
- elif confirm is False or confirm == 'no':
- logging.debug(f"{selected_appimage_file_path} already exists in {config.APPDIR_BINDIR}. No need to copy.") # noqa: E501
- os.symlink(selected_appimage_file_path, appimage_symlink_path)
- logging.debug("AppImage symlink updated.")
- config.SELECTED_APPIMAGE_FILENAME = f"{selected_appimage_file_path}"
- logging.debug("Updated config with new AppImage path.")
- else:
- logging.error("Error getting user confirmation.")
-
- write_config(config.CONFIG_FILE)
- if app:
- app.root.event_generate("<>")
-
-
-def update_to_latest_lli_release(app=None):
- status, _ = compare_logos_linux_installer_version()
-
- if get_runmode() != 'binary':
- logging.error("Can't update LogosLinuxInstaller when run as a script.")
- elif status == 0:
- 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()
- if status == 0:
- set_appimage_symlink()
- 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):
- dirs = [
- config.MYDOWNLOADS,
- Path.home(),
- Path.cwd(),
- ]
- for d in dirs:
- file_path = Path(d) / filename
- if file_path.is_file():
- logging.info(f"'{filename}' exists in {str(d)}.")
- return str(file_path)
- logging.debug(f"File not found: {filename}")
diff --git a/wine.py b/wine.py
deleted file mode 100644
index b1dde802..00000000
--- a/wine.py
+++ /dev/null
@@ -1,429 +0,0 @@
-import logging
-import os
-import psutil
-import re
-import signal
-import subprocess
-import time
-from pathlib import Path
-
-import config
-import msg
-import utils
-
-
-def get_pids_using_file(file_path, mode=None):
- # Make list (set) of pids using 'directory'.
- pids = set()
- for proc in psutil.process_iter(['pid', 'open_files']):
- 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()]
- if len(paths) > 0 and file_path in paths:
- pids.add(proc.pid)
- except psutil.AccessDenied:
- pass
- return pids
-
-
-def wait_on(command):
- try:
- # Start the process in the background
- process = subprocess.Popen(
- command,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True
- )
- msg.cli_msg(f"Waiting on \"{' '.join(command)}\" to finish.", end='')
- time.sleep(1.0)
- while process.poll() is None:
- msg.logos_progress()
- time.sleep(0.5)
- print()
-
- # Process has finished, check the result
- stdout, stderr = process.communicate()
-
- if process.returncode == 0:
- logging.info(f"\"{' '.join(command)}\" has ended properly.")
- else:
- logging.error(f"Error: {stderr}")
-
- except Exception as e:
- logging.critical(f"{e}")
-
-
-def light_wineserver_wait():
- command = [f"{config.WINESERVER_EXE}", "-w"]
- wait_on(command)
-
-
-def heavy_wineserver_wait():
- utils.wait_process_using_dir(config.WINEPREFIX)
- wait_on([f"{config.WINESERVER_EXE}", "-w"])
-
-
-def get_wine_release(binary):
- cmd = [binary, "--version"]
- try:
- version_string = subprocess.check_output(cmd, encoding='utf-8').strip()
- logging.debug(f"Version string: {str(version_string)}")
- try:
- version, release = version_string.split()
- except ValueError:
- # Neither "Devel" nor "Stable" release is noted in version output
- version = version_string
- release = get_wine_branch(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]
- release = release.lstrip('(').rstrip(')').lower() # remove parens
- else:
- ver_major = 0
- ver_minor = 0
-
- wine_release = [int(ver_major), int(ver_minor), release]
- logging.debug(f"Wine release of {binary}: {str(wine_release)}")
-
- if ver_major == 0:
- return False, "Couldn't determine wine version."
- else:
- return wine_release, "yes"
-
- except subprocess.CalledProcessError as e:
- return False, f"Error running command: {e}"
-
- except ValueError as e:
- return False, f"Error parsing version: {e}"
-
- except Exception as e:
- return False, f"Error: {e}"
-
-
-def check_wine_version_and_branch(TESTBINARY):
- # Does not check for Staging. Will not implement: expecting merging of
- # commits in time.
- if config.TARGETVERSION == "10":
- WINE_MINIMUM = [7, 18]
- elif config.TARGETVERSION == "9":
- WINE_MINIMUM = [7, 0]
- else:
- raise ValueError("TARGETVERSION not set.")
-
- # Check if the binary is executable. If so, check if TESTBINARY's version
- # is ≥ WINE_MINIMUM, or if it is Proton or a link to a Proton binary, else
- # remove.
- if not os.path.exists(TESTBINARY):
- reason = "Binary does not exist."
- return False, reason
-
- if not os.access(TESTBINARY, os.X_OK):
- reason = "Binary is not executable."
- return False, reason
-
- wine_release = []
- wine_release, error_message = get_wine_release(TESTBINARY)
-
- if wine_release is not False and error_message is not None:
- if wine_release[2] == 'stable':
- return False, "Can't use Stable release"
- elif wine_release[0] < 7:
- return False, "Version is < 7.0"
- elif wine_release[0] < 8:
- if (
- "Proton" in TESTBINARY
- or ("Proton" in os.path.realpath(TESTBINARY) if os.path.islink(TESTBINARY) else False) # noqa: E501
- ):
- if wine_release[1] == 0:
- return True, "None"
- elif wine_release[2] != 'staging':
- return False, "Needs to be Staging release"
- elif wine_release[1] < WINE_MINIMUM[1]:
- reason = f"{'.'.join(wine_release)} is below minimum required, {'.'.join(WINE_MINIMUM)}" # noqa: E501
- return False, reason
- elif wine_release[0] < 9:
- if wine_release[1] < 1:
- return False, "Version is 8.0"
- elif wine_release[1] < 16:
- if wine_release[2] != 'staging':
- return False, "Version < 8.16 needs to be Staging release"
- else:
- return False, error_message
-
- return True, "None"
-
-
-def initializeWineBottle(app=None):
- msg.cli_msg("Initializing wine bottle...")
-
- # Avoid wine-mono window
- orig_overrides = config.WINEDLLOVERRIDES
- config.WINEDLLOVERRIDES = f"{config.WINEDLLOVERRIDES};mscoree="
- run_wine_proc(config.WINE_EXE, exe='wineboot', exe_args=['--init'])
- config.WINEDLLOVERRIDES = orig_overrides
- light_wineserver_wait()
-
-
-def wine_reg_install(REG_FILE):
- msg.cli_msg(f"Installing registry file: {REG_FILE}")
- env = get_wine_env()
- p = subprocess.run(
- [config.WINE_EXE, "regedit.exe", REG_FILE],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- env=env,
- text=True,
- cwd=config.WORKDIR,
- )
- if p.returncode == 0:
- logging.info(f"{REG_FILE} installed.")
- elif p.returncode != 0:
- msg.logos_error(f"Failed to install reg file: {REG_FILE}")
- light_wineserver_wait()
-
-
-def install_msi():
- msg.cli_msg(f"Running MSI installer: {config.LOGOS_EXECUTABLE}.")
- # Execute the .MSI
- exe_args = ["/i", f"{config.INSTALLDIR}/data/{config.LOGOS_EXECUTABLE}"]
- if config.PASSIVE is True:
- exe_args.append('/passive')
- logging.info(f"Running: {config.WINE_EXE} msiexec {' '.join(exe_args)}")
- run_wine_proc(config.WINE_EXE, exe="msiexec", exe_args=exe_args)
-
-
-def run_wine_proc(winecmd, exe=None, exe_args=list()):
- env = get_wine_env()
- if config.WINECMD_ENCODING is None:
- # Get wine system's cmd.exe encoding for proper decoding to UTF8 later.
- codepages = get_registry_value('HKCU\\Software\\Wine\\Fonts', 'Codepages').split(',') # noqa: E501
- config.WINECMD_ENCODING = codepages[-1]
- logging.debug(f"run_wine_proc: {winecmd}; {exe=}; {exe_args=}")
- wine_env_vars = {k: v for k, v in env.items() if k.startswith('WINE')}
- logging.debug(f"wine environment: {wine_env_vars}")
-
- command = [winecmd]
- if exe is not None:
- command.append(exe)
- if exe_args:
- command.extend(exe_args)
- logging.debug(f"subprocess cmd: '{' '.join(command)}'")
-
- try:
- process = subprocess.Popen(
- command,
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- env=env
- )
- with process.stdout:
- for line in iter(process.stdout.readline, b''):
- if winecmd.endswith('winetricks'):
- logging.debug(line.decode('cp437').rstrip())
- else:
- try:
- logging.info(line.decode().rstrip())
- except UnicodeDecodeError:
- logging.info(line.decode(config.WINECMD_ENCODING).rstrip()) # noqa: E501
- returncode = process.wait()
-
- if returncode != 0:
- logging.error(f"Error running '{' '.join(command)}': {process.returncode}") # noqa: E501
-
- except subprocess.CalledProcessError as e:
- logging.error(f"Exception running '{' '.join(command)}': {e}")
-
-
-def run_winetricks(cmd=None):
- run_wine_proc(config.WINETRICKSBIN, exe=cmd)
- run_wine_proc(config.WINESERVER_EXE, exe_args=["-w"])
-
-
-def winetricks_install(*args):
- cmd = [*args]
- msg.cli_msg(f"Running winetricks \"{args[-1]}\"")
- logging.info(f"running \"winetricks {' '.join(cmd)}\"")
- run_wine_proc(config.WINETRICKSBIN, exe_args=cmd)
- logging.info(f"\"winetricks {' '.join(cmd)}\" DONE!")
- heavy_wineserver_wait()
-
-
-def installFonts():
- msg.cli_msg("Configuring fonts...")
- fonts = ['corefonts', 'tahoma']
- if not config.SKIP_FONTS:
- for f in fonts:
- args = [f]
- if config.WINETRICKS_UNATTENDED:
- args.insert(0, '-q')
- winetricks_install(*args)
-
- winetricks_install('-q', 'settings', 'fontsmooth=rgb')
-
-
-def installD3DCompiler():
- cmd = ['d3dcompiler_47']
- if config.WINETRICKS_UNATTENDED is None:
- cmd.insert(0, '-q')
- winetricks_install(*cmd)
-
-
-def get_registry_value(reg_path, name):
- value = None
- env = get_wine_env()
- cmd = [config.WINE_EXE, 'reg', 'query', reg_path, '/v', name]
- stdout = subprocess.run(
- cmd, capture_output=True,
- text=True, encoding=config.WINECMD_ENCODING,
- env=env).stdout
- for line in stdout.splitlines():
- if line.strip().startswith(name):
- value = line.split()[-1].strip()
- break
- return value
-
-
-def get_app_logging_state(app=None, init=False):
- state = 'DISABLED'
- current_value = get_registry_value(
- 'HKCU\\Software\\Logos4\\Logging',
- 'Enabled'
- )
- if current_value == '0x1':
- state = 'ENABLED'
- if app is not None:
- app.logging_q.put(state)
- if init:
- app.root.event_generate('<>')
- else:
- app.root.event_generate('<>')
- return state
-
-
-def switch_logging(action=None, app=None):
- state_disabled = 'DISABLED'
- value_disabled = '0000'
- state_enabled = 'ENABLED'
- value_enabled = '0001'
- if action == 'disable':
- value = value_disabled
- state = state_disabled
- elif action == 'enable':
- value = value_enabled
- state = state_enabled
- else:
- current_state = get_app_logging_state()
- logging.debug(f"app logging {current_state=}")
- if current_state == state_enabled:
- value = value_disabled
- state = state_disabled
- else:
- value = value_enabled
- state = state_enabled
-
- logging.info(f"Setting app logging to '{state}'.")
- exe_args = [
- 'add', 'HKCU\\Software\\Logos4\\Logging', '/v', 'Enabled',
- '/t', 'REG_DWORD', '/d', value, '/f'
- ]
- run_wine_proc(config.WINE_EXE, exe='reg', exe_args=exe_args)
- run_wine_proc(config.WINESERVER_EXE, exe_args=['-w'])
- config.LOGS = state
- if app is not None:
- app.logging_q.put(state)
- app.root.event_generate(app.logging_event)
-
-
-def get_mscoree_winebranch(mscoree_file):
- try:
- with mscoree_file.open('rb') as f:
- for line in f:
- m = re.search(rb'wine-[a-z]+', line)
- if m is not None:
- return m[0].decode().lstrip('wine-')
- except FileNotFoundError as e:
- logging.error(e)
-
-
-def get_wine_branch(binary):
- logging.info(f"Determining wine branch of '{binary}'")
- binary_obj = Path(binary).expanduser().resolve()
- if utils.check_appimage(binary_obj):
- logging.debug(f"Mounting AppImage: {binary_obj}")
- # Mount appimage to inspect files.
- p = subprocess.Popen(
- [binary_obj, '--appimage-mount'],
- stdout=subprocess.PIPE,
- encoding='UTF8'
- )
- while p.returncode is None:
- for line in p.stdout:
- if line.startswith('/tmp'):
- tmp_dir = Path(line.rstrip())
- for f in tmp_dir.glob('**/lib64/**/mscoree.dll'):
- branch = get_mscoree_winebranch(f)
- break
- p.send_signal(signal.SIGINT)
- p.poll()
- return branch
- else:
- logging.debug("Binary object is not an AppImage.")
- logging.info(f"'{binary}' resolved to '{binary_obj}'")
- mscoree64 = binary_obj.parents[1] / 'lib64' / 'wine' / 'x86_64-windows' / 'mscoree.dll' # noqa: E501
- return get_mscoree_winebranch(mscoree64)
-
-
-def get_wine_env():
- wine_env = os.environ.copy()
- winepath = Path(config.WINE_EXE)
- 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'
- wine_env_defaults = {
- 'WINE': str(winepath),
- 'WINE_EXE': config.WINE_EXE,
- 'WINEDEBUG': config.WINEDEBUG,
- 'WINEDLLOVERRIDES': config.WINEDLLOVERRIDES,
- 'WINELOADER': str(winepath),
- 'WINEPREFIX': config.WINEPREFIX,
- 'WINETRICKS_SUPER_QUIET': '',
- }
- 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
-
- return wine_env
-
-
-def run_logos():
- run_wine_proc(config.WINE_EXE, exe=config.LOGOS_EXE)
- run_wine_proc(config.WINESERVER_EXE, exe_args=["-w"])
-
-
-def run_indexing():
- for root, dirs, files in os.walk(os.path.join(config.WINEPREFIX, "drive_c")): # noqa: E501
- for f in files:
- if f == "LogosIndexer.exe" and root.endswith("Logos/System"):
- logos_indexer_exe = os.path.join(root, f)
- break
-
- run_wine_proc(config.WINESERVER_EXE, exe_args=["-k"])
- run_wine_proc(config.WINE_EXE, exe=logos_indexer_exe)
- run_wine_proc(config.WINESERVER_EXE, exe_args=["-w"])