code:
"""
CK3 Localization Helper script by erri120 under GPLv3.

Usage:

Updating translations:

python localization_helper.py update --lang german

Viewing statistics:

python localization_helper.py stats

Viewing statistics for a specific language:

python localization_helper.py stats --lang german

"""

import os;
import argparse;
import sys;
import glob;
import shutil;
from typing import Tuple, List;

#key, value, translated, comment
Yaml = Tuple[str, str, bool, bool];

BASE_LANGUAGE = 'english';
ENCODING = 'utf-8-sig';

localization_dir = os.path.abspath('localization');
base_dir = os.path.join(localization_dir, BASE_LANGUAGE);

def create_empty_list(count: int) -> List[str]:
    """Creates a new empty list"""
    return [''] * count;

def is_empty(string: str) -> bool:
    """Checks if the string is empty or only has whitespaces"""

    if not string:
        return True;
    return string.isspace();

def get_lang_name(lang: str) -> str:
    """Returns the name ending for a language."""

    return 'l_'+lang;

def get_yaml(string: str) -> Yaml:
    """Gets a YAML Tuple for the current line"""

    empty = ('', '', False, False);

    if is_empty(string):
        return empty;

    line = string.strip();
    comment_index = -1;

    try:
        comment_index = line.index('#');
        if comment_index == 0:
            return ('', '', False, True);
    except ValueError:
        pass;

    line_len = len(line);
    index = -1;
    key = '';
    value = '';
    translated = False;

    try:
        index = line.index(':');
    except ValueError:
        print('Unable to get index of : in line \"'+line+'\"');
        return empty;

    key = line[0:index];

    if line_len <= index+1:
        return (key, value, translated, False);

    if not is_empty(line[index+1]):
        translation_value = line[index+1];
        if not translation_value in ('0', '1'):
            print('Unknown translation value \"'+translation_value+'\" in line \"'+line+'\"');
        else:
            translated = translation_value == '1';
        value = line[index+2:].strip();
    else:
        value = line[index+1:].strip();

    result = (key, value, translated, False);
    return result;

def get_yaml_from_file(path: str) -> List[Yaml]:
    """Reads a file and returns a list of all YAML values"""

    yaml = list();
    with open(path, 'r', encoding=ENCODING) as file_stream:
        line = file_stream.readline();
        while line:
            yaml.append(get_yaml(line));
            line = file_stream.readline();

    return yaml;

def yaml_to_string(yaml: Yaml, include_translate: bool = True) -> str:
    """Converst a YAML Tuple to string"""
    translate_str = '' if not include_translate else '1' if yaml[2] else '0';
    return yaml[0]+':'+translate_str+' '+yaml[1]+'\n';

def update_translation_file(input_path: str, output_path: str, output_lang: str):
    """Updates the provided file with new translations"""

    output_lang_name = get_lang_name(output_lang);

    print('Updating file '+output_path);

    if not os.path.exists(output_path):
        print('File does not exist, copying from '+input_path+' to '+output_path);
        shutil.copyfile(input_path, output_path);

    input_yaml = get_yaml_from_file(input_path);
    output_yaml = get_yaml_from_file(output_path);

    input_len = len(input_yaml);
    output_len = len(output_yaml);
    new_lines = create_empty_list(input_len);

    if output_len != input_len:
        print('Input length does not equal output length: '+str(input_len)+'!='+str(output_len));

    for i in range(input_len):
        cur_input = input_yaml[i];
        # is comment
        if cur_input[3]:
            continue;

        if i == 0:
            new_lines[0] = output_lang_name+':\n';
            continue;

        if is_empty(cur_input[0]):
            new_lines[i] = '\n';
            continue;

        # searching for a YAML tuple with the same key as the current
        found_in_output = next((x for x in output_yaml if x[0] == cur_input[0]), ('', '', False));

        # not found means we just add the input
        if not found_in_output[0]:
            print('Missing key '+ cur_input[0]);
            new_lines[i] = yaml_to_string(cur_input);
            continue;

        # if we found it in the output file we check if it's translated
        if found_in_output[2]:
            new_lines[i] = yaml_to_string(found_in_output);
            continue;

        # if it's not translated then we copy the input
        new_lines[i] = yaml_to_string(cur_input);

    with open(output_path, 'w', encoding=ENCODING) as file_stream:
        file_stream.writelines(new_lines);

def copy_base_translations(input_dir: str, output_dir: str, input_lang: str, output_lang: str):
    """
    Recursively copies the contents of one folder to another and
    calls the updateFile function on every file.
    """

    input_lang_file_name = get_lang_name(input_lang)+'.yml';
    output_lang_file_name = get_lang_name(output_lang)+'.yml';

    if not os.path.exists(output_dir):
        os.mkdir(output_dir);
    items = glob.glob(input_dir + '/*');
    for item in items:
        if os.path.isdir(item):
            path = os.path.join(output_dir, item.split(os.path.sep)[-1]);
            copy_base_translations(item, path, input_lang, output_lang);
        else:
            file_name = item.split(os.path.sep)[-1];
            if file_name.endswith(input_lang_file_name):
                file_name = file_name.replace(input_lang_file_name, output_lang_file_name);
            path = os.path.join(output_dir, file_name);
            update_translation_file(item, path, output_lang);

def updating(output_language: str):
    """Updates all Translations"""

    print('Updating Translation for Language: '+output_language);
    output_dir = os.path.join(localization_dir, output_language);

    print('Using '+BASE_LANGUAGE+' Translations from '+base_dir+' in '+output_dir);
    copy_base_translations(base_dir, output_dir, BASE_LANGUAGE, output_language);

def get_translation_stats_file(base: str, path: str, cur_lang: str, detail: bool) -> Tuple[str, int, int]:
    """Gets the number of translated and not translated string of a file"""

    yaml = get_yaml_from_file(path);

    total = 0;
    translated = 0;
    not_translated = 0;

    lang_name = get_lang_name(cur_lang);

    for x in yaml:
        if x[3]:
            continue;
        if is_empty(x[0]):
            continue;
        if x[0] == lang_name:
            continue;

        total += 1;
        if x[2]:
            translated += 1;
        else:
            if detail:
                print(path+' '+x[0]);
            not_translated += 1;

    return (path.replace(base, ''), translated, not_translated);

def get_translation_stats_folder(base: str, folder: str, cur_lang: str, detail: bool) -> List[Tuple[str, int, int]]:
    """Gets Translation stats for a folder"""

    result = list();

    items = glob.glob(folder + '\\*');
    for item in items:
        if os.path.isdir(item):
            dir_stats = get_translation_stats_folder(base, item, cur_lang, detail);
            for stat in dir_stats:
                result.append(stat);
        else:
            file_stats = get_translation_stats_file(base, item, cur_lang, detail);
            result.append(file_stats);

    return result;

def add(enumerable: List[int]) -> int:
    """Adds all numbers of a list"""

    i = 0;
    for x in enumerable:
        i += x;
    return i;

def stats(lang: str):
    """Displays Translation Statistics"""

    print('Translation Statistics:\n');
    print('%-15s %10s %7s %7s' % ('Language ', 'Translated', 'Missing', 'Ratio'))

    languages = glob.glob(localization_dir+'\\*');
    detail = lang != '';
    for language in languages:
        name = language.replace(localization_dir+'\\', '');
        if name == BASE_LANGUAGE:
            continue;
        if detail and lang != name:
            continue;
        lang_stats = get_translation_stats_folder(language+'\\', language, name, detail);
        translated = list(map(lambda x: x[1], lang_stats));
        missing = list(map(lambda x: x[2], lang_stats));
        total_translated = add(translated);
        total_missing = add(missing);
        total = total_translated+total_missing;
        ratio = (total_translated/total)*100;

        ratio_str = f'{ratio:.2f}%';

        print('%-15s %10s %7s %7s' % (name, total_translated, total_missing, ratio_str))

def main():
    """Main"""
    parser = argparse.ArgumentParser('Localization Helper',
        description='Localization Helper by erri120',
        allow_abbrev=False);
    parser.add_argument('verb',
        help='Translation Update or Statistics',
        choices=('update', 'stats'));
    parser.add_argument('--lang',
        action='store',
        type=str,
        metavar='Language',
        help='Language for Translation Updates');
    args = parser.parse_args();

    lang = args.lang;
    if args.verb == 'update':
        if lang is None:
            print('update requires --lang to be set!');
            sys.exit(-1);
        updating(lang);
    else:
        if lang is None:
            stats('');
        else:
            stats(lang);

if __name__ == '__main__':
    main();


code:
regula_opening_book = {
	music = "file:/music/dragon-ball-super-ost-57-regret.ogg"
	can_be_interrupted = yes
}

regula_dies_irae = {
    music = "file:/music/dies_irae.ogg"
    can_be_interrupted = yes
}

regula_keeper_released = {
    music = "file:/music/saiyajin-kitaru-part-2.ogg"
    can_be_interrupted = yes
}

license: mit


code:
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
#   For a library or package, you might want to ignore these files since the code is
#   intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# poetry
#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock

# pdm
#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
#   in version control.
#   https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

# PyCharm
#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
#  and can be added to the global gitignore or merged into this file.  For a more nuclear
#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

Mods/
providers/
!libidinous_loader.spec

path: .gitignore

license: unknown


code:
stages:
  - build
  - package
  - release

variables:
  PACKAGE_REGISTRY_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages"
  TEST_PACKAGE_LINUX: "${PACKAGE_REGISTRY_URL}/generic/linux/test/libidinous_loader"
  TEST_PACKAGE_LINUX_APPIMAGE: "${PACKAGE_REGISTRY_URL}/generic/linux/test/libidinous_loader.AppImage"
  TEST_PACKAGE_WINDOWS: "${PACKAGE_REGISTRY_URL}/generic/windows/test/libidinous_loader.exe"

build-linux:
  stage: build
  image: "python:3.12"
  before_script:
    - apt-get update && apt-get install make
    - >
      if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
        echo "$CI_COMMIT_SHA" > commit_id;
        cat commit_id;
      fi;
  script:
    - make
  artifacts:
    expire_in: 1 day
    paths:
      - dist/libidinous_loader

build-linux-appimage:
  stage: build
  image: "python:3.12-bookworm"
  before_script:
    - apt-get update && apt-get install -y make libglu1-mesa-dev libgl1-mesa-dev xorg-dev libxrandr-dev desktop-file-utils
    - >
      if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
        echo "$CI_COMMIT_SHA" > commit_id;
        cat commit_id;
      fi;
  script:
    - make appimage
  artifacts:
    expire_in: 1 day
    paths:
      - dist/libidinous_loader.AppImage

build-win:
  stage: build
  image: "ggcr.io/ablativeabsolute/libidinous_loader/pyinstaller-wine"
  before_script:
    - pwd
    - >
      if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
        echo "$CI_COMMIT_SHA" > commit_id;
        cat commit_id;
      fi;
  script:
    - PYTHON_EXECUTABLE=python NO_VENV=1 NO_UPGRADE_PIP=1 make
  artifacts:
    expire_in: 1 day
    paths:
      - dist/libidinous_loader.exe

clear-test-packages:
  stage: package
  image: "registry.gitlab.com/gitlab-ci-utils/curl-jq"
  needs:
    - build-linux
    - build-linux-appimage
    - build-win
  variables:
    GIT_STRATEGY: none
  allow_failure: true
  script:
    - echo "Trying to remove Linux test files"
    - |
      LINUX_TEST_PACKAGE_ID=$(curl -s -H "JOB-TOKEN: $CI_JOB_TOKEN" -X GET "$PACKAGE_REGISTRY_URL" -H "Accept: application/json" | jq -r '.[] | select((.name=="linux") and (.version=="test")) | .id')
    - echo "$LINUX_TEST_PACKAGE_ID"
    - >
      if [ "$LINUX_TEST_PACKAGE_ID" != "null" ]; then
        FILE_IDS=$(curl -s -H "JOB-TOKEN: $CI_JOB_TOKEN" -X GET "$PACKAGE_REGISTRY_URL/$LINUX_TEST_PACKAGE_ID/package_files" -H "Accept: application/json" | jq -r '.[].id');
        echo $FILE_IDS;
        for id in $FILE_IDS; do
            curl -H "JOB-TOKEN: $CI_JOB_TOKEN" -X DELETE "$PACKAGE_REGISTRY_URL/$LINUX_TEST_PACKAGE_ID/package_files/$id";
        done;
      fi;

    - echo "Trying to remove Windows test files"
    - |
      WINDOWS_TEST_PACKAGE_ID=$(curl -s -H "JOB-TOKEN: $CI_JOB_TOKEN" -X GET "$PACKAGE_REGISTRY_URL" -H "Accept: application/json" | jq -r '.[] | select((.name=="windows") and (.version=="test")) | .id');
    - echo "$WINDOWS_TEST_PACKAGE_ID"
    - >
      if [ "$WINDOWS_TEST_PACKAGE_ID" != "null" ]; then
        FILE_IDS=$(curl -s -H "JOB-TOKEN: $CI_JOB_TOKEN" -X GET "$PACKAGE_REGISTRY_URL/$WINDOWS_TEST_PACKAGE_ID/package_files" -H "Accept: application/json" | jq -r '.[].id');
        echo $FILE_IDS;
        for id in $FILE_IDS; do
            curl -H "JOB-TOKEN: $CI_JOB_TOKEN" -X DELETE "$PACKAGE_REGISTRY_URL/$WINDOWS_TEST_PACKAGE_ID/package_files/$id";
        done;
      fi;
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      #changes:
      #  - .gitlab-ci.yml
      #  - requirements.txt
      #  - src/**/*

