diff options
Diffstat (limited to 'libs/installer_wizard')
27 files changed, 3230 insertions, 0 deletions
diff --git a/libs/installer_wizard/.gitattributes b/libs/installer_wizard/.gitattributes new file mode 100644 index 0000000..9790024 --- /dev/null +++ b/libs/installer_wizard/.gitattributes @@ -0,0 +1,6 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto + +# Explicitly declare text files you want to always be normalized and converted +# to native line endings on checkout. +*.py text eol=crlf diff --git a/libs/installer_wizard/.github/workflows/build.yml b/libs/installer_wizard/.github/workflows/build.yml new file mode 100644 index 0000000..c486f62 --- /dev/null +++ b/libs/installer_wizard/.github/workflows/build.yml @@ -0,0 +1,39 @@ +name: Build Installer Wizard Plugin + +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + build: + runs-on: windows-2022 + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set environmental variables + shell: bash + run: | + echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> $GITHUB_ENV + + - uses: actions/checkout@v4 + + - name: Configure Installer Wizard Plugin build + shell: pwsh + run: | + cmake --preset vs2022-windows "-DCMAKE_INSTALL_PREFIX=install" "-DVCPKG_MANIFEST_FEATURES=standalone" + + - name: Build Installer Wizard Plugin + run: cmake --build vsbuild --config RelWithDebInfo + + - name: Install Installer Wizard Plugin + run: cmake --install vsbuild --config RelWithDebInfo + + - name: Upload Installer Wizard Plugin artifact + uses: actions/upload-artifact@master + with: + name: installer_wizard + path: ./install/bin/plugins diff --git a/libs/installer_wizard/.github/workflows/linters.yml b/libs/installer_wizard/.github/workflows/linters.yml new file mode 100644 index 0000000..95f45e6 --- /dev/null +++ b/libs/installer_wizard/.github/workflows/linters.yml @@ -0,0 +1,32 @@ +name: linters + +on: [push, pull_request] + +jobs: + checks: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: [3.12] + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - uses: abatilo/actions-poetry@v2 + - name: Install package + run: poetry install + + # generate UI file for linting + - name: Generate UI files + shell: pwsh + run: | + Get-ChildItem -Recurse -File -Include "*.ui" | ForEach-Object { + poetry run pyuic6 $_ -o ([io.path]::ChangeExtension($_.FullName, "py")) + } + + - name: Lint + run: poetry run poe lint diff --git a/libs/installer_wizard/.gitignore b/libs/installer_wizard/.gitignore new file mode 100644 index 0000000..b3abdd9 --- /dev/null +++ b/libs/installer_wizard/.gitignore @@ -0,0 +1,20 @@ +# Generated UI files - The __init__.py is also exclude but it has +# been added so should be fine: +src/ui/*.py + +# IDE / Tools files: +**/__pycache__ +**/.mypy_cache +.tox +.vscode +venv + +# MO2 build files: +vsbuild +mob.log + +# Generated files: +lib +src/lib +installer_wizard +installer_wizard-*.zip diff --git a/libs/installer_wizard/.pre-commit-config.yaml b/libs/installer_wizard/.pre-commit-config.yaml new file mode 100644 index 0000000..047c78c --- /dev/null +++ b/libs/installer_wizard/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: mixed-line-ending + args: [--fix=lf] + - id: check-case-conflict + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.7 # must match pyproject.toml + hooks: + - id: ruff + args: [--extend-select, I, --fix] + - id: ruff-format + +ci: + autofix_commit_msg: "[pre-commit.ci] Auto fixes from pre-commit.com hooks." + autofix_prs: true + autoupdate_commit_msg: "[pre-commit.ci] Pre-commit autoupdate." + autoupdate_schedule: quarterly + submodules: false diff --git a/libs/installer_wizard/CMakeLists.txt b/libs/installer_wizard/CMakeLists.txt new file mode 100644 index 0000000..2fb2ad5 --- /dev/null +++ b/libs/installer_wizard/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.16) + +project(installer_wizard LANGUAGES NONE) + +find_package(mo2-cmake CONFIG REQUIRED) + +add_subdirectory(src) diff --git a/libs/installer_wizard/CMakePresets.json b/libs/installer_wizard/CMakePresets.json new file mode 100644 index 0000000..acc6d97 --- /dev/null +++ b/libs/installer_wizard/CMakePresets.json @@ -0,0 +1,17 @@ +{ + "configurePresets": [ + { + "binaryDir": "${sourceDir}/vsbuild", + "toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "generator": "Visual Studio 17 2022", + "name": "vs2022-windows" + } + ], + "buildPresets": [ + { + "name": "vs2022-windows", + "configurePreset": "vs2022-windows" + } + ], + "version": 4 +} diff --git a/libs/installer_wizard/LICENSE b/libs/installer_wizard/LICENSE new file mode 100644 index 0000000..45ca39c --- /dev/null +++ b/libs/installer_wizard/LICENSE @@ -0,0 +1,7 @@ +Copyright 2020 Holt59 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/libs/installer_wizard/README.md b/libs/installer_wizard/README.md new file mode 100644 index 0000000..b8db085 --- /dev/null +++ b/libs/installer_wizard/README.md @@ -0,0 +1,83 @@ +# MO2 BAIN Wizard Installer Plugin + +This plugin can be used to install BAIN archives containing a wizard script (`wizard.txt`). + +## How to install? + +Go to the [releases page](https://github.com/Holt59/modorganizer-installer_wizard/releases) and download +the latest release for your MO2 version. + +### A few words on INI Tweaks + +Mod Organizer 2 does not currently manage INI Tweaks, so the Wizard installer is partially functional +regarding them. + +- The installer will create proper INI Tweaks when requested, but these will not be applied + to the game INI files automatically. If INI Tweaks are present, a pop-up should appear at + the end of the installation. +- INI Tweaks for OBSE script are directly applied to the OBSE scripts. + +## How to contribute? + +### Setting-up the environment + +Below are the steps to setup a development environment. + + +1. Clone this repository into the Mod Organizer 2 plugins folder. + +```bash +# (Optional) you can change the name of the folder: +git clone https://github.com/Holt59/modorganizer-installer_wizard installer_wizard +``` + +2. **Requirements:** You need a Python 3.8 installation. The list of requirements is in + [`requirements.txt`](requirements.txt): + +```bash +# Those are only the development requirements. +pip install -r requirements +``` + +3. "Build" the installer: + +```bash +# This will install the 3rd party libraries in src/lib (required for the installer) and convert the .ui files into .py files. +make.ps1 +``` + +4. Create a root `__init__.py` - MO2 will not find and load the plugin unless there is a + `__init__.py` file in the root of the folder, so you need to create one: + +```python +from .src import createPlugin +``` + +### Opening a Pull-Request + +Once you are satisfied with your changes, you can +[open a pull-request](https://github.com/Holt59/modorganizer-installer_wizard/pulls). +Before doing so, you should check that your code is properly +formatted and clean: + +```bash +# The -vv option is mandatory, otherwise tox will crash... +tox -vv -e py38-lint +``` + +### The interpreter + +The interpreter used by the installer is from the +[`bain-wizard-interpreter`](https://github.com/Holt59/bain-wizard-interpreter) package. +For issues related to interpreter (i.e. the script is wrongly parsed), open issues on the interpreter repository. + +## License + +MIT License + +Copyright (c) 2020 Mikaƫl Capelle + +See [LICENSE](LICENSE) for more information. + +**Note:** The release archives contains external libraries that are under their +own LICENSE. diff --git a/libs/installer_wizard/make-release.ps1 b/libs/installer_wizard/make-release.ps1 new file mode 100644 index 0000000..dab186f --- /dev/null +++ b/libs/installer_wizard/make-release.ps1 @@ -0,0 +1,29 @@ +# Build everything first: +.\make.ps1 + +# Package: +$target = ".\installer_wizard\" + +Remove-Item -Recurse -Force -ErrorAction Ignore $target +New-Item -Path $target -Type Directory | Out-Null +Copy-Item -Recurse -Path .\src\* -Exclude "*.ui" -Destination $target +Get-ChildItem -Recurse $target -Include "__pycache__" | Remove-Item -Recurse -Force +Copy-Item .\installer_wizard_en.ts, .\README.md, .\LICENSE $target + +# Find the version: +$ctx = Get-Content .\src\installer.py | Select-String -Pattern "def version\(self\):" -Context 0, 1 +$parts = $ctx.Context[0].PostContext.Split("(")[1].Trim(")").Split(",").Trim() +$version = Join-String -Separator "." -InputObject $parts[0..2] + +if ($parts[3] -match "ALPHA") { + $version += "a" +} +if ($parts[3] -match "BETA") { + $version += "b" +} + +# Create the zip: +$archive = "installer_wizard-$version.zip" +Remove-Item -Force -ErrorAction Ignore $archive +Compress-Archive -Path $target -DestinationPath $archive +Write-Output "Created archive $archive." diff --git a/libs/installer_wizard/make.ps1 b/libs/installer_wizard/make.ps1 new file mode 100644 index 0000000..b6bfdb9 --- /dev/null +++ b/libs/installer_wizard/make.ps1 @@ -0,0 +1,10 @@ +# Install the lib: +pip install --target=.\src\lib --upgrade -r .\plugin-requirements.txt | Out-Null + +# Convert ui files: +Get-ChildItem -Recurse -File -Include "*.ui" | ForEach-Object { + pyuic5 $_ -o ([io.path]::ChangeExtension($_.FullName, "py")) +} + +# Generate the .ts file: +pylupdate5 (Get-ChildItem src -Exclude lib | Get-ChildItem -Recurse -File -Include "*.py") -ts installer_wizard_en.ts diff --git a/libs/installer_wizard/plugin-requirements.txt b/libs/installer_wizard/plugin-requirements.txt new file mode 100644 index 0000000..5203390 --- /dev/null +++ b/libs/installer_wizard/plugin-requirements.txt @@ -0,0 +1 @@ +bain-wizard-interpreter==1.0.4 diff --git a/libs/installer_wizard/poetry.lock b/libs/installer_wizard/poetry.lock new file mode 100644 index 0000000..b5a1efb --- /dev/null +++ b/libs/installer_wizard/poetry.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "antlr4-python3-runtime" +version = "4.13.1" +description = "ANTLR 4.13.1 runtime for Python 3" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "antlr4-python3-runtime-4.13.1.tar.gz", hash = "sha256:3cd282f5ea7cfb841537fe01f143350fdb1c0b1ce7981443a2fa8513fddb6d1a"}, + {file = "antlr4_python3_runtime-4.13.1-py3-none-any.whl", hash = "sha256:78ec57aad12c97ac039ca27403ad61cb98aaec8a3f9bb8144f889aa0fa28b943"}, +] + +[[package]] +name = "bain-wizard-interpreter" +version = "1.0.4" +description = "BAIN Wizard Interpreter based on wizparse." +optional = false +python-versions = "<4.0,>=3.11.1" +groups = ["main"] +files = [ + {file = "bain_wizard_interpreter-1.0.4-py3-none-any.whl", hash = "sha256:c6a084349caa6fd80d8fbf222869fb63df8ede337edbb8774dff557ae2a981bc"}, + {file = "bain_wizard_interpreter-1.0.4.tar.gz", hash = "sha256:4ebd36112690775bab6c921c90422c9e5432901ca90860f36c5ba0153bb82079"}, +] + +[package.dependencies] +antlr4-python3-runtime = ">=4.13.1,<5.0.0" +chardet = ">=5.2.0,<6.0.0" + +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + +[[package]] +name = "mobase-stubs" +version = "2.5.2" +description = "PEP561 stub files for the mobase Python API." +optional = false +python-versions = "<4.0,>=3.12" +groups = ["dev"] +files = [ + {file = "mobase_stubs-2.5.2-py3-none-any.whl", hash = "sha256:6a2c1c95494e7dc0d0f51d4f8f178423c0d9904f823f3c44845315bff304b948"}, + {file = "mobase_stubs-2.5.2.tar.gz", hash = "sha256:70c15579828df3bce01746e51fee07176390cdc386facea2b156ea04b3de055c"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.0-py2.py3-none-any.whl", hash = "sha256:508ecec98f9f3330b636d4448c0f1a56fc68017c68f1e7857ebc52acf0eb879a"}, + {file = "nodeenv-1.9.0.tar.gz", hash = "sha256:07f144e90dae547bf0d4ee8da0ee42664a42a04e02ed68e06324348dafe4bdb1"}, +] + +[[package]] +name = "pastel" +version = "0.2.1" +description = "Bring colors to your terminal." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] +files = [ + {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, + {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, +] + +[[package]] +name = "poethepoet" +version = "0.34.0" +description = "A task runner that works well with poetry." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "poethepoet-0.34.0-py3-none-any.whl", hash = "sha256:c472d6f0fdb341b48d346f4ccd49779840c15b30dfd6bc6347a80d6274b5e34e"}, + {file = "poethepoet-0.34.0.tar.gz", hash = "sha256:86203acce555bbfe45cb6ccac61ba8b16a5784264484195874da457ddabf5850"}, +] + +[package.dependencies] +pastel = ">=0.2.1,<0.3.0" +pyyaml = ">=6.0.2,<7.0" + +[package.extras] +poetry-plugin = ["poetry (>=1.2.0,<3.0.0) ; python_version < \"4.0\""] + +[[package]] +name = "pyqt6" +version = "6.7.1" +description = "Python bindings for the Qt cross platform application toolkit" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyQt6-6.7.1-1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7f397f4b38b23b5588eb2c0933510deb953d96b1f0323a916c4839c2a66ccccc"}, + {file = "PyQt6-6.7.1-1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2f202b7941aa74e5c7e1463a6f27d9131dbc1e6cabe85571d7364f5b3de7397"}, + {file = "PyQt6-6.7.1-cp38-abi3-macosx_11_0_universal2.whl", hash = "sha256:f053378e3aef6248fa612c8afddda17f942fb63f9fe8a9aeb2a6b6b4cbb0eba9"}, + {file = "PyQt6-6.7.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0adb7914c732ad1dee46d9cec838a98cb2b11bc38cc3b7b36fbd8701ae64bf47"}, + {file = "PyQt6-6.7.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2d771fa0981514cb1ee937633dfa64f14caa902707d9afffab66677f3a73e3da"}, + {file = "PyQt6-6.7.1-cp38-abi3-win_amd64.whl", hash = "sha256:fa3954698233fe286a8afc477b84d8517f0788eb46b74da69d3ccc0170d3714c"}, + {file = "PyQt6-6.7.1.tar.gz", hash = "sha256:3672a82ccd3a62e99ab200a13903421e2928e399fda25ced98d140313ad59cb9"}, +] + +[package.dependencies] +PyQt6-Qt6 = ">=6.7.0,<6.8.0" +PyQt6-sip = ">=13.8,<14" + +[[package]] +name = "pyqt6-qt6" +version = "6.7.1" +description = "The subset of a Qt installation needed by PyQt6." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "PyQt6_Qt6-6.7.1-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:29622b5dd38740b4b6962e0c88d082d08fa10b64542ef5d911b04214aad70150"}, + {file = "PyQt6_Qt6-6.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbcfe66a57199c6a26542b8b9b2f2ee59d974db36293de335a1251f24c4fe917"}, + {file = "PyQt6_Qt6-6.7.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9fbab2a96d72d77d16021e259ef86a1a3c87adb0e7eebcc92df0d39f3fdf7e27"}, + {file = "PyQt6_Qt6-6.7.1-py3-none-win_amd64.whl", hash = "sha256:590a2f30d15892b5259e6a17ecc0a755675b1b49f553d964e195e90094b44120"}, +] + +[[package]] +name = "pyqt6-sip" +version = "13.10.2" +description = "The sip module support for PyQt6" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyqt6_sip-13.10.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8132ec1cbbecc69d23dcff23916ec07218f1a9bbbc243bf6f1df967117ce303e"}, + {file = "pyqt6_sip-13.10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07f77e89d93747dda71b60c3490b00d754451729fbcbcec840e42084bf061655"}, + {file = "pyqt6_sip-13.10.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4ffa71ddff6ef031d52cd4f88b8bba08b3516313c023c7e5825cf4a0ba598712"}, + {file = "pyqt6_sip-13.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:e907394795e61f1174134465c889177f584336a98d7a10beade2437bf5942244"}, + {file = "pyqt6_sip-13.10.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1a6c2f168773af9e6c7ef5e52907f16297d4efd346e4c958eda54ea9135be18e"}, + {file = "pyqt6_sip-13.10.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1d3cc9015a1bd8c8d3e86a009591e897d4d46b0c514aede7d2970a2208749cd"}, + {file = "pyqt6_sip-13.10.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ddd578a8d975bfb5fef83751829bf09a97a1355fa1de098e4fb4d1b74ee872fc"}, + {file = "pyqt6_sip-13.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:061d4a2eb60a603d8be7db6c7f27eb29d9cea97a09aa4533edc1662091ce4f03"}, + {file = "pyqt6_sip-13.10.2-cp311-cp311-win_arm64.whl", hash = "sha256:45ac06f0380b7aa4fcffd89f9e8c00d1b575dc700c603446a9774fda2dcfc0de"}, + {file = "pyqt6_sip-13.10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:83e6a56d3e715f748557460600ec342cbd77af89ec89c4f2a68b185fa14ea46c"}, + {file = "pyqt6_sip-13.10.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ccf197f8fa410e076936bee28ad9abadb450931d5be5625446fd20e0d8b27a6"}, + {file = "pyqt6_sip-13.10.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:37af463dcce39285e686d49523d376994d8a2508b9acccb7616c4b117c9c4ed7"}, + {file = "pyqt6_sip-13.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:c7b34a495b92790c70eae690d9e816b53d3b625b45eeed6ae2c0fe24075a237e"}, + {file = "pyqt6_sip-13.10.2-cp312-cp312-win_arm64.whl", hash = "sha256:c80cc059d772c632f5319632f183e7578cd0976b9498682833035b18a3483e92"}, + {file = "pyqt6_sip-13.10.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8b5d06a0eac36038fa8734657d99b5fe92263ae7a0cd0a67be6acfe220a063e1"}, + {file = "pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad376a6078da37b049fdf9d6637d71b52727e65c4496a80b753ddc8d27526aca"}, + {file = "pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:3dde8024d055f496eba7d44061c5a1ba4eb72fc95e5a9d7a0dbc908317e0888b"}, + {file = "pyqt6_sip-13.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:0b097eb58b4df936c4a2a88a2f367c8bb5c20ff049a45a7917ad75d698e3b277"}, + {file = "pyqt6_sip-13.10.2-cp313-cp313-win_arm64.whl", hash = "sha256:cc6a1dfdf324efaac6e7b890a608385205e652845c62130de919fd73a6326244"}, + {file = "pyqt6_sip-13.10.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38b5823dca93377f8a4efac3cbfaa1d20229aa5b640c31cf6ebbe5c586333808"}, + {file = "pyqt6_sip-13.10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5506b9a795098df3b023cc7d0a37f93d3224a9c040c43804d4bc06e0b2b742b0"}, + {file = "pyqt6_sip-13.10.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e455a181d45a28ee8d18d42243d4f470d269e6ccdee60f2546e6e71218e05bb4"}, + {file = "pyqt6_sip-13.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:9c67ed66e21b11e04ffabe0d93bc21df22e0a5d7e2e10ebc8c1d77d2f5042991"}, + {file = "pyqt6_sip-13.10.2.tar.gz", hash = "sha256:464ad156bf526500ce6bd05cac7a82280af6309974d816739b4a9a627156fafe"}, +] + +[[package]] +name = "pyright" +version = "1.1.401" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pyright-1.1.401-py3-none-any.whl", hash = "sha256:6fde30492ba5b0d7667c16ecaf6c699fab8d7a1263f6a18549e0b00bf7724c06"}, + {file = "pyright-1.1.401.tar.gz", hash = "sha256:788a82b6611fa5e34a326a921d86d898768cddf59edde8e93e56087d277cc6f1"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" +typing-extensions = ">=4.1" + +[package.extras] +all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] +nodejs = ["nodejs-wheel-binaries"] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "ruff" +version = "0.11.11" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.11.11-py3-none-linux_armv6l.whl", hash = "sha256:9924e5ae54125ed8958a4f7de320dab7380f6e9fa3195e3dc3b137c6842a0092"}, + {file = "ruff-0.11.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c8a93276393d91e952f790148eb226658dd275cddfde96c6ca304873f11d2ae4"}, + {file = "ruff-0.11.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6e333dbe2e6ae84cdedefa943dfd6434753ad321764fd937eef9d6b62022bcd"}, + {file = "ruff-0.11.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7885d9a5e4c77b24e8c88aba8c80be9255fa22ab326019dac2356cff42089fc6"}, + {file = "ruff-0.11.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b5ab797fcc09121ed82e9b12b6f27e34859e4227080a42d090881be888755d4"}, + {file = "ruff-0.11.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e231ff3132c1119ece836487a02785f099a43992b95c2f62847d29bace3c75ac"}, + {file = "ruff-0.11.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a97c9babe1d4081037a90289986925726b802d180cca784ac8da2bbbc335f709"}, + {file = "ruff-0.11.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8c4ddcbe8a19f59f57fd814b8b117d4fcea9bee7c0492e6cf5fdc22cfa563c8"}, + {file = "ruff-0.11.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6224076c344a7694c6fbbb70d4f2a7b730f6d47d2a9dc1e7f9d9bb583faf390b"}, + {file = "ruff-0.11.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:882821fcdf7ae8db7a951df1903d9cb032bbe838852e5fc3c2b6c3ab54e39875"}, + {file = "ruff-0.11.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dcec2d50756463d9df075a26a85a6affbc1b0148873da3997286caf1ce03cae1"}, + {file = "ruff-0.11.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:99c28505ecbaeb6594701a74e395b187ee083ee26478c1a795d35084d53ebd81"}, + {file = "ruff-0.11.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9263f9e5aa4ff1dec765e99810f1cc53f0c868c5329b69f13845f699fe74f639"}, + {file = "ruff-0.11.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:64ac6f885e3ecb2fdbb71de2701d4e34526651f1e8503af8fb30d4915a3fe345"}, + {file = "ruff-0.11.11-py3-none-win32.whl", hash = "sha256:1adcb9a18802268aaa891ffb67b1c94cd70578f126637118e8099b8e4adcf112"}, + {file = "ruff-0.11.11-py3-none-win_amd64.whl", hash = "sha256:748b4bb245f11e91a04a4ff0f96e386711df0a30412b9fe0c74d5bdc0e4a531f"}, + {file = "ruff-0.11.11-py3-none-win_arm64.whl", hash = "sha256:6c51f136c0364ab1b774767aa8b86331bd8e9d414e2d107db7a2189f35ea1f7b"}, + {file = "ruff-0.11.11.tar.gz", hash = "sha256:7774173cc7c1980e6bf67569ebb7085989a78a103922fb83ef3dfe230cd0687d"}, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] + +[metadata] +lock-version = "2.1" +python-versions = "^3.12" +content-hash = "32e576ced1cd9fd1078bf3656ed1726be6e76a63e31498a891a463e5c2078e60" diff --git a/libs/installer_wizard/pyproject.toml b/libs/installer_wizard/pyproject.toml new file mode 100644 index 0000000..b4fadb6 --- /dev/null +++ b/libs/installer_wizard/pyproject.toml @@ -0,0 +1,65 @@ +[project] +name = "installer-wizard" +version = "1.0.3" +description = "" +authors = [{ name = "Mikaƫl Capelle", email = "capelle.mikael@gmail.com" }] +license = "MIT" +readme = "README.md" +dynamic = ["dependencies"] + +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.12" +pyqt6 = "6.7.1" +bain-wizard-interpreter = "1.0.4" + +[tool.poetry.group.dev.dependencies] +pyright = "^1.1.401" +ruff = "^0.11.11" +poethepoet = "^0.34.0" +mobase-stubs = {version = "2.5.2", allow-prereleases = true} + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poe.tasks] +format-imports = "ruff check --select I . --fix" +format-ruff = "ruff format ." +format.sequence = ["format-imports", "format-ruff"] +lint-ruff = "ruff check ." +lint-ruff-format = "ruff format --check ." +lint-pyright = "pyright ." +lint.sequence = ["lint-ruff", "lint-ruff-format", "lint-pyright"] +lint.ignore_fail = "return_non_zero" + +[tool.ruff] +target-version = "py312" + +[tool.ruff.lint] +extend-select = ["B", "Q", "I"] + +[tool.ruff.lint.isort.sections] +mobase = ["mobase"] +wizard = ["wizard"] + +[tool.ruff.lint.isort] +required-imports = ["from __future__ import annotations"] +section-order = [ + "future", + "standard-library", + "third-party", + "first-party", + "wizard", + "mobase", + "local-folder", +] + +[tool.pyright] +exclude = ["lib", "venv", "src/ui"] +typeCheckingMode = "strict" +reportMissingTypeStubs = true +reportMissingModuleSource = false +pythonPlatform = "Windows" diff --git a/libs/installer_wizard/src/CMakeLists.txt b/libs/installer_wizard/src/CMakeLists.txt new file mode 100644 index 0000000..b772dea --- /dev/null +++ b/libs/installer_wizard/src/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) + +add_custom_target(installer_wizard ALL) +mo2_configure_python(installer_wizard MODULE) diff --git a/libs/installer_wizard/src/__init__.py b/libs/installer_wizard/src/__init__.py new file mode 100644 index 0000000..2b3b391 --- /dev/null +++ b/libs/installer_wizard/src/__init__.py @@ -0,0 +1,17 @@ +""" +This file is the entry point of the module and must contain a createPlugin() +or createPlugins() function. +""" + +from __future__ import annotations + +import os +import site + +site.addsitedir(os.path.join(os.path.dirname(__file__), "lib")) + +from .installer import WizardInstaller # noqa: E402 + + +def createPlugin() -> WizardInstaller: + return WizardInstaller() diff --git a/libs/installer_wizard/src/dialog.py b/libs/installer_wizard/src/dialog.py new file mode 100644 index 0000000..76d10bb --- /dev/null +++ b/libs/installer_wizard/src/dialog.py @@ -0,0 +1,752 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any, cast + +from antlr4 import ParserRuleContext +from PyQt6 import QtWidgets +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFontDatabase, QKeySequence, QPixmap, QResizeEvent, QShortcut +from PyQt6.QtWidgets import QApplication + +from wizard.contexts import ( + WizardInterpreterContext, + WizardRequireVersionsContext, + WizardSelectContext, + WizardSelectManyContext, + WizardSelectOneContext, + WizardTerminationContext, + WizardTopLevelContext, +) +from wizard.errors import WizardError +from wizard.interpreter import WizardInterpreter +from wizard.manager import SelectOption +from wizard.runner import WizardRunnerKeywordVisitor, WizardRunnerState +from wizard.tweaks import WizardINISetting +from wizard.value import Plugin + +import mobase + +from .ui.wizardinstallercomplete import Ui_WizardInstallerComplete +from .ui.wizardinstallerdialog import Ui_WizardInstallerDialog +from .ui.wizardinstallererror import Ui_WizardInstallerError +from .ui.wizardinstallerpage import Ui_WizardInstallerPage +from .ui.wizardinstallerrequires import Ui_WizardInstallerRequires +from .utils import make_ini_tweaks + +WizardRunnerContext = WizardInterpreterContext[WizardRunnerState, Any] + + +def check_version( + context: WizardRequireVersionsContext[WizardRunnerState], + organizer: mobase.IOrganizer, +) -> tuple[bool, bool, bool, bool]: + """ + Check if the requirements are ok. + + Args: + context: The requires version context to check. + organizer: The organizer to fetch actual versions from. + + Returns: + A 4-tuple of boolean values, where each value is True if the installed + version is ok, False otherwise. In order, checks are game, script extender + graphics extender (True if there is no requirements, False otherwise since + we cannot check in MO2), and wrye bash (always True). + """ + game = organizer.managedGame() + + game_ok = True + if context.game_version: + game_ok = mobase.VersionInfo(context.game_version) <= game.version() + + # script extender + se_ok = True + if context.script_extender_version: + se = organizer.gameFeatures().gameFeature(mobase.ScriptExtender) + if not se or not se.isInstalled(): + se_ok = False + else: + if mobase.VersionInfo( + context.script_extender_version + ) <= mobase.VersionInfo(se.getExtenderVersion()): + se_ok = True + else: + se_ok = False + + # cannot check these so... + ge_ok = not context.graphics_extender_version + + return (game_ok, se_ok, ge_ok, True) + + +class WizardInstallerRequiresVersionPage(QtWidgets.QWidget): + context: WizardRequireVersionsContext[WizardRunnerState] + + def __init__( + self, + context: WizardRequireVersionsContext[WizardRunnerState], + organizer: mobase.IOrganizer, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self.context = context + + # set the ui file + self.ui = Ui_WizardInstallerRequires() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.groupBox.setStyleSheet( + 'QLabel[headercell="true"] { font-weight: bold; }' + ) + + game = organizer.managedGame() + + okIcon = QPixmap(":/MO/gui/checked-checkbox").scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + noIcon = QPixmap(":/MO/gui/unchecked-checkbox").scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + koIcon = QPixmap(":/MO/gui/indeterminate-checkbox").scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + self.ui.labelGame.setText(game.gameName()) + + # set the required version + self.ui.labelGameNeed.setText(context.game_version) + self.ui.labelScriptExtenderNeed.setText(context.script_extender_version) + self.ui.labelGraphicsExtenderNeed.setText(context.graphics_extender_version) + self.ui.labelWryeBashNeed.setText(context.wrye_bash_version) + + # set the current version + self.ui.labelGameHave.setText(game.version().canonicalString()) + se = organizer.gameFeatures().gameFeature(mobase.ScriptExtender) + if se and se.isInstalled(): + self.ui.labelScriptExtenderHave.setText(se.getExtenderVersion()) + + # cannot check these so... + game_ok, se_ok, _, _ = check_version(context, organizer) + self.ui.labelGameIcon.setPixmap(okIcon if game_ok else koIcon) + self.ui.labelScriptExtenderIcon.setPixmap(okIcon if se_ok else koIcon) + self.ui.labelGraphicsExtenderIcon.setPixmap(noIcon) + self.ui.labelWryeBashIcon.setPixmap(noIcon) + + +class WizardInstallerSelectPage(QtWidgets.QWidget): + # signal emitted when an item is double-clicked, only for SelectOne context + itemDoubleClicked = pyqtSignal() + + context: WizardSelectContext[WizardRunnerState, Any] + _images: dict[Path, Path] + _currentImage: QPixmap + + def __init__( + self, + context: WizardSelectContext[WizardRunnerState, Any], + images: dict[Path, Path], + options: Sequence[str] | None, + parent: QtWidgets.QWidget, + ): + """ + Args: + context: The context for this page. + images: A mapping from path (in the archive) to extracted path. + options: Potential list of options to select. Might not exactly match. + parent: The parent widget. + """ + super().__init__(parent) + + self._images = images + + # set the ui file + self.ui = Ui_WizardInstallerPage() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.optionList.currentItemChanged.connect( # pyright: ignore[reportUnknownMemberType] + self.onCurrentItemChanged + ) + + # create list item widgets + for _ in context.options: + item = QtWidgets.QListWidgetItem() + self.ui.optionList.addItem(item) + + self.update_context(context) + + # extract previous select options + previous_options = [] + if options: + previous_options = [ + option for option in context.options if option.name in options + ] + else: + if isinstance(context, WizardSelectManyContext): + previous_options = context.defaults + elif isinstance(context, WizardSelectOneContext): + previous_options = [context.default] + + # set default values + for i, option in enumerate(context.options): + item = self.ui.optionList.item(i) + assert item is not None + if isinstance(context, WizardSelectManyContext): + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + if option in previous_options: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + elif ( + isinstance(context, WizardSelectOneContext) + and option in previous_options + ): + item.setSelected(True) + self.ui.optionList.setCurrentItem(item) + + if isinstance(context, WizardSelectOneContext): + self.ui.optionList.doubleClicked.connect( # pyright: ignore[reportUnknownMemberType] + self.itemDoubleClicked.emit + ) + + def update_context(self, context: WizardSelectContext[WizardRunnerState, Any]): + self.context = context + + options = self.context.options + assert len(options) == self.ui.optionList.count() + + self.ui.selectDescriptionLabel.setText(context.description) + self.ui.selectDescriptionLabel.setMargin(4) + + # update the content of the items + for i, option in enumerate(options): + item = self.ui.optionList.item(i) + assert item is not None + item.setText(option.name) + item.setData(Qt.ItemDataRole.UserRole, option) + + # no item selected, select the first one + if not self.ui.optionList.currentItem(): + self.ui.optionList.setCurrentRow(0) + + def onCurrentItemChanged( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + option: SelectOption = current.data(Qt.ItemDataRole.UserRole) + self.ui.descriptionTextEdit.setText(option.description) + image = option.image + if image and Path(image) in self._images: + target = self._images[Path(image)] + self._currentImage = QPixmap(target.as_posix()) + else: + self._currentImage = QPixmap() + + self.ui.imageLabel.setPixmap(self.getResizedImage()) + + def getResizedImage(self) -> QPixmap: + if self._currentImage.isNull(): + return self._currentImage + return self._currentImage.scaled( + self.ui.imageLabel.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + def resizeEvent(self, a0: QResizeEvent | None) -> None: + super().resizeEvent(a0) + self.ui.imageLabel.setPixmap(self.getResizedImage()) + + def selectedOptions(self) -> list[SelectOption]: + options: list[SelectOption] = [] + if isinstance(self.context, WizardSelectOneContext): + item = self.ui.optionList.currentItem() + assert item is not None + options.append(item.data(Qt.ItemDataRole.UserRole)) + else: + for i in range(self.ui.optionList.count()): + item = self.ui.optionList.item(i) + assert item is not None + if item.checkState() == Qt.CheckState.Checked: + options.append(item.data(Qt.ItemDataRole.UserRole)) + return options + + def selected(self) -> WizardSelectContext[WizardRunnerState, Any]: + if isinstance(self.context, WizardSelectOneContext): + return self.context.select(self.selectedOptions()[0]) + elif isinstance(self.context, WizardSelectManyContext): + return self.context.select(self.selectedOptions()) + else: + return self.context + + +class WizardInstallerCompletePage(QtWidgets.QWidget): + def __init__( + self, + context: WizardTerminationContext[WizardRunnerState], + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + # set the ui file + self.ui = Ui_WizardInstallerComplete() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.setStyleSheet('QLabel[heading="true"] { font-weight: bold; }') + + self.context = context + self.state = context.state + + # retrieve the keyword visitor + kvisitor = cast(WizardRunnerKeywordVisitor, context.factory.kvisitor) + + # the list of plugins in selected sub-packages + plugins: set[Plugin] = set() + + # sub-packages + for sp in kvisitor.subpackages: + item = QtWidgets.QListWidgetItem() + item.setText(sp.name) + if sp.name in self.state.subpackages: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + plugins.update(kvisitor.plugins_for(sp)) + self.ui.subpackagesList.addItem(item) + + # switch the renamed plugins + for plugin in list(plugins): + if plugin in self.state.renames: + plugins.remove(plugin) + plugins.add(Plugin(self.state.renames[plugin])) + + # lugins + for plugin in sorted(plugins): + item = QtWidgets.QListWidgetItem() + item.setText(plugin.name) + if plugin in self.state.plugins: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + self.ui.pluginsList.addItem(item) + + # INI Tweaks + self.ui.tweaksWidget.setVisible(bool(self.state.tweaks)) + self.ui.tweaksList.currentItemChanged.connect( # pyright: ignore[reportUnknownMemberType] + self.onCurrentTweakItemChanged + ) + if self.state.tweaks: + # group the tweaks per file + tweaks = { + file: self.state.tweaks.tweaks(file) + for file in self.state.tweaks.files() + } + + for file, ftweaks in tweaks.items(): + item = QtWidgets.QListWidgetItem() + item.setText(file.replace("\\", "/")) + item.setData(Qt.ItemDataRole.UserRole, ftweaks) + self.ui.tweaksList.addItem(item) + + self.ui.tweaksTextEdit.setFont( + QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + ) + self.ui.tweaksList.setCurrentRow(0) + + # notes + md = "" + for note in self.state.notes: + md += f"- {note}\n" + document = self.ui.notesTextEdit.document() + assert document is not None + document.setIndentWidth(10) + self.ui.notesTextEdit.setMarkdown(md) + + def onCurrentTweakItemChanged( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + # clear text area and create the tweaks + self.ui.tweaksTextEdit.clear() + self.ui.tweaksTextEdit.appendPlainText( + make_ini_tweaks(current.data(Qt.ItemDataRole.UserRole)) + ) + + def subpackages(self) -> list[str]: + """ + Returns: + The list of subpackages selected in the UI (either automatically by the + interpreter or by the user). + """ + sp: list[str] = [] + for i in range(self.ui.subpackagesList.count()): + item = self.ui.subpackagesList.item(i) + assert item is not None + if item.checkState() == Qt.CheckState.Checked: + sp.append(item.text()) + return sp + + def plugins(self) -> dict[str, bool]: + """ + Returns: + The list of plugins selected in the UI (either automatically by the + interpreter or by the user). + """ + sp: dict[str, bool] = {} + for i in range(self.ui.pluginsList.count()): + item = self.ui.pluginsList.item(i) + assert item is not None + sp[item.text()] = item.checkState() == Qt.CheckState.Checked + return sp + + def tweaks(self) -> dict[str, list[WizardINISetting]]: + """ + Returns: + The list of tweaks created by the wizard. The returned value maps filenames + to INI tweaks. + """ + rets: dict[str, list[WizardINISetting]] = {} + for i in range(self.ui.tweaksList.count()): + item = self.ui.tweaksList.item(i) + assert item is not None + rets[item.text()] = item.data(Qt.ItemDataRole.UserRole) + return rets + + +class WizardInstallerCancelPage(QtWidgets.QWidget): + def __init__( + self, + context: WizardTerminationContext[WizardRunnerState], + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + # set the ui file (same UI file for both cancel and error) + self.ui = Ui_WizardInstallerError() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.titleLabel.setText( + "The installation was cancelled by the installer with the following reason." + ) + style = self.style() + assert style is not None + self.ui.iconLabel.setPixmap( + style.standardIcon( + QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning + ).pixmap(24, 24) + ) + self.ui.messageEdit.setText(context.message()) + + +class WizardInstallerErrorPage(QtWidgets.QWidget): + def __init__( + self, + error: WizardError, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + # set the ui file (same UI file for both cancel and error) + self.ui = Ui_WizardInstallerError() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.titleLabel.setText( + "An error occurred during the installation of the script, " + "this is probably due to an incorrect script file (wizard.txt) in the " + "archive." + ) + style = self.style() + assert style is not None + self.ui.iconLabel.setPixmap( + style.standardIcon( + QtWidgets.QStyle.StandardPixmap.SP_MessageBoxCritical + ).pixmap(24, 24) + ) + self.ui.messageEdit.setText(str(error)) + + +class WizardInstallerDialog(QtWidgets.QDialog): + # flag to indicate if the user chose to do a manual installation + _manual: bool = False + + # organizer + _organizer: mobase.IOrganizer + + # interpreter + _interpreter: WizardInterpreter[WizardRunnerState] + _images: dict[Path, Path] + _options: dict[str, list[str]] + + # the Wizard MO2 interface + _start_context: WizardTopLevelContext[WizardRunnerState] + + # dict from context to selected options + _pages: dict[ParserRuleContext, WizardInstallerSelectPage] + + def __init__( + self, + organizer: mobase.IOrganizer, + interpreter: WizardInterpreter[WizardRunnerState], + context: WizardTopLevelContext[WizardRunnerState], + name: mobase.GuessedString, + images: dict[Path, Path], + options: dict[str, list[str]], + parent: QtWidgets.QWidget, + ): + """ + Args: + interpreter: The interpreter to use. + context: The initial context of the script. + name: The name of the mod. + images: A mapping from path (in the archive) to extracted path. + options: The previously selected options. + parent: The parent widget. + """ + super().__init__(parent) + + self._organizer = organizer + self._interpreter = interpreter + self._images = images + self._options = options + self._start_context = context + self._pages = {} + + # set the ui file + self.ui = Ui_WizardInstallerDialog() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False) + self.setWindowFlag(Qt.WindowType.WindowMaximizeButtonHint, True) + + # mobase.GuessedString contains multiple names with various level of + # "guess", using.variants() returns the list of names, and doing str(name) + # will return the most-likely value + for value in name.variants(): + self.ui.nameCombo.addItem(value) + completer = self.ui.nameCombo.completer() + assert completer is not None + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive) + self.ui.nameCombo.setCurrentIndex(self.ui.nameCombo.findText(str(name))) + + # we need to connect the Cancel / Manual buttons. We can of course use + # PyQt6 signal/slot syntax + self.ui.cancelBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + self.reject + ) + + def manualClicked(): + self._manual = True + self.reject() + + self.ui.manualBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + manualClicked + ) + + self.ui.prevBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + self.previousClicked + ) + self.ui.nextBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + self.nextClicked + ) + + backShortcut = QShortcut(QKeySequence(Qt.Key.Key_Backspace), self) + backShortcut.activated.connect( # pyright: ignore[reportUnknownMemberType] + self.previousClicked + ) + + @property + def scriptButtonClicked(self) -> pyqtSignal: + return self.ui.scriptBtn.clicked # pyright: ignore[reportReturnType] + + def name(self): + return self.ui.nameCombo.currentText() + + def subpackages(self): + """ + Returns: + The list of subpackages to install. Only valid if exec() returned + Accepted. + """ + # we cannot fetch it from the state since the user can modify it in the UI + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return widget.subpackages() + + def plugins(self) -> dict[str, bool]: + """ + Returns: + The list of plugins to install and enable. Only valid if exec() returned + Accepted. + """ + # we cannot fetch it from the state since the user can modify it in the UI + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return widget.plugins() + + def renames(self) -> dict[str, str]: + """ + Returns: + The mapping of renames for plugins. Only valid if exec() returned Accepted. + """ + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return { + plugin.name: new_name for plugin, new_name in widget.state.renames.items() + } + + def tweaks(self) -> dict[str, list[WizardINISetting]]: + """ + Returns: + The list of tweaks per file. Only valid if exec() returned Accepted. + """ + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return widget.tweaks() + + def selectedOptions(self) -> dict[str, list[str]]: + """ + Returns: + The list of all currently selected options. + """ + result: dict[str, list[str]] = {} + for i in range(self.ui.stackedWidget.count()): + page = self.ui.stackedWidget.widget(i) + if isinstance(page, WizardInstallerSelectPage): + result[page.context.description] = [ + option.name for option in page.selectedOptions() + ] + return result + + def isManualRequested(self): + return self._manual + + def previousClicked(self): + index = self.ui.stackedWidget.currentIndex() + if index > 0: + self.ui.stackedWidget.removeWidget(self.ui.stackedWidget.widget(index)) + + self._update_prev_button() + self._update_next_button() + self._update_focus() + + def nextClicked(self): + widget = self.ui.stackedWidget.currentWidget() + + try: + if isinstance(widget, WizardInstallerSelectPage): + context = widget.selected().exec() + elif isinstance(widget, WizardInstallerRequiresVersionPage): + context = widget.context.exec() + else: + self.accept() + return + + context = self._exec_until(context) + + if context.context in self._pages: + page = self._pages[context.context] + assert isinstance(context, WizardSelectContext) + page.update_context(context) + else: + page = self._make_page(context) + + except WizardError as ex: + page = WizardInstallerErrorPage(ex, self) + + index = self.ui.stackedWidget.addWidget(page) + self.ui.stackedWidget.setCurrentIndex(index) + self._update_prev_button() + self._update_next_button() + self._update_focus() + + def _update_focus(self): + widget = self.ui.stackedWidget.currentWidget() + if isinstance(widget, WizardInstallerSelectPage): + widget.ui.optionList.setFocus() + + def _update_prev_button(self): + self.ui.prevBtn.setDisabled(self.ui.stackedWidget.currentIndex() <= 0) + + def _update_next_button(self): + widget = self.ui.stackedWidget.currentWidget() + + self.ui.nextBtn.setDisabled(False) + + name: str = self.ui.nextBtn.text() + if isinstance(widget, WizardInstallerSelectPage): + name = self.tr("Next") + elif isinstance(widget, WizardInstallerRequiresVersionPage): + name = self.tr("Install anyway") + elif isinstance(widget, (WizardInstallerCancelPage, WizardInstallerErrorPage)): + self.ui.nextBtn.setDisabled(True) + else: + name = self.tr("Install") + + self.ui.nextBtn.setText(name) + + def _exec_until(self, context: WizardRunnerContext) -> WizardRunnerContext: + context = self._interpreter.exec_until( + context, + ( + WizardSelectContext, + WizardRequireVersionsContext, + ), + ) + + # if all requirements are ok, skip the context + if isinstance(context, WizardRequireVersionsContext): + if all(check_version(context, self._organizer)): + return self._exec_until(context.exec()) + + return context + + def _make_page(self, context: WizardRunnerContext) -> QtWidgets.QWidget: + page: QtWidgets.QWidget + if isinstance(context, WizardSelectContext): + page = WizardInstallerSelectPage( + context, + self._images, + self._options.get(context.description, None), + self, + ) + page.itemDoubleClicked.connect( # pyright: ignore[reportUnknownMemberType] + self.nextClicked + ) + self._pages[context.context] = page # type: ignore + elif isinstance(context, WizardRequireVersionsContext): + page = WizardInstallerRequiresVersionPage(context, self._organizer, self) + elif isinstance(context, WizardTerminationContext): + if context.is_cancel(): + page = WizardInstallerCancelPage(context, self) + else: + page = WizardInstallerCompletePage(context, self) + else: + raise NotImplementedError() # for typing purpose + + return page + + def exec(self): + try: + context = self._exec_until(self._start_context) + page = self._make_page(context) + except WizardError as ex: + page = WizardInstallerErrorPage(ex, self) + self.ui.stackedWidget.addWidget(page) + self._update_prev_button() + self._update_next_button() + self._update_focus() + return super().exec() + + def tr(self, value: str): # pyright: ignore[reportIncompatibleMethodOverride] + return QApplication.translate("WizardInstallerDialog", value) diff --git a/libs/installer_wizard/src/installer.py b/libs/installer_wizard/src/installer.py new file mode 100644 index 0000000..c32956c --- /dev/null +++ b/libs/installer_wizard/src/installer.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import os +import re +import sys +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Union, cast + +from PyQt6 import QtWidgets +from PyQt6.QtWidgets import QApplication + +from wizard.runner import WizardRunnerState + +import mobase + +from .dialog import WizardInstallerDialog +from .runner import make_interpreter +from .utils import make_ini_tweaks, merge_ini_tweaks + + +class WizardInstaller(mobase.IPluginInstallerSimple): + """ + This is the actual plugin. MO2 has two types of installer plugin, this one is + "simple", i.e., it will work directly on the file-tree contained in the archive. + The purpose of the installer is to take the file-tree from the archive, check if + it is valid (for this installer) and then modify it if required before extraction. + """ + + # regex used to parse settings + RE_DESCRIPTION = re.compile(r"select([0-9]+)-description") + RE_OPTION = re.compile(r"select([0-9]+)-option([0-9]+)") + + _organizer: mobase.IOrganizer + + # list of selected options + _installerOptions: Dict[str, List[str]] + _installerUsed: bool + + def __init__(self): + super().__init__() + + def init(self, organizer: mobase.IOrganizer): + self._organizer = organizer + return True + + def name(self): + return "BAIN Wizard Installer" + + def localizedName(self) -> str: + return self.tr("BAIN Wizard Installer") + + def author(self): + return "Holt59" + + def description(self): + return self.tr("Installer for BAIN archive containing wizard scripts.") + + def version(self): + return mobase.VersionInfo(1, 0, 2) + + def isActive(self): + return self._organizer.pluginSetting(self.name(), "enabled") + + def settings(self): + return [ + mobase.PluginSetting("enabled", "check to enable this plugin", True), + mobase.PluginSetting( + "prefer_fomod", + "prefer FOMOD installer over this one when possible", + True, + ), + mobase.PluginSetting( + "prefer_omod", + "prefer OMOD installer over this one when possible", + False, + ), + # above FOMOD? + mobase.PluginSetting("priority", "priority of this installer", 120), + ] + + # method for IPluginInstallerSimple + + def priority(self) -> int: + return cast(int, self._organizer.pluginSetting(self.name(), "priority")) + + def isManualInstaller(self) -> bool: + return False + + def onInstallationStart( + self, + archive: str, + reinstallation: bool, + current_mod: Optional[mobase.IModInterface], + ): + self._installerUsed = False + self._installerOptions = {} + + if current_mod: + settings = current_mod.pluginSettings(self.name()) + + # first extract the description + descriptions: Dict[int, str] = {} + options: Dict[int, Dict[int, str]] = defaultdict(dict) + for setting, value in settings.items(): + mdesc = WizardInstaller.RE_DESCRIPTION.match(setting) + if mdesc: + select = int(mdesc.group(1)) + descriptions[select] = str(value) + + mopt = WizardInstaller.RE_OPTION.match(setting) + if mopt: + select = int(mopt.group(1)) + index = int(mopt.group(2)) + options[select][index] = str(value) + + for kdesc, desc in descriptions.items(): + self._installerOptions[desc] = [] + if kdesc in options: + for index in sorted(options[kdesc].keys()): + self._installerOptions[desc].append(options[kdesc][index]) + + def onInstallationEnd( + self, result: mobase.InstallResult, new_mod: Optional[mobase.IModInterface] + ): + if ( + result != mobase.InstallResult.SUCCESS + or not self._installerUsed + or not new_mod + ): + return + + new_mod.clearPluginSettings(self.name()) + for i, desc in enumerate(self._installerOptions): + new_mod.setPluginSetting(self.name(), f"select{i}-description", desc) + for iopt, opt in enumerate(self._installerOptions[desc]): + new_mod.setPluginSetting(self.name(), f"select{i}-option{iopt}", opt) + + def _hasFomodInstaller(self) -> bool: + # do not consider the NCC installer + return self._organizer.isPluginEnabled("Fomod Installer") + + def _hasOmodInstaller(self) -> bool: + return self._organizer.isPluginEnabled("Omod Installer") + + def _getWizardArchiveBase( + self, tree: mobase.IFileTree, data_name: str, checker: mobase.ModDataChecker + ) -> Optional[mobase.IFileTree]: + """ + Try to find the folder containing wizard.txt. + + Args: + tree: Tree to look the data folder in. + data_name: Name of the data folder (e.g., "data" for Bethesda games). + checker: Checker to use to check if a tree is a data folder. + + Returns: + The tree corresponding to the folder containing wizard.txt, or None. + """ + + entry = tree.find("wizard.txt", mobase.FileTreeEntry.FILE) + + if entry: + return tree + + if len(tree) == 1 and isinstance((root := tree[0]), mobase.IFileTree): + return self._getWizardArchiveBase(root, data_name, checker) + + return None + + def _getEntriesToExtract( + self, + tree: mobase.IFileTree, + extensions: Sequence[str] = ["png", "jpg", "jpeg", "gif", "bmp", "ini"], + ) -> list[mobase.FileTreeEntry]: + """ + Retrieve all the entries to extract from the given tree. + + Args: + tree: The tree. + extensions: The extensions of files. + + Returns: + A list of entries corresponding to files with the given extensions. + """ + entries: list[mobase.FileTreeEntry] = [] + + def fn(path: str, entry: mobase.FileTreeEntry): + if entry.isFile() and entry.hasSuffix(extensions): + entries.append(entry) + return mobase.IFileTree.CONTINUE + + tree.walk(fn) + + return entries + + def isArchiveSupported(self, tree: mobase.IFileTree) -> bool: + """ + Check if the given file-tree (from the archive) can be installed by this + installer. + + Args: + tree: The tree to check. + + Returns: + True if the file-tree can be installed, false otherwise. + """ + + # retrieve the name of the "data" folder + data_name = self._organizer.managedGame().dataDirectory().dirName() + + # retrieve the mod-data-checker + checker = self._organizer.gameFeatures().gameFeature(mobase.ModDataChecker) + + # retrieve the base + base = self._getWizardArchiveBase(tree, data_name, checker) + + if not base: + return False + + # check FOMOD for priority + fomod = base.exists("fomod/ModuleConfig.xml") + if ( + fomod + and self._hasFomodInstaller() + and self._organizer.pluginSetting(self.name(), "prefer_fomod") + ): + return False + + # TODO: Check OMOD? + + return True + + def install( + self, + name: mobase.GuessedString, + tree: mobase.IFileTree, + version: str, + nexus_id: int, + ) -> Union[mobase.InstallResult, mobase.IFileTree]: + """ + Perform the actual installation. + + Args: + name: The "name" of the mod. This can be updated to change the name of the + mod. + tree: The original archive tree. + version: The original version of the mod. + nexus_id: The original ID of the mod. + + Returns: We either return the modified file-tree (if the installation was + successful), or a InstallResult otherwise. + + Note: It is also possible to return a tuple (InstallResult, IFileTree, str, int) + containing where the two last members correspond to the new version and ID + of the mod, in case those were updated by the installer. + """ + + # retrieve the name of the "data" folder + data_name = self._organizer.managedGame().dataDirectory().dirName() + + # retrieve the mod-data-checker + checker = self._organizer.gameFeatures().gameFeature(mobase.ModDataChecker) + + # retrieve the "base" folder + base = self._getWizardArchiveBase(tree, data_name, checker) + if not base or not checker: + return mobase.InstallResult.NOT_ATTEMPTED + + wizard = base.find("wizard.txt") + if wizard is None: + return mobase.InstallResult.NOT_ATTEMPTED + + to_extract = self._getEntriesToExtract(tree) + + # extract the script + paths = self._manager().extractFiles([wizard] + to_extract, silent=False) + if len(paths) != len(to_extract) + 1: + return mobase.InstallResult.FAILED + + interpreter = make_interpreter(base, self._organizer) + + script = paths[0] + + dialog = WizardInstallerDialog( + self._organizer, + interpreter, + interpreter.make_top_level_context(Path(script), WizardRunnerState()), + name, + { + Path(entry.path()): Path(path) + for entry, path in zip(to_extract, paths[1:], strict=True) + if not path.endswith(".ini") + }, + self._installerOptions, + self._parentWidget(), + ) + + dialog.scriptButtonClicked.connect( # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] + lambda: os.startfile(script) + ) + + # unlike the official installer, we do not have a "silent" setting, but it is + # really simple to add it + if dialog.exec() == QtWidgets.QDialog.DialogCode.Accepted: + # we update the name with the user specified one + name.update(dialog.name(), mobase.GuessQuality.USER) + + # create the tree with all the sub-packages + new_tree = tree.createOrphanTree() + + for subpackage in dialog.subpackages(): + entry = base.find(subpackage) + + # should never happens since we fetch the subpackage for the archive + if not entry or not isinstance(entry, mobase.IFileTree): + print( + f"SubPackage {subpackage} not found in the archive.", + file=sys.stderr, + ) + continue + + new_tree.merge(entry) + + # handle renames + for original, new in dialog.renames().items(): + # entry should be at the root + entry = new_tree.find(original) + + if not entry: + print(f"Plugin {original} not found, cannot rename.") + continue + + new_tree.move(entry, new) + + # move not selected plugins to optional + for plugin, enabled in dialog.plugins().items(): + if not enabled: + entry = new_tree.find(plugin) + if not entry: + continue # silently fail since the plugin should be disabled + new_tree.addDirectory("optional").insert(entry) + + # TODO: INI Tweaks: + alltweaks = dialog.tweaks() + + for filename, tweaks in alltweaks.items(): + # find the original file (if any) + o_entry = new_tree.find(filename) + o_filename: Optional[str] = None + if o_entry: + # find the filepath from the list of extracted files + index = to_extract.index(o_entry) + + # +1 because the first one is the script + o_filename = paths[index + 1] + + # if the file existed before, we keep the new one at the same place + if o_entry or Path(filename).parts[0].lower() == "ini tweaks": + entry = new_tree.addFile(filename, replace_if_exists=True) + + # otherwise we create it in INI Tweaks + else: + entry = new_tree.addFile( + os.path.join("INI Tweaks", filename), replace_if_exists=True + ) + + filepath = self._manager().createFile(entry) + + if not o_filename: + data = make_ini_tweaks(tweaks) + else: + data = merge_ini_tweaks(tweaks, Path(o_filename)) + + with open(filepath, "w") as fp: + fp.write(data) + + # mark stuff for saving + self._installerUsed = True + self._installerOptions = dict(dialog.selectedOptions()) + + return new_tree + + # if user requested a manual installation, we update the name (to keep it + # in the manual installation dialog) and just notify the installation manager + elif dialog.isManualRequested(): + name.update(dialog.name(), mobase.GuessQuality.USER) + return mobase.InstallResult.MANUAL_REQUESTED + + # if user canceled, we simply notify the installation manager + else: + return mobase.InstallResult.CANCELED + + def tr(self, value: str) -> str: + # we need this to translate string in Python. Check the common documentation + # for more details + return QApplication.translate("WizardInstaller", value) diff --git a/libs/installer_wizard/src/installer_wizard_en.ts b/libs/installer_wizard/src/installer_wizard_en.ts new file mode 100644 index 0000000..0b1bd68 --- /dev/null +++ b/libs/installer_wizard/src/installer_wizard_en.ts @@ -0,0 +1,156 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1"> + <context> + <name>WizardInstaller</name> + <message> + <location filename="installer.py" line="52" /> + <source>BAIN Wizard Installer</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="installer.py" line="58" /> + <source>Installer for BAIN archive containing wizard scripts.</source> + <translation type="unfinished" /> + </message> + </context> + <context> + <name>WizardInstallerComplete</name> + <message> + <location filename="ui\wizardinstallercomplete.ui" line="0" /> + <source>The installer script has finished. The following sub-packages and plugins will be installed.</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallercomplete.ui" line="0" /> + <source>Sub-Packages:</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallercomplete.ui" line="0" /> + <source>Plugins:</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallercomplete.ui" line="0" /> + <source>INI Tweaks:</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallercomplete.ui" line="0" /> + <source>Notes:</source> + <translation type="unfinished" /> + </message> + </context> + <context> + <name>WizardInstallerDialog</name> + <message> + <location filename="ui\wizardinstallerdialog.ui" line="0" /> + <location filename="dialog.py" line="688" /> + <source>Next</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="dialog.py" line="690" /> + <source>Install anyway</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="dialog.py" line="694" /> + <source>Install</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerdialog.ui" line="0" /> + <source>BAIN Wizard Installer</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerdialog.ui" line="0" /> + <source>Name</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerdialog.ui" line="0" /> + <source>Manual</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerdialog.ui" line="0" /> + <source>Script</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerdialog.ui" line="0" /> + <source>Back</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerdialog.ui" line="0" /> + <source>Cancel</source> + <translation type="unfinished" /> + </message> + </context> + <context> + <name>WizardInstallerPage</name> + <message> + <location filename="ui\wizardinstallerpage.ui" line="0" /> + <source>Options:</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerpage.ui" line="0" /> + <source>Description:</source> + <translation type="unfinished" /> + </message> + </context> + <context> + <name>WizardInstallerRequires</name> + <message> + <location filename="ui\wizardinstallerrequires.ui" line="0" /> + <source><html><head/><body><p><span style=" font-weight:600;">Warning:</span> The following version requirements are not met for using this installer.</p></body></html></source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerrequires.ui" line="0" /> + <source>Requirements</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerrequires.ui" line="0" /> + <source>Graphics Extender</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerrequires.ui" line="0" /> + <source>Wrye Bash</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerrequires.ui" line="0" /> + <source>Need</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerrequires.ui" line="0" /> + <source>Game</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerrequires.ui" line="0" /> + <location filename="ui\wizardinstallerrequires.ui" line="0" /> + <source>N/A</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerrequires.ui" line="0" /> + <source>Have</source> + <translation type="unfinished" /> + </message> + <message> + <location filename="ui\wizardinstallerrequires.ui" line="0" /> + <source>Script Extender</source> + <translation type="unfinished" /> + </message> + </context> +</TS> diff --git a/libs/installer_wizard/src/runner.py b/libs/installer_wizard/src/runner.py new file mode 100644 index 0000000..e098e11 --- /dev/null +++ b/libs/installer_wizard/src/runner.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Iterable, List, Optional + +from wizard.interpreter import WizardInterpreter +from wizard.manager import ManagerModInterface +from wizard.severity import SeverityContext +from wizard.utils import make_runner_context_factory +from wizard.value import SubPackage, SubPackages + +import mobase + + +class MO2SubPackage(SubPackage): + _tree: mobase.IFileTree + _files: List[str] + + def __init__(self, tree: mobase.IFileTree): + super().__init__(tree.name()) + self._tree = tree + + # we cannot perform lazy iteration on the tree in a Python way so we + # have to list the files + self._files = [] + + def fn(folder: str, entry: mobase.FileTreeEntry) -> mobase.IFileTree.WalkReturn: + self._files.append(entry.path()) + return mobase.IFileTree.CONTINUE + + self._tree.walk(fn) + + @property + def files(self) -> Iterable[str]: + return self._files + + +class MO2SeverityContext(SeverityContext): + _organizer: mobase.IOrganizer + + def __init__(self, organizer: mobase.IOrganizer): + super().__init__() + self._organizer = organizer + + def warning(self, text: str): + print(text, file=sys.stderr) + + +class MO2ManagerModInterface(ManagerModInterface): + _organizer: mobase.IOrganizer + _game: mobase.IPluginGame + _subpackages: SubPackages + + def __init__(self, tree: mobase.IFileTree, organizer: mobase.IOrganizer): + self._organizer = organizer + self._game = organizer.managedGame() + + checker = self._organizer.gameFeatures().gameFeature(mobase.ModDataChecker) + + # read the sub-packages + self._subpackages = SubPackages() + for entry in tree: + if isinstance(entry, mobase.IFileTree): + if checker: + if checker.dataLooksValid(entry) == mobase.ModDataChecker.VALID: + self._subpackages.append(MO2SubPackage(entry)) + continue + + # add entry with INI tweaks + if entry.exists("INI Tweaks") or entry.exists("INI"): + self._subpackages.append(MO2SubPackage(entry)) + continue + + # we add folder with format "XXX Docs" where "XXX" is a number + parts = entry.name().split() + if ( + len(parts) >= 2 + and parts[0].isdigit() + and parts[1].lower().startswith("doc") + ): + self._subpackages.append(MO2SubPackage(entry)) + + @property + def subpackages(self) -> SubPackages: + return self._subpackages + + def compareGameVersion(self, version: str) -> int: + v1 = mobase.VersionInfo(version) + v2 = mobase.VersionInfo(self._game.gameVersion()) + if v1 < v2: + return 1 + elif v1 > v2: + return -1 + else: + return 0 + + def compareSEVersion(self, version: str) -> int: + se = self._organizer.gameFeatures().gameFeature(mobase.ScriptExtender) + if not se: + return 1 + v1 = mobase.VersionInfo(version) + v2 = mobase.VersionInfo(se.getExtenderVersion()) + if v1 < v2: + return 1 + elif v1 > v2: + return -1 + else: + return 0 + + def compareGEVersion(self, version: str) -> int: + # cannot do th is in MO2 + return 1 + + def compareWBVersion(self, version: str) -> int: + # cannot do this in MO2 + return 1 + + def _resolve(self, filepath: str) -> Optional[Path]: + """ + Resolve the given filepath. + + Args: + filepath: The path to resolve. + + Returns: + The path to the given file on the disk, or one of the file mapping + to it in the VFS, or None if the file does not exists. + """ + # TODO: This does not handle weird path that go back (..) and then in data + # again, e.g. ../data/xxx.esp. + path: Optional[Path] + if filepath.startswith(".."): + path = Path(self._game.dataDirectory().absoluteFilePath(filepath)) + if not path.exists(): + path = None + else: + path = Path(filepath) + parent = path.parent.as_posix() + if parent == ".": + parent = "" + + files = self._organizer.findFiles(parent, "*" + path.name) + if files: + path = Path(files[0]) + else: + path = None + + return path + + def dataFileExists(self, *filepaths: str) -> bool: + return all(self._resolve(path) for path in filepaths) + + def getPluginLoadOrder(self, filename: str, fallback: int = -1) -> int: + return self._organizer.pluginList().loadOrder(filename) + + def getPluginStatus(self, filename: str) -> int: + state = self._organizer.pluginList().state(filename) + + if state == mobase.PluginState.ACTIVE: + return 2 + if state == mobase.PluginState.INACTIVE: + return 0 # Or 1? + return -1 + + def getFilename(self, path: str) -> str: + path_ = self._resolve(path) + if path_: + if path_.is_file(): + return path_.name + return "" + + def getFolder(self, path: str) -> str: + path_ = self._resolve(path) + if path_: + if path_.is_dir(): + return path_.name + return "" + + +def make_interpreter( + base: mobase.IFileTree, organizer: mobase.IOrganizer +) -> WizardInterpreter[Any]: + manager = MO2ManagerModInterface(base, organizer) + severity = MO2SeverityContext(organizer) + + factory = make_runner_context_factory(manager.subpackages, manager, severity) + + return WizardInterpreter(factory) diff --git a/libs/installer_wizard/src/ui/wizardinstallercomplete.ui b/libs/installer_wizard/src/ui/wizardinstallercomplete.ui new file mode 100644 index 0000000..e5357dd --- /dev/null +++ b/libs/installer_wizard/src/ui/wizardinstallercomplete.ui @@ -0,0 +1,271 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>WizardInstallerComplete</class> + <widget class="QWidget" name="WizardInstallerComplete"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>652</width> + <height>476</height> + </rect> + </property> + <property name="windowTitle"> + <string notr="true"/> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QLabel" name="label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>The installer script has finished. The following sub-packages and plugins will be installed.</string> + </property> + </widget> + </item> + <item> + <widget class="QWidget" name="widget" native="true"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <layout class="QVBoxLayout" name="verticalLayout_11"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <widget class="QSplitter" name="splitter"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <widget class="QWidget" name="packagesWidget" native="true"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>15</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <layout class="QVBoxLayout" name="verticalLayout_9"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout_5"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Sub-Packages:</string> + </property> + <property name="heading" stdset="0"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QListWidget" name="subpackagesList"/> + </item> + </layout> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_4"> + <item> + <widget class="QLabel" name="label_3"> + <property name="text"> + <string>Plugins:</string> + </property> + <property name="heading" stdset="0"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QListWidget" name="pluginsList"/> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </widget> + <widget class="QWidget" name="tweaksWidget" native="true"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>10</verstretch> + </sizepolicy> + </property> + <layout class="QVBoxLayout" name="verticalLayout_7"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout_6"> + <item> + <widget class="QLabel" name="tweaksLabel"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Minimum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="lineWidth"> + <number>0</number> + </property> + <property name="text"> + <string>INI Tweaks:</string> + </property> + <property name="heading" stdset="0"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="tweaksLayout"> + <item> + <widget class="QListWidget" name="tweaksList"/> + </item> + <item> + <widget class="QPlainTextEdit" name="tweaksTextEdit"> + <property name="readOnly"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </widget> + <widget class="QWidget" name="notesWidget" native="true"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>10</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <layout class="QVBoxLayout" name="verticalLayout_10"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout_8"> + <item> + <widget class="QLabel" name="notesLabel"> + <property name="text"> + <string>Notes:</string> + </property> + <property name="heading" stdset="0"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QTextEdit" name="notesTextEdit"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="readOnly"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + </item> + </layout> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/libs/installer_wizard/src/ui/wizardinstallerdialog.ui b/libs/installer_wizard/src/ui/wizardinstallerdialog.ui new file mode 100644 index 0000000..4ae2565 --- /dev/null +++ b/libs/installer_wizard/src/ui/wizardinstallerdialog.ui @@ -0,0 +1,128 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>WizardInstallerDialog</class> + <widget class="QDialog" name="WizardInstallerDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>769</width> + <height>477</height> + </rect> + </property> + <property name="windowTitle"> + <string>BAIN Wizard Installer</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,0"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2" stretch="1,2"> + <property name="spacing"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Name</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="nameCombo"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="editable"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QStackedWidget" name="stackedWidget"> + <property name="currentIndex"> + <number>-1</number> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QPushButton" name="manualBtn"> + <property name="text"> + <string>Manual</string> + </property> + <property name="autoDefault"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="scriptBtn"> + <property name="text"> + <string>Script</string> + </property> + <property name="autoDefault"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="prevBtn"> + <property name="enabled"> + <bool>false</bool> + </property> + <property name="text"> + <string>Back</string> + </property> + <property name="autoDefault"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="nextBtn"> + <property name="text"> + <string>Next</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="cancelBtn"> + <property name="text"> + <string>Cancel</string> + </property> + <property name="autoDefault"> + <bool>false</bool> + </property> + <property name="default"> + <bool>false</bool> + </property> + <property name="flat"> + <bool>false</bool> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/libs/installer_wizard/src/ui/wizardinstallererror.ui b/libs/installer_wizard/src/ui/wizardinstallererror.ui new file mode 100644 index 0000000..d364001 --- /dev/null +++ b/libs/installer_wizard/src/ui/wizardinstallererror.ui @@ -0,0 +1,103 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>WizardInstallerError</class> + <widget class="QWidget" name="WizardInstallerError"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>652</width> + <height>476</height> + </rect> + </property> + <property name="windowTitle"> + <string notr="true"/> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QLabel" name="iconLabel"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string notr="true"/> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>10</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="titleLabel"> + <property name="text"> + <string notr="true"/> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QTextEdit" name="messageEdit"> + <property name="readOnly"> + <bool>true</bool> + </property> + <property name="markdown"> + <string notr="true"/> + </property> + <property name="html"> + <string notr="true"><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html></string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/libs/installer_wizard/src/ui/wizardinstallerpage.ui b/libs/installer_wizard/src/ui/wizardinstallerpage.ui new file mode 100644 index 0000000..2e31c83 --- /dev/null +++ b/libs/installer_wizard/src/ui/wizardinstallerpage.ui @@ -0,0 +1,171 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>WizardInstallerPage</class> + <widget class="QWidget" name="WizardInstallerPage"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>652</width> + <height>476</height> + </rect> + </property> + <property name="windowTitle"> + <string notr="true"/> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,2,0,1"> + <property name="spacing"> + <number>6</number> + </property> + <item> + <widget class="QFrame" name="selectDescriptionFrame"> + <property name="enabled"> + <bool>true</bool> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="frameShape"> + <enum>QFrame::NoFrame</enum> + </property> + <property name="frameShadow"> + <enum>QFrame::Plain</enum> + </property> + <property name="lineWidth"> + <number>1</number> + </property> + <property name="midLineWidth"> + <number>0</number> + </property> + <layout class="QVBoxLayout" name="verticalLayout_5"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>2</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>2</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout_4"> + <property name="spacing"> + <number>6</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="selectDescriptionLabel"> + <property name="frameShape"> + <enum>QFrame::StyledPanel</enum> + </property> + <property name="frameShadow"> + <enum>QFrame::Sunken</enum> + </property> + <property name="text"> + <string notr="true"/> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + <property name="margin"> + <number>0</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QLabel" name="optionLabel"> + <property name="text"> + <string>Options:</string> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2" stretch="1,1"> + <item> + <widget class="QListWidget" name="optionList"> + <property name="resizeMode"> + <enum>QListView::Adjust</enum> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="imageLabel"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Ignored" vsizetype="Ignored"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string/> + </property> + <property name="scaledContents"> + <bool>false</bool> + </property> + <property name="alignment"> + <set>Qt::AlignCenter</set> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QLabel" name="descriptionLabel"> + <property name="text"> + <string>Description:</string> + </property> + </widget> + </item> + <item> + <widget class="QTextEdit" name="descriptionTextEdit"> + <property name="frameShape"> + <enum>QFrame::WinPanel</enum> + </property> + <property name="readOnly"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/libs/installer_wizard/src/ui/wizardinstallerrequires.ui b/libs/installer_wizard/src/ui/wizardinstallerrequires.ui new file mode 100644 index 0000000..63bd607 --- /dev/null +++ b/libs/installer_wizard/src/ui/wizardinstallerrequires.ui @@ -0,0 +1,220 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>WizardInstallerRequires</class> + <widget class="QWidget" name="WizardInstallerRequires"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>652</width> + <height>476</height> + </rect> + </property> + <property name="windowTitle"> + <string notr="true"/> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string><html><head/><body><p><span style=" font-weight:600;">Warning:</span> The following version requirements are not met for using this installer.</p></body></html></string> + </property> + <property name="margin"> + <number>10</number> + </property> + </widget> + </item> + <item> + <widget class="QGroupBox" name="groupBox"> + <property name="title"> + <string>Requirements</string> + </property> + <layout class="QGridLayout" name="gridLayout_2"> + <item row="0" column="0"> + <layout class="QGridLayout" name="gridLayout"> + <item row="3" column="0"> + <widget class="QLabel" name="labelGraphicsExtender"> + <property name="text"> + <string>Graphics Extender</string> + </property> + <property name="headercell" stdset="0"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="4" column="3"> + <widget class="QLabel" name="labelWryeBashIcon"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="1" column="2"> + <widget class="QLabel" name="labelGameHave"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="4" column="0"> + <widget class="QLabel" name="labelWryeBash"> + <property name="text"> + <string>Wrye Bash</string> + </property> + <property name="headercell" stdset="0"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QLabel" name="label_18"> + <property name="text"> + <string>Need</string> + </property> + <property name="headercell" stdset="0"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="3" column="1"> + <widget class="QLabel" name="labelGraphicsExtenderNeed"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="labelGame"> + <property name="text"> + <string>Game</string> + </property> + <property name="headercell" stdset="0"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QLabel" name="labelScriptExtenderNeed"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="4" column="1"> + <widget class="QLabel" name="labelWryeBashNeed"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="2" column="3"> + <widget class="QLabel" name="labelScriptExtenderIcon"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="4" column="2"> + <widget class="QLabel" name="labelWryeBashHave"> + <property name="text"> + <string>N/A</string> + </property> + </widget> + </item> + <item row="0" column="2"> + <widget class="QLabel" name="label_19"> + <property name="text"> + <string>Have</string> + </property> + <property name="headercell" stdset="0"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="3" column="2"> + <widget class="QLabel" name="labelGraphicsExtenderHave"> + <property name="text"> + <string>N/A</string> + </property> + </widget> + </item> + <item row="1" column="3"> + <widget class="QLabel" name="labelGameIcon"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="labelScriptExtender"> + <property name="text"> + <string>Script Extender</string> + </property> + <property name="headercell" stdset="0"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="2" column="2"> + <widget class="QLabel" name="labelScriptExtenderHave"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="3" column="3"> + <widget class="QLabel" name="labelGraphicsExtenderIcon"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QLabel" name="labelGameNeed"> + <property name="text"> + <string/> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Preferred</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/libs/installer_wizard/src/utils.py b/libs/installer_wizard/src/utils.py new file mode 100644 index 0000000..da85c48 --- /dev/null +++ b/libs/installer_wizard/src/utils.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import re +from collections.abc import Sequence +from pathlib import Path +from typing import cast + +from wizard.tweaks import WizardINISetting, WizardINISettingEdit + + +def make_obscript_ini_tweaks(tweaks: Sequence[WizardINISetting]) -> str: + lines: list[str] = [] + + for tweak in tweaks: + if not isinstance(tweak, WizardINISettingEdit): + continue + line = f"{tweak.section} {tweak.setting} to {tweak.value}" + if tweak.comment: + line += f" ; {tweak.comment}" + + lines.append(line) + + return "\n".join(["; Generated by Mod Organizer 2 via Wizard"] + sorted(lines)) + + +def make_standard_ini_tweaks(tweaks: Sequence[WizardINISetting]) -> str: + # group Tweaks per section + sections: dict[str, list[WizardINISetting]] = {m.section: [] for m in tweaks} + for m in tweaks: + sections[m.section].append(m) + + lines: list[str] = [] + + for k in sorted(sections): + s_tweaks = sorted(sections[k], key=lambda m: m.setting) + lines.append(f"[{k}]") + for tw in s_tweaks: + if isinstance(tw, WizardINISettingEdit): + line = f"{tw.setting} = {tw.value}" + if tw.comment: + line += f" # {tw.comment}" + else: + line = f"# {tw.setting} - disabled" + lines.append(line) + lines.append("\n") + + return "\n".join(lines[:-1]) + + +def merge_standard_ini_tweaks(tweaks: Sequence[WizardINISetting], file: Path) -> str: + import sys + + print(f"Cannot merge INI Tweaks for {file.name}.", file=sys.stderr) + return make_standard_ini_tweaks(tweaks) + + +def merge_obscript_ini_tweaks(tweaks: Sequence[WizardINISetting], file: Path) -> str: + # this is from Wrye Bash (and the function is inspired from Wrye Bash) + reComment = re.compile(";.*", re.U) + reDeleted = re.compile("" r";-(\w.*?)$", re.U) + reSet = re.compile("" r"\s*set\s+(.+?)\s+to\s+(.*)", re.I | re.U) + reSetGS = re.compile("" r"\s*setGS\s+(.+?)\s+(.*)", re.I | re.U) + reSetNGS = re.compile("" r"\s*SetNumericGameSetting\s+(.+?)\s+(.*)", re.I | re.U) + + _regex_tuples = ( + (reSet, "set", "set {} to {}"), + (reSetGS, "setgs", "setGS {} {}"), + (reSetNGS, "setnumericgamesetting", "SetNumericGameSetting {} {}"), + ) + + def _parse_obse_line(line: str): + for regex, sectionKey, format_string in _regex_tuples: + match = regex.match(line) + if match: + return match, sectionKey, format_string + return None, None, None + + # read the original file + with open(file, "r") as fp: + olines = fp.readlines() + + # map setting name to value + settings: dict[str, dict[str, WizardINISettingEdit]] = {} + deleted: dict[str, dict[str, WizardINISetting]] = {} + for tweak in tweaks: + if isinstance(tweak, WizardINISettingEdit): + if tweak.section not in settings: + settings[tweak.section.lower()] = {} + settings[tweak.section.lower()][tweak.setting] = tweak + else: + if tweak.section not in deleted: + deleted[tweak.section.lower()] = {} + deleted[tweak.section.lower()][tweak.setting] = tweak + + # create the lines + lines: list[str] = [] + for line in olines: + line = line.rstrip() + maDeleted = reDeleted.match(line) + if maDeleted: + stripped = maDeleted.group(1) + else: + stripped = line + stripped = reComment.sub("", stripped).strip() + + match, section_key, format_string = _parse_obse_line(stripped) + + if match: + assert format_string is not None + setting = match.group(1) + if section_key in settings and setting in settings[section_key]: + value = settings[section_key][setting].value + line = format_string.format(setting, value) + comment = "" + if settings[section_key][setting].comment: + comment = cast(str, settings[section_key][setting].comment) + comment += " " + comment += f"(set by MO2 via Wizard, was {match.group(2)})" + line = f"{line} ; {comment}" + del settings[section_key][setting] + elif ( + not maDeleted + and section_key in deleted + and setting in deleted[section_key] + ): + line = f";-{line}" + + lines.append(line) + + for section in settings.values(): + line = "" + for setting in section.values(): + if setting.section.lower() == "set": + line = f"{setting.section} {setting.setting} to {setting.value}" + else: + line = f"{setting.section} {setting.setting} {setting.value}" + + if setting.comment: + comment = setting.comment + " (set by MO2 via Wizard)" + else: + comment = "(set by MO2 via Wizard)" + line = f"{line} ; {comment}" + + lines.append(line) + + return "\n".join(lines) + + +def make_ini_tweaks(tweaks: Sequence[WizardINISetting]) -> str: + is_set = [ + tw.section.lower() in ("set", "setgs", "setnumericgamesetting") for tw in tweaks + ] + + # assume there is no mix + if all(is_set): + return make_obscript_ini_tweaks(tweaks) + else: + return make_standard_ini_tweaks(tweaks) + + +def merge_ini_tweaks(tweaks: Sequence[WizardINISetting], file: Path) -> str: + is_set = [ + tw.section.lower() in ("set", "setgs", "setnumericgamesetting") for tw in tweaks + ] + + # assume there is no mix + if all(is_set): + return merge_obscript_ini_tweaks(tweaks, file) + else: + return merge_standard_ini_tweaks(tweaks, file) diff --git a/libs/installer_wizard/vcpkg.json b/libs/installer_wizard/vcpkg.json new file mode 100644 index 0000000..a27b7c5 --- /dev/null +++ b/libs/installer_wizard/vcpkg.json @@ -0,0 +1,17 @@ +{ + "features": { + "standalone": { + "description": "Build Standalone.", + "dependencies": [ + "mo2-cmake" + ] + } + }, + "vcpkg-configuration": { + "default-registry": { + "kind": "git", + "repository": "https://github.com/ModOrganizer2/vcpkg-registry", + "baseline": "69db61b22147b14ad66fd05cf798d855f6d68c12" + } + } +} |