upload-test-packages:
  stage: package
  image: "curlimages/curl:latest"
  needs:
    - build-linux
    - build-linux-appimage
    - build-win
    - clear-test-packages
  variables:
    GIT_STRATEGY: none
  script:
    - echo "${TEST_PACKAGE_LINUX}"
    - 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file dist/libidinous_loader "${TEST_PACKAGE_LINUX}"'
    - echo "${TEST_PACKAGE_LINUX_APPIMAGE}"
    - 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file dist/libidinous_loader.AppImage "${TEST_PACKAGE_LINUX_APPIMAGE}"'
    - echo "${TEST_PACKAGE_WINDOWS}"
    - 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file dist/libidinous_loader.exe "${TEST_PACKAGE_WINDOWS}"'
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      #changes:
      #  - .gitlab-ci.yml
      #  - requirements.txt
      #  - src/**/*

mirror-test-packages:
  stage: package
  image:
    name: "ggcr.io/ablativeabsolute/mirror_uploader/mirror_uploader:latest"
    entrypoint: [""]
  needs:
    - build-linux
    - build-linux-appimage
    - build-win
    - clear-test-packages
  variables:
    GIT_STRATEGY: none
  script:
    - echo ${#MIRRORACE_API_KEY}
    - echo ${#MIRRORACE_API_TOKEN}
    - mirror_uploader dist/libidinous_loader.AppImage > mirror_appimage.txt
    - mirror_uploader dist/libidinous_loader.exe > mirror_windows.txt
  allow_failure: true
  artifacts:
    expire_in: 3 days
    paths:
      - mirror_appimage.txt
      - mirror_windows.txt
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      #changes:
      #  - .gitlab-ci.yml
      #  - requirements.txt
      #  - src/**/*


code:
PYTHON_EXECUTABLE ?= python3

OUTPUT_PATH ?= dist

APP_NAME = libidinous_loader
APP_ID = io.gitgud.ablativeabsolute.libidinous_loader
APP_AUTHOR = AblativeAbsolute
APP_DESCRIPTION = "A loader to moisturize your RimWorld experience."

# virtual environment dirname
VENV = venv

# path to the virtual environment's bin directory
ifdef NO_VENV
	BIN ?= /usr/bin/
else
	BIN = $(VENV)/bin/
endif

ifeq ($(OS), Windows_NT)
	BIN ?= $(VENV)/Scripts/
	PYTHON_EXECUTABLE ?= python
endif

package: $(VENV)
	$(BIN)pyinstaller --distpath $(OUTPUT_PATH) --workpath build --clean -y libidinous_loader.spec

appimage: clean $(VENV)
	$(BIN)pip install --upgrade pydeployment
	$(BIN)pydeploy -y --log DEBUG --id $(APP_ID) --appname $(APP_NAME) --author $(APP_AUTHOR) --publisher $(APP_AUTHOR) --description $(APP_DESCRIPTION) -r requirements.txt --appimage-extract-and-run libidinous_loader.spec -- -- --appimage
	#$(BIN)pip install --upgrade git+https://github.com/AblativeAbsolute/pydeployment@hotfix-appimage-extract-and-run
	#$(BIN)pydeploy -y --log DEBUG --id $(APP_ID) --appname $(APP_NAME) --author $(APP_AUTHOR) --publisher $(APP_AUTHOR) --description $(APP_DESCRIPTION) -r requirements.txt libidinous_loader.spec -- -- --appimage
	mv $(OUTPUT_PATH)/*.AppImage $(OUTPUT_PATH)/$(APP_NAME).AppImage

$(VENV): requirements.txt
ifndef NO_VENV
	$(PYTHON_EXECUTABLE) -m venv --clear $(VENV)
endif
ifndef NO_UPGRADE_PIP
	$(BIN)pip install --upgrade pip
endif
	$(BIN)pip install --upgrade -r requirements.txt
	$(BIN)pip install pyinstaller
	touch $(VENV)

pretty:
	isort src/ || \
	ruff check --fix src/

clean:
	rm -rf __pycache__
	rm -rf $(VENV)
	rm -rf dist
	rm -rf build

path: Makefile


code:
# Libidinous Loader

A loader to moisturize your RimWorld experience.

![GUI](libidinous_loader.png)

# How to build
This requires GNU make and Python 3.6+
```bash
make
```
The executable can be found in `dist/`.

Supported environment variables:
- `PYTHON_EXECUTABLE`: specify the path to a custom python installation
- `OUTPUT_PATH`: where to store the executable
- `NO_VENV`: do not use a virtual enviroment
- `NO_UPGRADE_PIP`: do not upgrade pip before using it
- `BIN`: specify the location of python binaries. Checked if `NO_VENV` is set

# How to contribute
- Split your changes into meaningful commits
- Run `make pretty`

# How to use
- Install git.
- Put the executable in an empty directory and run it from there
- Select your `Mods` directory
- Select the mods you want to install/update
- Press `Install`
- Wait until the progress bar displays `Done`

# Credits
- RimWorld font by [Marnador](https://ludeon.com/forums/index.php?topic=11022.msg109601#msg109601)
- Inconsolata font by [raphlinus](https://github.com/googlefonts/Inconsolata/releases/tag/v3.000)


code:
# -*- mode: python ; coding: utf-8 -*-
import argparse
import os

parser = argparse.ArgumentParser()
parser.add_argument("--appname", default='libidinous_loader')
parser.add_argument("--appimage", action="store_true")
options = parser.parse_args()

datas = [
	('rsrc/RimWorldFont.ttf', './rsrc/'),
	('rsrc/Inconsolata-Regular.otf', './rsrc/'),
	('rsrc/schema-v0.json', './rsrc/')
]
for image in os.listdir('rsrc/title_textures'):
	datas.append(('rsrc/title_textures/'+image, './rsrc/title_textures/'))

if os.path.isfile('commit_id'):
	datas.append(('commit_id', './rsrc/'))

a = Analysis(
	['src/libidinous_loader/__init__.py'],
	pathex=['src/libidinous_loader/'],
	binaries=[],
	datas=datas,
	hiddenimports=[],
	hookspath=[],
	hooksconfig={},
	runtime_hooks=[],
	excludes=[],
	noarchive=False,
	optimize=0,
)
pyz = PYZ(a.pure)

if not options.appimage:
	exe = EXE(
		pyz,
		a.scripts,
		a.binaries,
		a.datas,
		[],
		name=options.appname,
		debug=False,
		bootloader_ignore_signals=False,
		strip=False,
		upx=True,
		upx_exclude=[],
		runtime_tmpdir=None,
		console=True,
		disable_windowed_traceback=False,
		argv_emulation=False,
		target_arch=None,
		codesign_identity=None,
		entitlements_file=None,
	)
else:
	exe = EXE(
	pyz,
		a.scripts,
		[],
		exclude_binaries=True,
		name=options.appname,
		debug=False,
		bootloader_ignore_signals=False,
		strip=False,
		upx=True,
		console=False
	)

	coll = COLLECT(
		exe,
		a.binaries,
		a.datas,
		name=options.appname,
	)


code:
[tool.ruff]
# Exclude a variety of commonly ignored directories.
exclude = [
	".bzr",
	".direnv",
	".eggs",
	".git",
	".git-rewrite",
	".hg",
	".ipynb_checkpoints",
	".mypy_cache",
	".nox",
	".pants.d",
	".pyenv",
	".pytest_cache",
	".pytype",
	".ruff_cache",
	".svn",
	".tox",
	".venv",
	".vscode",
	"__pypackages__",
	"_build",
	"buck-out",
	"build",
	"dist",
	"node_modules",
	"site-packages",
	"venv",
]

# Same as Black.
line-length = 88
indent-width = 4

# Assume Python 3.12
target-version = "py312"

[tool.ruff.lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`)  codes by default.
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
# McCabe complexity (`C901`) by default.
select = ["E4", "E7", "E9", "F"]
ignore = []

# Allow fix for all enabled rules.
fixable = ["ALL"]
unfixable = []

# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"

[tool.ruff.format]
# Like Black, use double quotes for strings.
quote-style = "double"

# Unlike Black, indent with tabs, rather than spaces.
indent-style = "tab"

# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false

# Enforce LF line ending.
line-ending = "lf"

# Enable auto-formatting of code examples in docstrings. Markdown,
# reStructuredText code/literal blocks and doctests are all supported.
#
# This is currently disabled by default, but it is planned for this
# to be opt-out in the future.
docstring-code-format = false

# Set the line length limit used when formatting code snippets in
# docstrings.
#
# This only has an effect when the `docstring-code-format` setting is
# enabled.
docstring-code-line-length = "dynamic"

# Multi line output of isort not supported
# https://github.com/astral-sh/ruff/issues/2600

[tool.isort]
py_version = 312 # Python version
multi_line_output = 3  # Enables vertical hanging indent
include_trailing_comma = true  # Adds trailing commas
use_parentheses = true  # Wraps imports in parentheses
indent = "\t"


code:
urllib3
dearpygui==1.11.1
certifi
fastjsonschema
defusedxml
tomlkit
can-ada
psutil
pyoxipng


title: rjw mod provider config file

description: this aims to provide a centralized way for RJW install scripts to discover installation recipes.

type: object

description: config format version

type: integer

description: category

description: mod

description: type of installation

type: string

enum: git zip

description: name in the Mods directory

$ref: #/definitions/limitedCharSet

description: description of the mod

$ref: #/definitions/nonEmptyString

description: url of additional information about the mod

$ref: #/definitions/url

description: Array of authors from `authors`. Meant for additional information, not attribution

type: array

description: url of git repository or file

description: which branch to checkout

description: leading path components to be stripped

required: type name description url

description: author id

description: how the name of the author should be displayed

description: array of urls for donations

description: array of urls for other purposes

required: version providers authors

format: uri


code:
import itertools
import locale
import logging
import multiprocessing
import os
import platform
import sys
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

import dearpygui.dearpygui as dpg
from app_version import has_latest_version
from environment import get_resource_path, get_script_path
from git_wrapper import (
	git_is_installed,
	git_is_repo,
	git_repo_checkout_branch,
	git_repo_current_branch,
	git_repo_get_branches,
	git_repo_pull,
)
from hacks import shutil_rmtree_with_retries
from image_manipulation import optimize_png
from local_mods import LocalMod, get_local_mods
from providers import Providers
from state import state_dump, state_load
from ui_helper import (
	align_items,
	blank,
	load_title_texture,
	make_help_text,
	make_key_value_table,
	make_title,
	register_themes,
	switch_tab,
	webbrowser_open,
	wrap_text,
)

PROVIDERS_REPO_URL = "https://gitgud.io/AblativeAbsolute/libidinous_loader_providers.git"
USER_PROVIDERS_PATH = Path(get_script_path(), "user.toml")

# Contains information about mod names, categories and location. Populated with mod_items_display()
# Key: mod name from PROVIDERS, value: dearpygui item id
MOD_ITEMS = dict()
USER_ITEMS = dict()

# Key: cat name from PROVIDERS, value: dearpygui item id
CATEGORY_ITEMS = dict()

# Cache of images loaded from local mods
LOCAL_MODS_IMAGE_ITEMS = []

AUTHOR_NAME = "AblativeAbsolute"
AUTHOR_URL = "https://www.loverslab.com/topic/231570-libidinous-loader-rjw-mod-loader-and-providers-list/"
APP_REPO_URL = "https://gitgud.io/AblativeAbsolute/libidinous_loader.git"
GIT_DOWNLOAD_URL = "https://git-scm.com/downloads"

STATE_FILE_OLD = Path(get_script_path(), 'last')
STATE_FILE = Path(get_script_path(), 'state.json')
LOCK_FILE = Path(get_script_path(), 'lock')
LOCK_FILE_EXISTED_ON_STARTUP = os.path.isfile(LOCK_FILE)
COMMIT_ID_FILE = Path(get_resource_path(), 'commit_id')
CANCEL_EVENT_UPDATE = threading.Event()
CANCEL_EVENT_LOCAL_MODS_OPTIMIZE = threading.Event()
MAX_UPDATE_WORKERS = os.cpu_count()

LAST_LOG_FILE = Path(get_script_path(), "last.log")
logging.basicConfig(
	level=logging.INFO,
	format="%(asctime)s:%(levelname)s:%(threadName)s:%(name)s:%(funcName)s:%(message)s",
	datefmt="%Y-%m-%d %H:%M:%S%z",
	handlers=[
		logging.FileHandler(LAST_LOG_FILE, mode='w'),
		logging.StreamHandler()
	]
)
LOGGER = logging.getLogger(__name__)

VIEWPORT_WIDTH = 600
VIEWPORT_HEIGHT = 800
MODAL_WIDTH = VIEWPORT_WIDTH - 6
FILE_DIALOG_WIDTH = 500
FILE_DIALOG_HEIGHT = 300
MAX_DISPLAYED_AUTHORS = 3

def exception_hook(exc_type, exc_value, exc_traceback):
	LOGGER.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
	#raise exc_type(exc_value).with_traceback(exc_traceback)
	# FIXME: yes, the real fix is to catch all exceptions properly
	if dpg.is_dearpygui_running(): # Fails in many instances. Oh well.
		dpg.destroy_context()
	sys.exit(-1)
sys.excepthook = exception_hook

LOGGER.info(f"platform info: {platform.platform()}")
LOGGER.info(f"uname: {platform.uname()}")
env_variables = '\n'.join(f"{key}={value}" for key, value in os.environ.items())
LOGGER.info(f"environment:\n{env_variables}\n")
LOGGER.info(f"locale.getencoding(): {locale.getencoding()}")
LOGGER.info(f"locale.getpreferredencoding(): {locale.getpreferredencoding()}")
LOGGER.info(f"cwd: {os.getcwd()}")
LOGGER.info(f"resource path: {get_resource_path()}")
LOGGER.info(f"script path: {get_script_path()}")
if os.path.isfile(COMMIT_ID_FILE):
	try:
		with open(COMMIT_ID_FILE, 'r') as f:
			COMMIT_ID = f.read().strip()
		LOGGER.info(f"commit id: {COMMIT_ID}")
	except Exception as e:
		LOGGER.warning(f"commit id file could not be read: {e}")
else:
	LOGGER.warning("commit id unknown")
	COMMIT_ID = None

MOD_FOLDER, LAST_XPOS, LAST_YPOS, PROVIDERS_REPO_URL, DEFAULT_SETTINGS = state_load(STATE_FILE, STATE_FILE_OLD, MAX_UPDATE_WORKERS)
LOGGER.info(f"{MOD_FOLDER=}")
LOGGER.info(f"{LAST_XPOS=}")
LOGGER.info(f"{LAST_YPOS=}")
LOGGER.info(f"{DEFAULT_SETTINGS=}")
LOGGER.info(f"{PROVIDERS_REPO_URL=}")

PROVIDERS = Providers(PROVIDERS_REPO_URL, USER_PROVIDERS_PATH)

def update_worker(selected_mod, mod_folder, allow_insecure_connections, fix_dubious_ownership):
	if CANCEL_EVENT_UPDATE.is_set():
		LOGGER.info("Cancel event is set. Not updating {selected_mod}. Exiting.")
		return (None, None)
	try:
		PROVIDERS.update_mod(selected_mod, mod_folder, allow_insecure_connections=allow_insecure_connections, fix_dubious_ownership=fix_dubious_ownership)
		return (True, None)
	except Exception as e:
		LOGGER.error(f"Could not complete task {selected_mod}: {e}")
		return (False, e)

def update_manager(selected_mods, mod_folder):
	total = len(selected_mods)
	progress = 0
	failed_updates = []
	n_update_workers = dpg.get_value("n_update_workers")
	allow_insecure_connections = dpg.get_value("setting_allow_insecure_connections")
	fix_dubious_ownership = dpg.get_value("setting_git_dubious_ownership_fix")

	dpg.set_value("progress", 0)
	dpg.configure_item("install_btn", show=False)
	dpg.configure_item("cancel_btn", show=True)
	dpg.configure_item("loading_indicator", show=True)
	dpg.configure_item("n_update_workers_group", show=False)

	# initial update of the providers repo
	dpg.configure_item("progress", overlay="Updating providers...")
	PROVIDERS.update_providers()
	dpg.configure_item("progress", overlay="Updating pinned providers...")
	PROVIDERS.update_pinned_providers()
	dpg.configure_item("progress", overlay=f"{progress}/{total}")

	with ThreadPoolExecutor(max_workers=n_update_workers, thread_name_prefix="update_executor") as executor:
		futures = {executor.submit(update_worker, selected_mod, mod_folder, allow_insecure_connections, fix_dubious_ownership): selected_mod for selected_mod in selected_mods}

		for future in as_completed(futures):
			finished_mod = futures[future]

			success, exception = future.result()
			if success is False:
				exception_message = str(exception)
				if type(exception) is PermissionError:
					exception_message = f"Failed to remove directory. Another process might be holding onto a file"
				failed_updates.append((finished_mod, exception_message))

			progress += 1
			dpg.set_value("progress", 1/total * (progress))
			if not CANCEL_EVENT_UPDATE.is_set():
				dpg.configure_item("progress", overlay=f"{progress}/{total}")

	dpg.configure_item("progress", overlay="Done" + (" (Cancelled)" if CANCEL_EVENT_UPDATE.is_set() else ''))
	if CANCEL_EVENT_UPDATE.is_set():
		CANCEL_EVENT_UPDATE.clear()
	dpg.configure_item("install_btn", show=True)
	dpg.configure_item("cancel_btn", show=False)
	dpg.configure_item("loading_indicator", show=False)
	dpg.configure_item("n_update_workers_group", show=True)
	if failed_updates:
		dpg.configure_item("update_status_window", show=True)
		dpg.set_value("update_status", wrap_text("\n".join([f"{fail[0]}: {fail[1]}" for fail in failed_updates])))
	else:
		dpg.configure_item("update_status_window", show=False)

	break_lock()
	LOGGER.info("Finished update")

	# After finishing the update, scan the mod folder for updates
	local_mods_scan()

def update_run():
	if is_locked():
		LOGGER.warning("Locked")
		return
	if not MOD_FOLDER:
		LOGGER.warning("Cannot run install without a mod folder")
		dpg.configure_item("progress", overlay="Cannot run install without a mod folder")
		return
	if CANCEL_EVENT_UPDATE.is_set():
		CANCEL_EVENT_UPDATE.clear()

	selected_mods = set()
	for mod_name, mod_item in MOD_ITEMS.items():
		if dpg.get_value(mod_item):
			selected_mods.add(mod_name)
	for mod_name, mod_item in USER_ITEMS.items():
		if dpg.get_value(mod_item):
			selected_mods.add(mod_name)

	if not selected_mods:
		dpg.configure_item("progress", overlay="No mods are selected")
		return

	LOGGER.info(f"Installing: {selected_mods}")

	set_lock()
	thread = threading.Thread(target=update_manager, args=(selected_mods, MOD_FOLDER), daemon=True)
	thread.start()

def update_cancel():
	if CANCEL_EVENT_UPDATE.is_set():
		return
	CANCEL_EVENT_UPDATE.set()
	dpg.configure_item("progress", overlay="Cancelling. Waiting for running updates to finish...")

def mod_folder_select(sender, app_data):
	global MOD_FOLDER

	selected_path = app_data['file_path_name']
	if not os.path.isdir(selected_path):
		LOGGER.error(f"The selected path is not valid: {selected_path}")
		return

	MOD_FOLDER = selected_path
	dpg.set_value("mod_folder", MOD_FOLDER)
	mod_items_select_existing_only(MOD_FOLDER)

def mod_items_display(filter="", refetch_providers=True, select_installed=False):
	"""Refresh the checkbox list inside the child window based on the content of PROVIDERS. get_providers() is called here
	"""

	global PROVIDERS
	PROVIDERS.update_providers(fetch=refetch_providers)

	# If the filter is not set, there should be nothing displayed in the filter input
	# (Various parts of the application can call this function and override/reset the filter)
	if not filter:
		dpg.set_value("item_filter", "")

	category_collapsed = set()

	# remember which mods were selected
	# Note: because names in USER_ITEMS and MOD_ITEMS can overlap, both will be selected
	previously_selected = set()
	for item_name, item in list(USER_ITEMS.items()) + list(MOD_ITEMS.items()):
		if dpg.get_value(item) is True:
			previously_selected.add(item_name)

	# Kill the children before the parents
	for user_item in USER_ITEMS.values():
		dpg.delete_item(user_item)
	for mod_item in MOD_ITEMS.values():
		dpg.delete_item(mod_item)
	for category_name, category_item in CATEGORY_ITEMS.items():
		# Store if the category was collapsed by the user
		if not dpg.get_value(category_item):
			category_collapsed.add(category_name)
		dpg.delete_item(category_item)

	# Categories and items might come and go
	USER_ITEMS.clear()
	MOD_ITEMS.clear()
	CATEGORY_ITEMS.clear()

	try:
		user_providers = PROVIDERS.user_providers.items()
		user_category_label = "user defined"
	except Exception as e:
		LOGGER.error(f"Failed to retrieve user defined providers: {e}")
		user_providers = {}
		user_category_label = f"user defined ({e})"

	CATEGORY_ITEMS[''] = dpg.add_collapsing_header(label=user_category_label, default_open='' not in category_collapsed, parent="mod_items_display")
	for mod_name, mod_metadata in user_providers:
		not_filtered = not is_filtered(filter, mod_name)
		with dpg.group(horizontal=True, parent=CATEGORY_ITEMS[''], show=not_filtered) as mod_line: # This show= is to hide the entire line
			USER_ITEMS[mod_name] = dpg.add_checkbox(label=mod_name, default_value=mod_name in previously_selected, show=not_filtered) # This show= is used because we are checking this element with dpg.is_item_shown
			button_id = dpg.add_button(label="info", callback=open_link_callback, user_data=mod_metadata["url"])
			dpg.bind_item_theme(button_id, "hyperlink_theme")
			button_id = dpg.add_button(label="Delete", callback=add_item_delete_callback, user_data=mod_name)
			dpg.bind_item_theme(button_id, "removal_theme")

			with align_items(0, 1, theme="no_vertical_cellpadding_theme"):
				dpg.add_button(label="Modify", callback=add_item_modify_callback, user_data=mod_name)

		if not_filtered:
			with dpg.tooltip(mod_line):
				make_title(mod_name, font="child_title_font", align="left")
				make_key_value_table(mod_metadata)

	for category_name, mods in PROVIDERS.providers.items():
		CATEGORY_ITEMS[category_name] = dpg.add_collapsing_header(label=category_name, default_open=category_name not in category_collapsed, parent="mod_items_display")
		for mod_name, mod_metadata in mods.items():
			not_filtered = not is_filtered(filter, mod_name)
			with dpg.group(horizontal=True, parent=CATEGORY_ITEMS[category_name], show=not_filtered) as mod_line: # This show= is to hide the entire line
				MOD_ITEMS[mod_name] = dpg.add_checkbox(label=mod_name, default_value=mod_name in previously_selected, show=not_filtered) # This show= is used because we are checking this element with dpg.is_item_shown
				button_id = dpg.add_button(label="info", callback=open_link_callback, user_data=mod_metadata["info_url" if "info_url" in mod_metadata else "url"])
				dpg.bind_item_theme(button_id, "hyperlink_theme")

				if "authors" in mod_metadata:
					authors = mod_metadata["authors"]
					for author in authors[:MAX_DISPLAYED_AUTHORS]:
						author_name = PROVIDERS.get_author_display_name(author)
						donation_link = PROVIDERS.get_author_donation_link(author)
						if author_name is not None and donation_link is not None:
							dpg.add_button(label=f"donate to {author_name}", callback=open_link_callback, user_data=donation_link)
							dpg.bind_item_theme(dpg.last_item(), "donationlink_theme")

				with align_items(0, 1, theme="no_vertical_cellpadding_theme"):
					dpg.add_button(label="Override", callback=add_item_modify_callback, user_data=mod_name)

			if not_filtered and 'description' in mod_metadata:
				with dpg.tooltip(mod_line):
					make_title(mod_name, font="child_title_font", align="left")
					dpg.add_separator()
					dpg.add_text(wrap_text(mod_metadata['description']))
					make_key_value_table(mod_metadata, filter=lambda key: not key == "description")

	all_mod_names = list(USER_ITEMS.keys())
	for mod_name in MOD_ITEMS.keys():
		if mod_name not in all_mod_names:
			all_mod_names.append(mod_name)
	dpg.configure_item("add_item_combo_mod_name", items=all_mod_names)

	if select_installed:
		mod_items_select_existing_only(MOD_FOLDER)

def mod_items_select(selection):
	"""Select or deselect all mod items
	"""

	for mod_item in MOD_ITEMS.values():
		if dpg.is_item_shown(mod_item): # Only select if not filtered out
			dpg.set_value(mod_item, selection)
	for mod_item in USER_ITEMS.values():
		if dpg.is_item_shown(mod_item): # Only select if not filtered out
			dpg.set_value(mod_item, selection)

def mod_items_select_existing():
	"""Deselect all items and call mod_items_select_existing_only. Added because lambda are single line.
	"""

	mod_items_select(False)
	mod_items_select_existing_only(MOD_FOLDER)

def mod_items_select_existing_only(mod_path):
	"""Select all mod items found in MOD_FOLDER
	"""

	if not os.path.isdir(mod_path):
		return
	for mod in get_local_mods(mod_path):
		# check for is_item_shown, because we only select if not filtered out
		if mod in MOD_ITEMS and dpg.is_item_shown(MOD_ITEMS[mod]):
			dpg.set_value(MOD_ITEMS[mod], True)
		if mod in USER_ITEMS and dpg.is_item_shown(USER_ITEMS[mod]):
			dpg.set_value(USER_ITEMS[mod], True)

def set_lock():
	LOCK_FILE.touch()

def break_lock():
	if is_locked():
		os.remove(LOCK_FILE)

def is_locked():
	return os.path.isfile(LOCK_FILE)

def break_log_dialog():
	break_lock()
	dpg.configure_item("lock_file_existed", show=False)

def provider_change_window_open():
	if is_locked():
		return
	dpg.set_value("provider_change_input", PROVIDERS_REPO_URL)
	dpg.configure_item("provider_change_window", show=True)

def provider_change_window_close():
	dpg.configure_item("provider_change_window", show=False)
	dpg.set_value("provider_change_status", "")

def provider_change_apply():
	global PROVIDERS_REPO_URL
	if PROVIDERS_REPO_URL == dpg.get_value("provider_change_input"):
		return
	dpg.set_value("provider_change_status", "Checking providers repo...")
	if PROVIDERS.test_providers(dpg.get_value("provider_change_input")):
		dpg.set_value("provider_change_status", "New providers repo set.")
		PROVIDERS_REPO_URL = dpg.get_value("provider_change_input")
		PROVIDERS.set_providers_url(PROVIDERS_REPO_URL)
		mod_items_display(select_installed=True)
	else:
		dpg.set_value("provider_change_status", "Not a valid providers repo")

def add_item_type_callback(sender, app_data):
	dpg.configure_item("add_item_group_branch", show=app_data == "git")
	dpg.configure_item("add_item_group_subdir", show=app_data == "zip")
	dpg.set_value("add_item_branch", "")
	dpg.set_value("add_item_subdir", "")

def add_item_delete_callback(sender, app_data, user_data):
	PROVIDERS.user_providers.delete_item(user_data)
	if user_data in USER_ITEMS:
		del USER_ITEMS[user_data]
	PROVIDERS.user_providers.save()
	mod_items_display(refetch_providers=False) # Discards filter

def add_item_add_callback():
	dpg.set_value("add_item_status", "")
	dpg.configure_item("add_item_loading_indicator", show=True)
	try:
		PROVIDERS.user_providers.add_item(
			dpg.get_value("add_item_type"),
			dpg.get_value("add_item_name").strip(),
			dpg.get_value("add_item_url"),
			branch=dpg.get_value("add_item_branch"),
			subdir=dpg.get_value("add_item_subdir")
		)
		PROVIDERS.user_providers.save()
		mod_items_display(refetch_providers=False) # Discards filter
		dpg.set_value("add_item_status", "OK")
	except Exception as e:
		LOGGER.warning(f"Encounterd an error while trying to add a user item: {e}")
		dpg.set_value("add_item_status", wrap_text(f"{e}"))
	dpg.configure_item("add_item_loading_indicator", show=False)

def add_item_fill_in_existing_values_callback(sender, app_data):
	provider = PROVIDERS.get_provider(app_data)
	if not provider:
		LOGGER.warning(f"Mod {app_data} could not be found")
		return
	if "type" in provider and provider["type"]:
		dpg.set_value("add_item_type", provider["type"])

		# When selecting the type, new fields might be shown
		# so we need to execute the calllback of the type field
		cb = dpg.get_item_callback("add_item_type")
		cb(__name__, provider["type"])
	if "name" in provider and provider["name"]:
		dpg.set_value("add_item_name", provider["name"])
	if "url" in provider and provider["url"]:
		dpg.set_value("add_item_url", provider["url"])
	if provider.get("type") == "git" and "branch" in provider:
		dpg.set_value("add_item_branch", provider["branch"])
	if provider.get("type") == "zip" and "subdir" in provider:
		dpg.set_value("add_item_subdir", provider["subdir"])

def add_item_modify_callback(sender, app_data, user_data):
	add_item_fill_in_existing_values_callback(__name__, user_data)
	switch_tab("main_tab_bar", "main_tab_add_item")

def open_link_callback(sender, app_data, user_data):
	LOGGER.info(f"Received link {user_data}")
	if user_data:
		LOGGER.info(f"Trying to open link {user_data}")
		webbrowser_open(user_data)

def filter_items_callback(sender, app_data):
	mod_items_display(filter=app_data, refetch_providers=False)

def is_filtered(filter, mod_name):
	if not filter:
		return False
	return filter not in mod_name

def app_version_check():
	def update_status(label, theme, has_link):
		dpg.configure_item("app_version_status", label=label)
		dpg.bind_item_theme("app_version_status", theme)
		dpg.configure_item("app_version_status", user_data=AUTHOR_URL if has_link else '')
		dpg.configure_item("app_version_status", show=True)

	dpg.configure_item("app_version_status", show=False)

	if not COMMIT_ID:
		update_status("Custom version", "hyperlink_theme", True)
		return
	try:
		if not has_latest_version(APP_REPO_URL, COMMIT_ID):
			update_status("New version available!", "hyperlink_theme", True)
			return
	except Exception:
		update_status("Update check failed", "hyperlink_theme", True)
		return
	update_status("Up to date!", "title_theme", False)

def state_save():
	state_dump(
		STATE_FILE,
		last_path                  = MOD_FOLDER,
		xpos                       = dpg.get_viewport_pos()[0],
		ypos                       = dpg.get_viewport_pos()[1],
		n_update_workers           = dpg.get_value("n_update_workers"),
		providers_repo_url         = PROVIDERS_REPO_URL,
		allow_insecure_connections = dpg.get_value("setting_allow_insecure_connections"),
		manual_branch_management   = dpg.get_value("setting_manual_branch_management"),
		image_optimization_level   = dpg.get_value("setting_image_optimization_level"),
		git_dubious_ownership_fix  = dpg.get_value("setting_git_dubious_ownership_fix"),
	)

def local_mods_scan():
	if not MOD_FOLDER:
		dpg.configure_item("local_mods_scanning_progress", overlay="Cannot run a scan without a mod folder")
		return

	progress = 0.0
	last_x_scroll = dpg.get_x_scroll("local_mods_display")
	last_y_scroll = dpg.get_y_scroll("local_mods_display")

	dpg.set_value("local_mods_scanning_progress", progress)

	for i in LOCAL_MODS_IMAGE_ITEMS:
		dpg.delete_item(i)
	LOCAL_MODS_IMAGE_ITEMS.clear()
	for c in dpg.get_item_children("local_mods_display_filter", 1):
		dpg.delete_item(c)
	if dpg.does_item_exist("local_mods_texture_registry"):
		for c in dpg.get_item_children("local_mods_texture_registry", 1):
			dpg.delete_item(c)
		dpg.delete_item("local_mods_texture_registry")
	dpg.add_texture_registry(tag="local_mods_texture_registry")

	dirs = [d for d in os.listdir(MOD_FOLDER) if os.path.isdir(Path(MOD_FOLDER, d))]
	if not dirs:
		dpg.configure_item("local_mods_scanning_progress", overlay="No dirs")
		return

	dpg.configure_item("local_mods_loading_indicator", show=True)

	n_dirs = len(dirs)
	increment = 1 / n_dirs

	for idx, d in enumerate(dirs):
		dpg.configure_item("local_mods_scanning_progress", overlay=f"[{idx+1}/{n_dirs}] Scanning {d}...")
		progress += increment
		dpg.set_value("local_mods_scanning_progress", progress)

		local_mod_path = Path(MOD_FOLDER, d)
		try:
			local_mod = LocalMod(local_mod_path)
		except Exception as e:
			LOGGER.warning(f"Path {local_mod_path} appears to not be a valid mod: {e}")
			continue

		with dpg.group(parent="local_mods_display_filter", filter_key=str(d)):
			if idx > 0:
				dpg.add_separator()
			with dpg.group(horizontal=True):
				if local_mod.mod_icon_path:
					local_mods_add_image(local_mod.mod_icon_path, max_height=30)
				dpg.add_text(str(d))
			with align_items(0, 4):
				dpg.add_button(label="Optimize PNGs", callback=local_mods_image_optimize_callback, user_data=local_mod_path)

				if git_is_repo(local_mod_path) and dpg.get_value("setting_manual_branch_management") and git_is_installed():
					current_branch, detached = git_repo_current_branch(local_mod_path)
					if current_branch is not None:
						branches = list(itertools.chain(*git_repo_get_branches(local_mod_path)))
						dpg.add_button(label="Pull", callback=local_mods_pull_branch_callback, user_data=local_mod_path)
						dpg.add_combo(items=branches, default_value=current_branch, width=150, callback=local_mods_switch_branch_callback, user_data=local_mod_path)
					else:
						blank()
						dpg.add_text("No branch info")
				else:
					blank()
					blank()

				dpg.add_button(label="Remove", user_data=local_mod_path, callback=local_mods_remove_callback)
				# FIXME: Skip to this tab, then back and the theme is applied to checkboxes...
				#dpg.bind_item_theme(button_id, "removal_theme")

			with dpg.collapsing_header(label="Additional Info"):
				if local_mod.preview_image_path:
					LOCAL_MODS_IMAGE_ITEMS.append(
						local_mods_add_image(local_mod.preview_image_path)
					)
				if local_mod.package_id:
					dpg.add_text("Package ID")
					dpg.add_input_text(default_value=local_mod.package_id, readonly=True, width=-1)
				if local_mod.name:
					dpg.add_text("Name")
					dpg.add_input_text(default_value=local_mod.name, readonly=True, width=-1)
				if local_mod.description:
					dpg.add_text("Description")
					dpg.add_input_text(default_value=local_mod.description, readonly=True, multiline=True, width=-1)
				if local_mod.mod_version:
					dpg.add_text("Version")
					dpg.add_input_text(default_value=local_mod.mod_version, readonly=True, width=-1)

	dpg.configure_item("local_mods_loading_indicator", show=False)
	dpg.configure_item("local_mods_scanning_progress", overlay="Done")
	dpg.set_x_scroll("local_mods_display", last_x_scroll)
	dpg.set_y_scroll("local_mods_display", last_y_scroll)

	LOGGER.info("Finished scanning local mods")

def local_mods_add_image(path, max_height=300):
	def max_dimensions(width, height, max_height):
		# TODO: Restrict width instead of height
		# TODO: Use Pillow module
		if height <= max_height:
			return width, height
		return width * (max_height / height), max_height

	if not os.path.isfile(path) or os.path.getsize(path) > 30 * 1024**2:
		return
	try:
		width, height, channels, data = dpg.load_image(str(path))
		texture = dpg.add_static_texture(width=width, height=height, default_value=data, parent="local_mods_texture_registry")
	except Exception as e:
		LOGGER.warning(f"Unable to display preview inage {path}: {e}")
		return
	width, height = max_dimensions(width, height, max_height)
	return dpg.add_image(texture, width=width, height=height)

def local_mods_filter_callback(sender, app_data):
	dpg.set_value("local_mods_display_filter", app_data)

def local_mods_remove_callback(sender, app_data, user_data):
	if is_locked():
		LOGGER.warning("Not removing anything while update is running")
		return

	if os.path.exists(user_data):
		LOGGER.info(f"Removing local mod {user_data}")
		shutil_rmtree_with_retries(user_data)
	local_mods_scan()
	mod_items_display(refetch_providers=False)

def local_mods_switch_branch_callback(sender, app_data, user_data):
	if not git_is_repo(user_data):
		return
	if is_locked():
		LOGGER.warning("Not switching anything while update is running")
		return

	LOGGER.info(f"Switching {user_data} to branch {app_data}")
	local_mods_status_window(True, message=f"Switching {user_data} to branch {app_data}")
	git_repo_checkout_branch(user_data, app_data)
	local_mods_status_window(False)
	local_mods_scan()

def local_mods_pull_branch_callback(sender, app_data, user_data):
	if not git_is_repo(user_data):
		return
	if is_locked():
		LOGGER.warning("Not pulling anything while update is running")
		return

	LOGGER.info(f"Manually pulling changes for {user_data}")
	local_mods_status_window(True, message=f"Manually pulling changes for {user_data}")
	try:
		git_repo_pull(user_data)
		local_mods_status_window(False)
	except Exception as e:
		local_mods_status_window(True, message=f"Failed to pull {user_data}. Are you on an actual branch?", show_close_button=True, show_loading_indicator=False)

def local_mods_status_window(show, message='', show_close_button=False, show_loading_indicator=True):
	dpg.set_value("local_mods_status_message", wrap_text(message))
	dpg.configure_item("local_mods_status_window", show=show)
	dpg.configure_item("local_mods_status_close_button", show=show_close_button)
	dpg.configure_item("local_mods_status_loading_indicator", show=show_loading_indicator)

def local_mods_image_optimize_callback(sender, app_data, user_data):
	if is_locked():
		LOGGER.warning("Not optimizing anything while update is running")
		return

	if CANCEL_EVENT_LOCAL_MODS_OPTIMIZE.is_set():
		CANCEL_EVENT_LOCAL_MODS_OPTIMIZE.clear()

	thread = threading.Thread(target=local_mods_image_optimize_worker, args=(user_data,), daemon=True)
	thread.start()

def local_mods_image_optimize_worker(mod_path):
	dpg.configure_item("local_mods_image_optimize_loading_indicator", show=True)
	dpg.set_value("local_mods_image_optimize_status", "Searching for images...")
	dpg.set_value("local_mods_image_optimize_progress", 0)
	dpg.set_value("local_mods_image_optimize_previous_total_size", "")
	dpg.set_value("local_mods_image_optimize_current_total_size", "")
	dpg.set_value("local_mods_image_optimize_total_size_diff", "")
	dpg.configure_item("local_mods_image_optimize_progress", overlay="")
	dpg.configure_item("local_mods_image_optimize_close", show=False)
	dpg.configure_item("local_mods_image_optimize_cancel", show=True)
	dpg.configure_item("local_mods_image_optimize_window", show=True)

	match dpg.get_value("setting_image_optimization_level"):
		case "max":
			level = 9001
		case "high":
			level = 6
		case "medium":
			level = 4
		case _:
			level = 2
	LOGGER.info(f"Using optimization level: {level}")

	image_files = []
	try:
		for root, dirs, files in os.walk(mod_path):
			for file in files:
				in_textures_dir = any("textures" in part.lower() for part in Path(root).parts)
				is_png = file.lower().endswith(("png",)) # TODO?: Use is_png check
				if is_png and in_textures_dir:
					image_files.append(os.path.join(root, file))
	except Exception as e:
		LOGGER.error(f"Failed to find image files to optimize: {e}")
		dpg.configure_item("local_mods_image_optimize_loading_indicator", show=False)
		dpg.set_value("local_mods_image_optimize_status", "Failed to find images.")
		dpg.configure_item("local_mods_image_optimize_cancel", show=False)
		dpg.configure_item("local_mods_image_optimize_close", show=True)
		return

	if not image_files:
		dpg.configure_item("local_mods_image_optimize_loading_indicator", show=False)
		dpg.set_value("local_mods_image_optimize_status", "No optimizable images found.")
		dpg.configure_item("local_mods_image_optimize_cancel", show=False)
		dpg.configure_item("local_mods_image_optimize_close", show=True)
		return

	dpg.set_value("local_mods_image_optimize_status", "Calculating total size...")
	try:
		previous_total_size = sum(os.path.getsize(image_file) for image_file in image_files)
	except Exception as e:
		# Might happen if files are removed while this runs
		LOGGER.error(f"Failed to calculate total size: {e}")
		previous_total_size = None
	dpg.set_value("local_mods_image_optimize_previous_total_size", f"{previous_total_size:,} bytes" if previous_total_size is not None else "Unable to calculate")

	progress = 0
	total = len(image_files)
	dpg.set_value("local_mods_image_optimize_status", f"Optimizing {total} images (This may take a long time)...")
	for image_file in image_files:
		if CANCEL_EVENT_LOCAL_MODS_OPTIMIZE.is_set():
			dpg.configure_item("local_mods_image_optimize_progress", overlay=f"[{progress}/{total}] (Cancelled)")
			break
		dpg.configure_item("local_mods_image_optimize_progress", overlay=f"[{progress}/{total}] {os.path.basename(image_file)}")

		LOGGER.info(f"Trying to optimize {image_file}...")
		# One might wonder about the performance implications of spawning a new process for every image...
		# until you look at the processing time of the optimizer
		process = multiprocessing.Process(target=optimize_png, args=(image_file,), kwargs={'level': level})
		process.start()
		process.join()

		progress += 1
		dpg.set_value("local_mods_image_optimize_progress", 1/total * (progress))
		dpg.configure_item("local_mods_image_optimize_progress", overlay=f"[{progress}/{total}] {os.path.basename(image_file)}")

	dpg.set_value("local_mods_image_optimize_status", "Calculating total size...")
	try:
		current_total_size = sum(os.path.getsize(image_file) for image_file in image_files)
	except Exception as e:
		# Might happen if files are removed while this runs
		LOGGER.error(f"Failed to calculate total size: {e}")
		current_total_size = None
	dpg.set_value("local_mods_image_optimize_current_total_size", f"{current_total_size:,} bytes" if current_total_size is not None else "Unable to calculate")
	if previous_total_size is not None and current_total_size is not None:
		diff = current_total_size - previous_total_size
		dpg.set_value("local_mods_image_optimize_total_size_diff", f"{diff:+,} bytes")

	dpg.configure_item("local_mods_image_optimize_loading_indicator", show=False)
	dpg.set_value("local_mods_image_optimize_status", "Optimization finished.")
	dpg.configure_item("local_mods_image_optimize_cancel", show=False)
	dpg.configure_item("local_mods_image_optimize_close", show=True)

def local_mods_image_optimize_cancel():
	if not CANCEL_EVENT_LOCAL_MODS_OPTIMIZE.is_set():
		CANCEL_EVENT_LOCAL_MODS_OPTIMIZE.set()
	dpg.configure_item("local_mods_image_optimize_progress", overlay="Cancelling...")

def pinned_providers_refresh():
	try:
		PROVIDERS.collect_pinned_rjw_version()
		dpg.configure_item("setting_pinned_providers_select", items=[""] + PROVIDERS.pinned_rjw_versions)
	except Exception as e:
		LOGGER.error(f"Could not refresh pinned providers: {e}")

def pinned_providers_select_callback(sender, app_data):
	if not app_data:
		PROVIDERS.unpin_rjw_version()
	else:
		PROVIDERS.pin_rjw_version(app_data)

dpg.create_context()
dpg.create_viewport(title="Libidinous Loader", width=VIEWPORT_WIDTH, height=VIEWPORT_HEIGHT, resizable=False, x_pos=LAST_XPOS, y_pos=LAST_YPOS)

register_themes()

with dpg.font_registry():
	dpg.add_font(Path(get_resource_path(), "RimWorldFont.ttf"), 16, tag="title_font")
	dpg.add_font(Path(get_resource_path(), "Inconsolata-Regular.otf"), 16, tag="child_title_font")
	dpg.add_font(Path(get_resource_path(), "Inconsolata-Regular.otf"), 14, tag="regular_font")
dpg.bind_font("regular_font") # default: proggyclean 13

with dpg.texture_registry():
	width, height, channels, data = load_title_texture()
	dpg.add_static_texture(width=width, height=height, default_value=data, tag="texture_title")

with dpg.value_registry():
	dpg.add_string_value(default_value=MOD_FOLDER, tag="mod_folder")

dpg.add_file_dialog(directory_selector=True, show=False, callback=mod_folder_select, tag="mod_folder_select", width=FILE_DIALOG_WIDTH, height=FILE_DIALOG_HEIGHT)

with dpg.window(tag="primary", label="Libidinous Loader"):
	dpg.draw_image("texture_title", (0, 0), (48, 48))
	dpg.draw_image("texture_title", (VIEWPORT_WIDTH-16-48, 0), (VIEWPORT_WIDTH-16, 48))

	with dpg.group():
		make_title("O Libidinous Loader", font="title_font")
		dpg.add_button(label=f"by {AUTHOR_NAME}", callback=open_link_callback, user_data=AUTHOR_URL, tag="author_name", width=-1)
		dpg.bind_item_theme("author_name", "hyperlink_theme")

	dpg.add_separator()
	with dpg.table(width=-1, policy=dpg.mvTable_SizingStretchSame, header_row=False, resizable=False):
		[dpg.add_table_column() for _ in range(3)]
		with dpg.table_row():
			help_text = dpg.add_button(label='How do I update?', width=-1)
			dpg.add_button(tag="app_version_status", label='', width=-1, show=False, callback=open_link_callback, user_data='')
			dpg.add_button(tag="app_version_check", label="Check for app update", width=-1, callback=lambda: app_version_check())

		dpg.bind_item_theme(help_text, "title_theme")
		with dpg.tooltip(help_text):
			dpg.add_text("1. Follow the link next to this question\n2. Re-download the executable\n3. Replace the old executable with the new one")
	dpg.add_separator()

	dpg.add_text("Mods folder")
	with dpg.group(horizontal=True):
		dpg.add_button(label="Select", callback=lambda: dpg.show_item("mod_folder_select"))
		mod_folder_display = dpg.add_input_text(source="mod_folder", hint="Please select a Mods folder", enabled=False, readonly=True, width=-1, tag="mod_folder_display")

	dpg.add_text("Installation")
	with dpg.group(horizontal=True):
		dpg.add_button(label="Install", callback=lambda: update_run(), show=True, tag="install_btn")
		dpg.add_button(label="Cancel", callback=lambda: update_cancel(), show=False, tag="cancel_btn")
		dpg.add_loading_indicator(tag="loading_indicator", radius=1.5, style=1, show=False, color=(20, 92, 142) )
		dpg.add_progress_bar(tag="progress", default_value=0, width=-1, overlay="")

	with dpg.tab_bar(tag="main_tab_bar"):
		with dpg.tab(label="Mod Providers", tag="main_tab_providers"):
			with dpg.group(horizontal=True):
				dpg.add_text("Providers")
				dpg.add_button(label="Change", callback=lambda: provider_change_window_open())
			with dpg.group(horizontal=True):
				dpg.add_button(label="Select All", callback=lambda: mod_items_select(True))
				dpg.add_button(label="Deselect All", callback=lambda: mod_items_select(False))
				dpg.add_button(label="Select Installed", callback=lambda: mod_items_select_existing())
				dpg.add_button(label="Refresh", callback=lambda: mod_items_display(filter=dpg.get_value("item_filter"), select_installed=True))
				dpg.add_input_text(hint="Filter", callback=filter_items_callback, tag="item_filter", width=-1)

			dpg.add_child_window(tag="mod_items_display", width=-1)
			# populate on first frame
			dpg.set_frame_callback(1, lambda: mod_items_display(select_installed=True))

		with dpg.tab(label="Local Mods", tag="main_tab_local_mods"):
			with dpg.group(horizontal=True):
				dpg.add_button(label="Scan", callback=lambda: local_mods_scan())
				dpg.add_loading_indicator(tag="local_mods_loading_indicator", radius=1.5, style=1, show=False, color=(20, 92, 142) )
				dpg.add_progress_bar(tag="local_mods_scanning_progress", width=-1)

			dpg.add_input_text(hint="Filter", callback=local_mods_filter_callback, width=-1)

			with dpg.child_window(tag="local_mods_display"):
				dpg.add_filter_set(tag="local_mods_display_filter")

			# populate on second frame
			dpg.set_frame_callback(2, lambda: local_mods_scan())

		with dpg.tab(label="Add Mod", tag="main_tab_add_item"):
			make_help_text(f"- You cannot save the configuration if it failed to load.\n- You can try deleting `{os.path.basename(USER_PROVIDERS_PATH)}`\n  and restarting the application", label="Having problems?")
			dpg.add_button(label="Add", width=-1, callback=lambda: add_item_add_callback())

			dpg.add_separator()

			with dpg.group(horizontal=True):
				dpg.add_text("Status: ")
				dpg.add_loading_indicator(tag="add_item_loading_indicator", radius=1.5, style=1, show=False, color=(20, 92, 142) )
				dpg.add_text("", tag="add_item_status")

			dpg.add_separator()

			with dpg.group():
				dpg.add_text("Type:")
				dpg.add_combo(tag="add_item_type", items=("git", "zip"), callback=add_item_type_callback, width=-1)
			with dpg.group():
				dpg.add_text("Name:")
				with dpg.group(horizontal=True):
					dpg.add_combo(tag="add_item_combo_mod_name", no_preview=True, items=(), callback=add_item_fill_in_existing_values_callback) # items= populated by mod_items_display
					dpg.add_input_text(tag="add_item_name", width=-1)
			with dpg.group():
				dpg.add_text("URL:")
				dpg.add_input_text(tag="add_item_url", width=-1)
			with dpg.group(show=False, tag="add_item_group_branch"):
				dpg.add_text("Branch:")
				dpg.add_input_text(tag="add_item_branch", width=-1)
			with dpg.group(show=False, tag="add_item_group_subdir"):
				dpg.add_text("Subdir:")
				dpg.add_input_text(tag="add_item_subdir", width=-1)

		with dpg.tab(label="Settings", tag="main_tab_settings"):
			with dpg.collapsing_header(label="Downloads", default_open=True):
				with dpg.group(horizontal=True):
					dpg.add_checkbox(
						tag="setting_allow_insecure_connections",
						label="Combat Extended Compatibility",
						default_value=DEFAULT_SETTINGS["allow_insecure_connections"]
					)
					make_help_text("Allow insecure connections")
				with dpg.group(horizontal=True):
					dpg.add_checkbox(
						tag="setting_git_dubious_ownership_fix",
						label="Use fix for Git's dubious ownership error",
						default_value=DEFAULT_SETTINGS["git_dubious_ownership_fix"]
					)
					make_help_text("Sets a global safe.directory entry for paths of mods that use git. Useful on shared drives")
				with dpg.group(horizontal=True, tag="n_update_workers_group"):
					dpg.add_input_int(
						tag="n_update_workers",
						width=150,
						default_value=DEFAULT_SETTINGS["n_update_workers"],
						min_value=1,
						max_value=MAX_UPDATE_WORKERS,
						min_clamped=True,
						max_clamped=True
					)
					dpg.add_text("Parallel downloads")
					make_help_text("How many simultaneous connections should be used to download mods")

			with dpg.collapsing_header(label="Local Mods", default_open=True):
				with dpg.group(horizontal=True):
					dpg.add_checkbox(
						tag="setting_manual_branch_management",
						label="Enable manual branch management",
						callback=lambda: local_mods_scan(),
						default_value=DEFAULT_SETTINGS["manual_branch_management"]
					)
					make_help_text("Allows for manual checkout of other branches")
				with dpg.group(horizontal=True):
					dpg.add_combo(
						tag="setting_image_optimization_level",
							items=("low", "medium", "high", "max"),
							default_value=DEFAULT_SETTINGS["image_optimization_level"],
							width=150
						)
					dpg.add_text("Set image optimization level")
					make_help_text("Keep in mind that higher levels might require a powerful CPU to finish in a reasonable amount of time")

			with dpg.collapsing_header(label="Experimental", default_open=False):
				with dpg.group(horizontal=True):
					dpg.add_combo(tag="setting_pinned_providers_select", width=150, default_value="", items=(), callback=pinned_providers_select_callback)
					dpg.add_button(label="Refresh", callback=lambda: pinned_providers_refresh())
					dpg.add_text("Select pinned RJW version")
					make_help_text("Choose a RJW version to pin and only receive updates of mods that happened around that time")
			# populate on third frame
			dpg.set_frame_callback(3, lambda: pinned_providers_refresh())

	with dpg.window(tag="update_status_window", width=MODAL_WIDTH, modal=True, no_move=True, no_resize=True, no_collapse=True, no_title_bar=True, no_close=True, show=False):
		make_title("SOME UPDATES FAILED!")
		dpg.add_button(label="Close", callback=lambda: dpg.configure_item("update_status_window", show=False), show=True)
		dpg.add_text("", tag="update_status")

	with dpg.window(tag="provider_change_window", width=MODAL_WIDTH, modal=True, no_move=True, no_resize=True, no_collapse=True, no_title_bar=True, no_close=True, show=False):
		make_title("CHANGE PROVIDER REPO URL")
		dpg.add_text(tag="provider_change_status")
		dpg.add_input_text(tag="provider_change_input", width=-1)
		dpg.add_button(label="Apply", width=-1, callback=lambda: provider_change_apply())
		dpg.add_button(label="Close", width=-1, callback=lambda: provider_change_window_close())

	with dpg.window(tag="local_mods_status_window", width=MODAL_WIDTH, modal=True, no_move=True, no_resize=True, no_collapse=True, no_title_bar=True, no_close=True, show=False):
		with dpg.group(horizontal=True):
			dpg.add_loading_indicator( tag="local_mods_status_loading_indicator", radius=1.5, style=1, color=(20, 92, 142) )
			dpg.add_text("", tag="local_mods_status_message")
		dpg.add_button(tag="local_mods_status_close_button", label="Close", show=False, callback=lambda: local_mods_status_window(False))

	with dpg.window(tag="local_mods_image_optimize_window", width=MODAL_WIDTH, modal=True, no_move=True, no_resize=True, no_collapse=True, no_title_bar=True, no_close=True, show=False):
		dpg.add_button(tag="local_mods_image_optimize_close", label="Close", callback=lambda: dpg.configure_item("local_mods_image_optimize_window", show=False))
		dpg.add_button(tag="local_mods_image_optimize_cancel", label="Cancel", callback=lambda: local_mods_image_optimize_cancel())
		with dpg.group(horizontal=True):
			dpg.add_loading_indicator( tag="local_mods_image_optimize_loading_indicator", radius=1.5, style=1, color=(20, 92, 142) )
			dpg.add_text("", tag="local_mods_image_optimize_status")
		dpg.add_progress_bar(tag="local_mods_image_optimize_progress", width=-1)
		with dpg.table(header_row=False):
			dpg.add_table_column()
			dpg.add_table_column()
			with dpg.table_row():
				dpg.add_text("Previous total size:")
				dpg.add_text("", tag="local_mods_image_optimize_previous_total_size")
			with dpg.table_row():
				dpg.add_text("Current total size:")
				dpg.add_text("", tag="local_mods_image_optimize_current_total_size")
			with dpg.table_row():
				dpg.add_text("Difference:")
				dpg.add_text("", tag="local_mods_image_optimize_total_size_diff")

	if LOCK_FILE_EXISTED_ON_STARTUP:
		with dpg.window(tag="lock_file_existed", width=MODAL_WIDTH, modal=True, no_move=True, no_resize=True, no_collapse=True, no_title_bar=True, no_close=True):
			make_title("WARNING!", font="child_title_font")
			dpg.add_text("A lock file was found at start up.\nThis could indicate that another instance of this program is running")
			dpg.add_text("If you have made sure that no other instance is present,\nyou can break the lock")
			with dpg.group(horizontal=True):
				dpg.add_button(label="Break lock", callback=lambda: break_log_dialog())
				dpg.add_button(label="Close", callback=lambda: dpg.stop_dearpygui())

	if not git_is_installed():
		with dpg.window(width=MODAL_WIDTH, modal=True, no_move=True, no_resize=True, no_collapse=True, no_title_bar=True, no_close=True):
			make_title("WARNING!", font="child_title_font")
			dpg.add_text("Git appears to not be installed.\nIn the past, this was supported but future changes will require a git installation.")
			with dpg.group(horizontal=True):
				dpg.add_text("Please install git from")
				button_id = dpg.add_button(label="here", callback=open_link_callback, user_data=GIT_DOWNLOAD_URL)
				dpg.bind_item_theme(button_id, "hyperlink_theme")
			dpg.add_button(label="Close", callback=lambda: dpg.stop_dearpygui())

dpg.setup_dearpygui()
dpg.show_viewport()
dpg.set_primary_window("primary", True)

threading.Thread(target=app_version_check, name='initial_version_check', daemon=True).start()

dpg.start_dearpygui()
state_save()
dpg.destroy_context()


code:
import logging

from git_wrapper import (
	git_remote_branch_last_commit,
	git_remote_has_branch,
	git_remote_is_repo,
)

LOGGER = logging.getLogger(__name__)
LOGGER.setLevel(logging.DEBUG)

def has_latest_version(url, compare, branch="main"):
	"""
	url: which url to check
	compare: what to compare against. With git, compare commit ids
	branch: if using git, use this branch

	search for tag when no longer rolling release?
	"""

	LOGGER.info(f"Checking {url} on branch {branch} for newer version with value {compare}")

	if not git_remote_is_repo(url):
		LOGGER.error(f"{url} is not a git repo")
		raise Exception("Update check failed")

	if not git_remote_has_branch(url, branch):
		LOGGER.error(f"{url} does not have a branch called {branch}")
		raise Exception("Update check failed")

	return git_remote_branch_last_commit(url, branch) == compare


code:
import os
import sys
from pathlib import Path

import psutil

def get_executable_path():
	if os.path.isfile(os.getenv('APPIMAGE', '')):
		return os.getenv('APPIMAGE')
	return psutil.Process().exe()

def get_resource_path():
	if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
		return Path(sys._MEIPASS, 'rsrc')
	elif __file__:
		return Path(__file__, 'rsrc').parent
	raise FileNotFoundError('Resource path could not be found')

def get_script_path():
	if os.path.isfile(os.getenv('APPIMAGE', '')) and os.path.isdir(os.getenv('APPDIR', '')):
		return os.path.dirname(os.getenv('APPIMAGE', ''))
	if getattr(sys, 'frozen', False):
		return os.path.dirname(sys.executable)
	elif __file__:
		return os.path.dirname(__file__)
	raise FileNotFoundError('Script path could not be found')


code:
import logging
import os
import re
import shlex
# git with subprocess
import shutil
import subprocess
import sys
from pathlib import Path

from hacks import shutil_rmtree_with_retries

LOGGER = logging.getLogger(__name__)
LOGGER.setLevel(logging.DEBUG)

GIT_EXEC = shutil.which("git")

# ssl error (GIT_SSL_NO_VERIFY)
GIT_ENV = os.environ.copy()
if "LD_LIBRARY_PATH" in GIT_ENV:
	del GIT_ENV["LD_LIBRARY_PATH"]
# Disable prompting (git 2.3)
GIT_ENV["GIT_TERMINAL_PROMPT"] = "0"

LS_REMOTE_REF_HEADS_PATTERN = re.compile(r'ref: refs/heads/(.+)\tHEAD')

def shell_quote(string):
	return str(string)

def git_is_installed():
	return GIT_EXEC is not None

def git_cmd(cmd):
	if not git_is_installed():
		raise FileNotFoundError("No git executable found.")
	cmd.insert(0, GIT_EXEC)
	LOGGER.info(cmd)

	result = subprocess.run(cmd, env=GIT_ENV, check=False, capture_output=True, text=True)
	if result.stdout.strip():
		LOGGER.debug(f"stdout of {cmd}: {result.stdout!r}")
	if result.stderr.strip():
		LOGGER.debug(f"stderr of {cmd}: {result.stderr!r}")
	if result.returncode != 0:
		raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=result.stderr)
	return result.stdout

def git_repo_clone(repo_url, repo_path, branch=None, partial=False):
	cloning_method = 'partially (blobless)' if partial else 'fully'
	LOGGER.info(f"Cloning new repo {cloning_method} into {repo_path}")

	if not git_remote_is_repo(repo_url):
		LOGGER.error(f"Not a valid git repository at remote url {repo_url}")
		raise Exception(f"Invalid repo url {repo_url}")

	partial_args = ["--filter=blob:none"] if partial else []

	git_cmd(["clone", *partial_args, shell_quote(repo_url), shell_quote(repo_path)])
	if branch:
		# git switch needs '--detach' to switch to commit id
		#git_cmd(["-C", shell_quote(repo_path), "switch", "--discard-changes", branch])
		git_cmd(["-C", shell_quote(repo_path), "checkout", "-f", branch])

def git_repo_pull(repo_path):
	git_cmd(["-C", shell_quote(repo_path), "fetch", "--all"])
	git_cmd(["-C", shell_quote(repo_path), "reset", "--hard", "HEAD"])
	git_cmd(["-C", shell_quote(repo_path), "pull", "-f", "--no-all"])

def git_repo_clone_or_pull(repo_path, provider_url, target_branch=None):
	if not git_is_repo(repo_path) or git_get_remote_url(repo_path) != provider_url:
		if os.path.isdir(repo_path):
			LOGGER.warning(f"Removing invalid directory at {repo_path}")
			shutil_rmtree_with_retries(repo_path)
		git_repo_clone(provider_url, str(repo_path), branch=target_branch)
		return

	LOGGER.info(f"Found already existing git repository at {repo_path}. Pulling newest version")
	try:
		# We cannot fetch/pull when detached, so we need to switch to a branch temporarily
		current_branch, is_detached = git_repo_current_branch(repo_path)
		if is_detached:
			LOGGER.info(f"Git repository {repo_path} appears to be detached")
			just_some_branch_to_switch_to = git_repo_get_branches(repo_path)[0][0]
			LOGGER.info(f"Git repository {repo_path} is detached, we are temporarily switching to branch {just_some_branch_to_switch_to}")
			git_cmd(["-C", shell_quote(repo_path), "checkout", "-f", just_some_branch_to_switch_to])
		git_repo_pull(str(repo_path))

		# Attempt to get the default branch of the remote repository.
		# This is necessary because the providers configuration might have been updated to no longer specify a branch,
		# yet we are still pointing to the old, specified one which might differ from the new default.
		# Will not be hit if using pinned providers, since commit ids are noted in target_branch
		if target_branch is None:
			LOGGER.info(f"Looking up the default branch of {provider_url}...")
			target_branch = git_remote_get_default_branch(provider_url)
			if target_branch:
				LOGGER.info(f"Found the default branch of {provider_url}: {target_branch}")
			else:
				LOGGER.warning(f"Could not find a default branch of {provider_url}: {target_branch!r}")

		if is_detached or (target_branch is not None and current_branch != target_branch):
			LOGGER.warning(f"Switching from current branch {current_branch} to {target_branch} in {repo_path}")
			git_repo_checkout_branch(repo_path, target_branch)
		else:
			LOGGER.warning(f"Not switching from current branch {current_branch} to {target_branch} in {repo_path}")
	except Exception as e:
		LOGGER.error(f"Failed to pull {repo_path}. {e}")
		if os.path.isdir(repo_path):
			shutil_rmtree_with_retries(repo_path)
		git_repo_clone(provider_url, str(repo_path), branch=target_branch)

def git_repo_get_branches(repo_path):
	if not git_is_repo(repo_path):
		LOGGER.warning(f"Specified path {repo_path} is not a git repo")
		return [], []

	try:
		branches_local = []
		branches_remote = []

		output = git_cmd(['-C', shell_quote(repo_path), 'for-each-ref', '--format=%(refname:short)', 'refs/heads']).strip()
		branches_local = [l.strip() for l in output.splitlines() if l.strip()]
		output = git_cmd(['-C', shell_quote(repo_path), 'for-each-ref', '--format=%(refname:short)', 'refs/remotes']).strip()
		 # Also outputs just the remote name (e.g. 'origin'), so check if a '/' is present
		branches_remote = [l.strip() for l in output.splitlines() if l.strip() and '/' in l.strip()]

		return branches_local, branches_remote
	except Exception as e:
		LOGGER.error(f"Failed to get branches {repo_path}. {e}")

	return [], []

def git_repo_current_branch(repo_path):
	if not git_is_repo(repo_path):
		LOGGER.warning(f"Specified path {repo_path} is not a git repo")
		return None, None

	# git symbolic-ref --short HEAD 2>/dev/null || echo "detached"

	try:
		output = git_cmd(["-C", shell_quote(repo_path), "rev-parse", "--abbrev-ref", "HEAD"]).strip()
		if output == "HEAD":
			commit_id = git_cmd(["-C", shell_quote(repo_path), "rev-parse", "HEAD"]).strip()
			return commit_id, True
		return output, False
	except Exception as e:
		LOGGER.error(f"Unable to get active branch: {e}")

	return None, None

def git_repo_checkout_branch(repo_path, target_branch):
	if not git_is_repo(repo_path):
		LOGGER.warning(f"Specified path {repo_path} is not a git repo")
		return False

	try:
		if target_branch in git_repo_get_branches(repo_path)[1]:
			git_cmd(["-C", shell_quote(str(repo_path)), "checkout", "--track", shell_quote(target_branch)])
			index = target_branch.find('/')
			target_branch = target_branch[index:]
		git_cmd(["-C", shell_quote(str(repo_path)), "checkout", shell_quote(target_branch)])
		return True
	except Exception as e:
		LOGGER.error(f"Could not checkout {target_branch} in {repo_path}: {e}")
		return False

def git_get_remote_url(repo_path, remote_name='origin'):
	try:
		LOGGER.info(f"Retrieving remote name from {repo_path}")
		return git_cmd(["-C", shell_quote(repo_path), "config", "--get", f"remote.{remote_name}.url"]).strip()
	except Exception as e:
		LOGGER.error(f"Unable to get remote url from {repo_path} (remote: {remote_name}): {e}")
		return None

def git_is_repo(repo_path):
	if not os.path.isdir(Path(repo_path, ".git")):
		return False
	try:
		output = git_cmd(["-C", shell_quote(repo_path), "rev-parse", "--is-inside-work-tree"]).strip()
		if output != "true":
			LOGGER.info(f"git rev-parse reported `{output!r}` in {repo_path}")
		return output == "true"
	except subprocess.CalledProcessError:
		return False

def git_remote_is_repo(url):
	try:
		git_cmd(['ls-remote', '--heads', url])
		return True
	except Exception as e:
		LOGGER.error(e)
	return False

def git_remote_has_branch(url, branch):
	try:
		git_cmd(['ls-remote', '--exit-code', '--heads', url, branch])
		return True
	except Exception as e:
		LOGGER.error(e)
	return False

def git_remote_branch_last_commit(url, branch):
	try:
		out = git_cmd(['ls-remote', '--exit-code', '--heads', url, branch])
		LOGGER.info(f"Remote heads: {out}")
		for line in out.splitlines():
			if 'refs/heads/' + branch in line:
				head = line.split()[0]
				LOGGER.info(f"Head discovered: {head}")
				return head
		return None
	except Exception as e:
		LOGGER.error(e)
	return None

def git_dubious_ownership_fix(repo_path):
	# https://stackoverflow.com/a/71904131
	# git config --global --add safe.directory '*'
	try:
		git_cmd(['config', '--global', '--add', 'safe.directory', repo_path])
	except Exception as e:
		LOGGER.error(f"Failed to fix dubious ownership: {e}")

def git_remote_get_default_branch(url):
	try:
		out = git_cmd(['ls-remote', '--exit-code', '--symref', url, 'HEAD'])
		# line should look like: `ref: refs/heads/main	HEAD`
		match = re.search(LS_REMOTE_REF_HEADS_PATTERN, out)
		if not match:
			raise Exception(f"No match found with {LS_REMOTE_REF_HEADS_PATTERN!r} in {out!r}")
		return match.group(1)
	except Exception as e:
		LOGGER.error(e)
	return None


code:
import errno
import logging
import os
import shutil
import stat
import time

LOGGER = logging.getLogger(__name__)

def remove_readonly_handle(func, path, exc):
	"""error handler suggested by this StackOverflow answer
	https://stackoverflow.com/a/1214935
	"""
	LOGGER.warning(f"Running remove readonly handler: `{func!r}` `{path}` `{exc!r}`")
	excvalue = exc[1]
	LOGGER.warning(f"remove readonly handler excvalue: `{excvalue}`")
	if func in (os.rmdir, os.remove, os.unlink) and excvalue.errno == errno.EACCES:
		LOGGER.warning(f"Attempting to chmod {path}")
		os.chmod(path, stat.S_IRWXU| stat.S_IRWXG| stat.S_IRWXO) # 0777
		LOGGER.warning(f"Attempting to remove {path} again")
		func(path)
	else:
		LOGGER.warning(f"Unable to remove readonly handler on {path}")
		raise

def shutil_rmtree_with_retries(path, retries=3, delay=.2):
	"""Remove a directory at the specified path with retries.
	This is an attempt to deal with some permission errors people are facing when
	trying to delete .git directories.
	"""
	last_exception = None

	for attempt in range(retries):
		try:
			if os.name == 'nt':
				shutil.rmtree(path, ignore_errors=False, onerror=remove_readonly_handle)
			else:
				shutil.rmtree(path)
			if not os.path.exists(path):
				logging.info(f"Successfully removed directory: {path}")
				return
			else:
				logging.warning(f"Directory still exists after attempt {attempt + 1}: {path}")
		except Exception as e:
			logging.warning(f"Error removing directory {path}: {e}")
			last_exception = e

		time.sleep(delay)

	logging.error(f"Failed to remove directory {path} after {retries} attempts.")
	raise last_exception


code:
import logging
import multiprocessing

import oxipng

# for debugging
LOGGER = multiprocessing.get_logger()
LOGGER.setLevel(logging.DEBUG)

def optimize_png(original_path, target_path=None, level=2):
	if not is_png(original_path):
		return

	kwargs = {}
	if level > 6:
		level = 6
		kwargs['strip'] = oxipng.StripChunks.all()
		kwargs['deflate'] = oxipng.Deflaters.zopfli(255)
	if level < 2:
		level = 2
	kwargs['level'] = level
	kwargs['output'] = target_path

	try:
		oxipng.optimize(original_path, **kwargs)
	except Exception as e:
		LOGGER.error(f"Failed to optimize {original_path}: {e}")

def is_png(path):
	# TODO?: Use libmagic
	png_magic = b'\x89PNG\r\n\x1a\n'

	with open(path, 'rb') as f:
		header = f.read(len(png_magic))

	return header == png_magic


code:
import logging
import os
from pathlib import Path

import defusedxml.ElementTree as et

LOGGER = logging.getLogger(__name__)
LOGGER.setLevel(logging.DEBUG)

class LocalMod:
	def __init__(self, path):
		self.about_file_path = None
		self.name = None
		self.package_id = None
		self.description = None
		self.preview_image_path = None
		self.mod_icon_path = None
		self.manifest_file_path = None
		self.mod_version = None

		LOGGER.info(f"Scannning local mod {path}")
		self.make(path)
		self.populate()

	def fnmatch_filter(self, paths, lowercase_target):
		# fnmatch.filter respects case sensitivity
		for path in paths:
			if lowercase_target == str(path).lower():
				return [path]
		return []

	def make(self, path):
		if not os.path.isdir(path):
			raise FileNotFoundError(f"Mod directory {path} is not a directory")

		about_folder = self.fnmatch_filter(os.listdir(path), 'about')
		# about in Translation folder?
		if not about_folder:
			raise FileNotFoundError("About folder could not be found")
		about_folder_path = Path(path, about_folder[0])
		if not os.path.isdir(about_folder_path):
			raise FileNotFoundError(f"About folder {about_folder_path} is not a directory")

		about_file = self.fnmatch_filter(os.listdir(about_folder_path), 'about.xml')
		if not about_file:
			raise FileNotFoundError("About file could not be found")
		about_file_path = Path(about_folder_path, about_file[0])
		if not os.path.isfile(about_file_path):
			raise FileNotFoundError(f"About file {about_file_path} is not a file")

		self.about_file_path = about_file_path

		manifest_file_path = self.find_file(about_folder_path, 'manifest.xml')
		if manifest_file_path:
			self.manifest_file_path = manifest_file_path

		preview_image_path = self.find_file(about_folder_path, 'preview.png')
		if preview_image_path:
			self.preview_image_path = preview_image_path

		mod_icon_path = self.find_file(about_folder_path, 'modicon.png')
		if mod_icon_path:
			self.mod_icon_path = mod_icon_path

	def find_file(self, path, filename):
		matches = self.fnmatch_filter(os.listdir(path), filename)
		if not matches:
			return
		file_path = Path(path, matches[0])
		if not os.path.isfile(file_path):
			return
		return file_path

	def element_text_or_none(self, root, xpath):
		elem = root.find(xpath)
		if elem is not None:
			lines = []
			for line in elem.text.splitlines():
				lines.append(line.strip())
			return '\n'.join(lines).strip()

	def populate(self):
		# https://rimworldwiki.com/wiki/About.xml
		LOGGER.info(f"Parsing local mod {os.path.dirname(self.about_file_path)}")

		self.parse_about()

		if self.manifest_file_path:
			self.parse_manifest()

	def parse_about(self):
		try:
			tree = et.parse(self.about_file_path)
			root = tree.getroot()
		except Exception as e:
			raise Exception(f"Could not parse {self.about_file_path}: {e}")

		if root.tag != 'ModMetaData':
			raise Exception(f"Root Element of {self.about_file_path} was {root.tag}")

		self.package_id = self.element_text_or_none(root, './packageId')
		self.name = self.element_text_or_none(root, './name')
		self.description = self.element_text_or_none(root, './description')

	def parse_manifest(self):
		try:
			tree = et.parse(self.manifest_file_path)
			root = tree.getroot()
		except Exception as e:
			LOGGER.error(f"Could not parse {self.manifest_file_path}: {e}")
			return

		if root.tag != 'Manifest':
			LOGGER.error(f"Root Element of {self.manifest_file_path} was {root.tag}")
			return

		self.mod_version = self.element_text_or_none(root, './version')

def get_local_mods(mod_folder):
	return [d for d in os.listdir(mod_folder) if os.path.isdir(Path(mod_folder, d))]


code:
import io
import json
import logging
import os
import tempfile
import zipfile
from pathlib import Path

import certifi
import fastjsonschema
import urllib3
from environment import get_resource_path, get_script_path
from git_wrapper import (
	git_dubious_ownership_fix,
	git_remote_has_branch,
	git_remote_is_repo,
	git_repo_clone,
	git_repo_clone_or_pull,
)
from hacks import shutil_rmtree_with_retries
from user_defined import UserItems

LOGGER = logging.getLogger(__name__)
LOGGER.setLevel(logging.DEBUG)

class Providers:
	def __init__(self, providers_repo_url, user_providers_path):
		self.providers_repo_url = providers_repo_url
		self.providers_cfg = "providers/providers.json"
		self.providers_branch = "v0"
		self.provider_schema = None
		self.provider_schema_path = Path(get_resource_path(), "schema-v0.json")

		self.poolman = urllib3.PoolManager(cert_reqs="CERT_REQUIRED", ca_certs=certifi.where())
		self.poolman_insecure = urllib3.PoolManager(cert_reqs="CERT_NONE")

		self.providers = dict()
		self.authors = dict()

		self.pinned_providers_temp = dict()
		self.pinned_providers_packages_url = "https://gitgud.io/api/v4/projects/AblativeAbsolute%2Flibidinous_loader_providers/packages"
		self.pinned_providers_url = "https://gitgud.io/api/v4/projects/amevarashi%2Flibidinous_loader_providers/packages/generic/provider_pin/{}/providers.json"
		self.pinned_rjw_version = None
		self.pinned_rjw_versions = []

		self.user_providers = UserItems(user_providers_path)

	def schema_validate(self, content):
		if not self.provider_schema:
			with open(self.provider_schema_path, 'r') as f:
				self.provider_schema = fastjsonschema.compile(json.load(f))
		self.provider_schema(content)

	def get_author_display_name(self, author_id):
		if author_id not in self.authors:
			return None
		return self.authors[author_id]['display_name']

	def get_author_donation_link(self, author_id):
		if author_id not in self.authors:
			return None
		if 'donation_urls' not in self.authors[author_id] or not self.authors[author_id]['donation_urls']:
			return None
		return self.authors[author_id]['donation_urls'][0]

	def set_providers_url(self, providers_repo_url):
		self.providers_repo_url = providers_repo_url

	def update_providers(self, fetch=True):
		providers_cfg_path = Path(get_script_path(), self.providers_cfg)
		if fetch:
			try:
				providers_repo = os.path.dirname(providers_cfg_path)
				git_repo_clone_or_pull(providers_repo, self.providers_repo_url, target_branch=self.providers_branch)
			except Exception as e:
				LOGGER.error(f"Failed to retrieve providers file from {self.providers_repo_url} with {e}")

		if not os.path.isfile(providers_cfg_path):
			LOGGER.critical(f"Providers file ({providers_cfg_path}) does not exist.")
			return

		with open(providers_cfg_path, "rb") as f:
			content = json.load(f)

		try:
			self.schema_validate(content)
		except fastjsonschema.JsonSchemaException as e:
			LOGGER.critical(f"Providers file is malformed. {e}")
			return

		self.providers = content["providers"]
		self.authors = content["authors"]

	def pin_rjw_version(self, rjw_version):
		self.pinned_rjw_version = rjw_version
		LOGGER.info(f"Pinned rjw version: {rjw_version}")

	def unpin_rjw_version(self):
		self.pinned_rjw_version = None
		LOGGER.info(f"Unpinned rjw version")

	def collect_pinned_rjw_version(self):
		rjw_versions = []

		response = self.poolman.request('GET', self.pinned_providers_packages_url)
		content = json.loads(response.data)
		for provider_pin_items in content:
			if provider_pin_items.get("name") != "provider_pin":
				continue
			if "version" not in provider_pin_items:
				continue
			provider_pin_version = str(provider_pin_items["version"]).strip()
			if provider_pin_version:
				rjw_versions.append(provider_pin_version)
		LOGGER.info(f"Pinned rjw versions found: {rjw_versions}")

		self.pinned_rjw_versions = rjw_versions

	def update_pinned_providers(self):
		try:
			self.collect_pinned_rjw_version()

			selected_rjw_version = self.pinned_rjw_version
			if not selected_rjw_version or selected_rjw_version not in self.pinned_rjw_versions:
				LOGGER.info(f"Pinned rjw version ({selected_rjw_version}) not requested or found, using latest.")
				version_sort_key = lambda version: tuple(map(int, version.split('.')))
				selected_rjw_version = max(self.pinned_rjw_versions, key=version_sort_key)
				LOGGER.info(f"Latest pinned rjw version: {selected_rjw_version}")

			providers_url = self.pinned_providers_url.format(selected_rjw_version)
			LOGGER.info(f"Trying to use pinned providers file: {providers_url}")
			response = self.poolman.request('GET', providers_url)
			content = json.loads(response.data)
			self.schema_validate(content)

			self.pinned_providers_temp = content["providers"]
		except Exception as e:
			LOGGER.error(f"Failed to get pinned releases: {e}")
			self.pinned_providers_temp = dict()

	def get_provider(self, mod_name):
		provider = self.user_providers.get_item(mod_name)
		if provider is not None:
			LOGGER.info(f"Found user defined provider {mod_name}.")
			return provider

		LOGGER.info(f"{mod_name} not defined by user. Looking up in pinned providers list")
		provider = self.get_provider_by_mod_name(mod_name, self.pinned_providers_temp)
		if provider:
			return provider

		LOGGER.info(f"{mod_name} not found in pinned providers list. Looking up in providers list")
		provider = self.get_provider_by_mod_name(mod_name, self.providers)
		if provider:
			return provider

		LOGGER.error(f"Could not find provider {mod_name} in\n{self.providers!r}")
		return

	def get_provider_by_mod_name(self, mod_name, providers):
		for category in providers.keys():
			for mod in providers[category].keys():
				if mod == mod_name:
					provider = providers[category][mod_name]
					if provider is not None:
						return provider

	def update_mod(self, mod_name, mod_folder, allow_insecure_connections=False, fix_dubious_ownership=False):
		if not os.path.isdir(mod_folder):
			return
		provider = self.get_provider(mod_name)
		if not provider:
			LOGGER.error(f"No provider {mod_name} could be found")
			raise ValueError(f"No provider {mod_name} could be found")

		LOGGER.info(provider)
		full_path = Path(mod_folder, mod_name)
		os.makedirs(full_path, exist_ok=True)

		if provider['type'] == 'git':
			if fix_dubious_ownership:
				git_dubious_ownership_fix(full_path)
			git_repo_clone_or_pull(full_path, provider['url'], target_branch=provider.get('branch'))
		elif provider['type'] == 'zip':
			if os.path.exists(full_path):
				LOGGER.warning(f"Removing previous installation at {full_path}")
				shutil_rmtree_with_retries(full_path)
				os.makedirs(full_path, exist_ok=True)
			poolman = self.poolman if not allow_insecure_connections else self.poolman_insecure
			response = poolman.request('GET', provider['url'])
			content = io.BytesIO(response.data)
			with zipfile.ZipFile(content) as z:
				subdir = provider.get('subdir')
				while subdir and subdir.endswith('/'):
					subdir = subdir[:-1]
				if not subdir:
					z.extractall(path=full_path)
				else:
					LOGGER.info(f"Extracting contents of {subdir}")
					for fileinfo in z.infolist():
						if not fileinfo.filename.startswith(subdir + '/'):
							continue
						output_path = os.path.join(full_path, fileinfo.filename[len(subdir) + 1:])
						if fileinfo.is_dir() and output_path:
							os.makedirs(output_path, exist_ok=True)
							continue
						output_parent = os.path.dirname(output_path)
						if not os.path.exists(output_parent):
							os.makedirs(output_parent)
						with z.open(fileinfo) as extract, open(output_path, 'wb') as output:
							output.write(extract.read())

	def test_providers(self, providers_repo_url, providers_cfg="providers.json"):
		LOGGER.info(f"Testing {providers_repo_url} for {providers_cfg}")
		if not git_remote_is_repo(providers_repo_url):
			LOGGER.error(f"{providers_repo_url} is not a valid git repository")
			return False
		if not git_remote_has_branch(providers_repo_url, self.providers_branch):
			LOGGER.error(f"{providers_repo_url} does not have required branch {self.providers_branch}")
			return False

		with tempfile.TemporaryDirectory() as temp_dir:
			LOGGER.info(f"Cloning {providers_repo_url} into {temp_dir}")
			try:
				git_repo_clone(providers_repo_url, temp_dir, branch=self.providers_branch)
			except Exception as e:
				LOGGER.error(f"Could not clone {providers_repo_url}: {e}")
				return False

			providers_cfg = Path(temp_dir, providers_cfg)
			if not os.path.isfile(providers_cfg):
				LOGGER.error(f"Providers file could not be found at {providers_cfg}")
				return False

			try:
				with open(providers_cfg, "rb") as f:
					content = json.load(f)
				self.schema_validate(content)
			except Exception as e:
				LOGGER.error(f"Providers file content could not be validated: {e}")
				return False

		return True


code:
import json
import logging
import os

LOGGER = logging.getLogger(__name__)
LOGGER.setLevel(logging.DEBUG)

def state_load(state_file, state_file_old, max_update_workers):
	# TODO?: set defaults with kwargs
	LOGGER.info(f"Loading last state from {state_file}")
	try:
		with open(state_file, "r") as f:
			last_state = json.load(f)
	except Exception as e:
		LOGGER.warning(f"Unable to load state file. {e}")
		last_state = dict()

	LOGGER.debug(f"Loaded state dict: {last_state!r}")

	mod_folder = last_state.get('last_path', '')
	if not os.path.isdir(mod_folder):
		LOGGER.error(f"Last path {mod_folder} is not valid")
		mod_folder = ''

	last_xpos = int(last_state.get('xpos', 0))
	last_ypos = int(last_state.get('ypos', 0))

	providers_repo_url = last_state.get('providers_repo_url', 'https://gitgud.io/AblativeAbsolute/libidinous_loader_providers.git')
	# Kept for backcompat
	try:
		with open(state_file_old, "r") as f:
			mod_folder = f.read()
		os.remove(state_file_old)
		LOGGER.info(f"Loaded and removed old state file {state_file_old}")
	except Exception:
		pass

	default_settings = dict()
	default_update_workers = int(last_state.get('n_update_workers', max_update_workers))
	if default_update_workers > max_update_workers:
		default_update_workers = max_update_workers
	default_settings['n_update_workers'] = default_update_workers
	default_settings['allow_insecure_connections'] = bool(last_state.get('allow_insecure_connections', False))
	default_settings['manual_branch_management'] = bool(last_state.get('manual_branch_management', False))
	default_settings['git_dubious_ownership_fix'] = bool(last_state.get('git_dubious_ownership_fix', False))
	default_settings['image_optimization_level'] = str(last_state.get('image_optimization_level', "low"))
	if default_settings['image_optimization_level'] not in ("low", "medium", "high", "max"):
		default_settings['image_optimization_level'] = "low"

	return mod_folder, last_xpos, last_ypos, providers_repo_url, default_settings

def state_dump(state_file, **kwargs):
	with open(state_file, "w") as f:
		f.write(json.dumps(kwargs))