├── .envrc ├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── .gitignore ├── AUTHORS.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── build ├── .gitignore ├── __init__.py ├── __main__.py ├── convert_readme_to_bbcode.py ├── nuitka_compile.py └── windows_installer │ ├── .gitignore │ ├── OneLauncher.wixproj │ ├── Package.wxs │ ├── build_installer.py │ └── installer_background.png ├── flake.lock ├── flake.nix ├── locale ├── de │ └── README.md └── fr │ └── README.md ├── pyproject.toml ├── src ├── onelauncher │ ├── __about__.py │ ├── __init__.py │ ├── __main__.py │ ├── addon_manager.py │ ├── addons │ │ ├── config.py │ │ ├── schemas │ │ │ ├── lotrointerface_feed.xsd │ │ │ └── vscode-lotro-api │ │ │ │ ├── LICENSE.md │ │ │ │ ├── compendium-basetypes.xsd │ │ │ │ ├── lotroplugin.xsd │ │ │ │ ├── musiccompendium.xsd │ │ │ │ ├── plugincompendium.xsd │ │ │ │ └── skincompendium.xsd │ │ └── startup_script.py │ ├── async_utils.py │ ├── cli.py │ ├── config.py │ ├── config_manager.py │ ├── game_account_config.py │ ├── game_config.py │ ├── game_launcher_local_config.py │ ├── game_utilities.py │ ├── images │ │ ├── DDOSwitchIcon.png │ │ ├── DDO_banner.png │ │ ├── LOTROSwitchIcon.png │ │ ├── LOTRO_banner.png │ │ ├── OneLauncherIcon.ico │ │ ├── OneLauncherIcon.png │ │ ├── ddo_icon.ico │ │ ├── lotro_icon.ico │ │ └── placeholder_icon.svg │ ├── locale │ │ ├── de │ │ │ ├── images │ │ │ │ ├── LOTRO_banner.png │ │ │ │ └── flag_icon.png │ │ │ └── language_info.toml │ │ ├── en-US │ │ │ ├── images │ │ │ │ ├── DDO_banner.png │ │ │ │ ├── LOTRO_banner.png │ │ │ │ └── flag_icon.png │ │ │ └── language_info.toml │ │ └── fr │ │ │ ├── images │ │ │ ├── LOTRO_banner.png │ │ │ └── flag_icon.png │ │ │ └── language_info.toml │ ├── logs.py │ ├── main_window.py │ ├── mypy_plugin.py │ ├── network │ │ ├── game_launcher_config.py │ │ ├── game_newsfeed.py │ │ ├── game_services_info.py │ │ ├── httpx_client.py │ │ ├── login_account.py │ │ ├── schemas │ │ │ ├── world_queue_result.xsd │ │ │ └── world_status.xsd │ │ ├── soap.py │ │ ├── world.py │ │ └── world_login_queue.py │ ├── official_clients.py │ ├── patch_game_window.py │ ├── patching_progress_monitor.py │ ├── program_config.py │ ├── py.typed │ ├── qtapp.py │ ├── resources.py │ ├── schemas │ │ └── v1x_config.xsd │ ├── settings_window.py │ ├── setup_wizard.py │ ├── standard_game_launcher.py │ ├── start_game.py │ ├── ui │ │ ├── about.ui │ │ ├── about_uic.py │ │ ├── addon_manager.ui │ │ ├── addon_manager_uic.py │ │ ├── custom_widgets.py │ │ ├── error_message.ui │ │ ├── error_message_uic.py │ │ ├── log_window.ui │ │ ├── log_window_uic.py │ │ ├── main.ui │ │ ├── main_uic.py │ │ ├── patching_window.ui │ │ ├── patching_window_uic.py │ │ ├── qtdesigner │ │ │ ├── __init__.py │ │ │ ├── custom_widgets.py │ │ │ ├── register_plugin.py │ │ │ └── style_preview_plugin.py │ │ ├── select_subscription.ui │ │ ├── select_subscription_uic.py │ │ ├── settings.ui │ │ ├── settings_uic.py │ │ ├── setup_wizard.ui │ │ ├── setup_wizard_uic.py │ │ ├── start_game.ui │ │ ├── start_game_uic.py │ │ ├── start_game_window.py │ │ └── style.py │ ├── ui_utilities.py │ ├── utilities.py │ ├── v1x_config_migrator.py │ ├── wine │ │ └── config.py │ └── wine_environment.py └── run_patch_client │ ├── .gitignore │ ├── Makefile │ ├── default.nix │ └── run_patch_client.c ├── stubs ├── qframelesswindow │ └── __init__.pyi └── qtawesome │ └── __init__.pyi ├── tests └── onelauncher │ ├── _test_mypy_plugin.mypy_test_data.py │ ├── network │ └── test_game_launcher_config.py │ ├── test_config_manager.py │ ├── test_mypy_plugin.py │ └── test_utilities.py └── uv.lock /.envrc: -------------------------------------------------------------------------------- 1 | watch_file flake.nix flake.lock pyproject.toml uv.lock 2 | 3 | use flake . 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: JuneStepp 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: junestepp 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: JuneStepp 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | permissions: 4 | contents: write # For making release 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*.*" 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | name: Build for ${{ matrix.os }} 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-22.04, windows-latest] 19 | include: 20 | - os: ubuntu-22.04 21 | artifact_path_name: onelauncher.bin 22 | artifact_rename: OneLauncher-Linux.bin 23 | - os: windows-latest 24 | artifact_path_name: OneLauncher.msi 25 | artifact_rename: OneLauncher-Windows.msi 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | # `run_patch_client` C code 31 | - name: Install mingw-w64 on Linux 32 | if: runner.os == 'Linux' 33 | run: sudo apt-get install mingw-w64 34 | - name: Build `run_patch_client` 35 | if: runner.os != 'Windows' 36 | run: make -C src/run_patch_client 37 | - name: Install mingw-w64 on Windows 38 | if: runner.os == 'Windows' 39 | uses: msys2/setup-msys2@v2 40 | with: 41 | msystem: MINGW32 42 | install: mingw-w64-i686-toolchain make 43 | - name: Build `run_patch_client` on Windows 44 | if: runner.os == 'Windows' 45 | shell: msys2 {0} 46 | run: make -C src/run_patch_client 47 | 48 | # uv 49 | # Can't use uv python with Nuitka yet 50 | # See https://github.com/Nuitka/Nuitka/issues/3331 51 | - name: Install Python 52 | uses: actions/setup-python@v5 53 | with: 54 | python-version: "3.11" 55 | - name: Install uv 56 | uses: astral-sh/setup-uv@v6 57 | with: 58 | activate-environment: true 59 | - run: uv sync --locked --no-dev --group build 60 | 61 | # Nuitka cache 62 | - name: Install ccache for Nuitka 63 | if: runner.os == 'Linux' 64 | run: sudo apt-get install -y ccache 65 | - name: Setup Nuitka env variables 66 | shell: bash 67 | run: | 68 | echo "NUITKA_CACHE_DIR=nuitka/cache" >> $GITHUB_ENV 69 | echo "PYTHON_VERSION=$(python --version | awk '{print $2}' | cut -d '.' -f 1,2)" >> $GITHUB_ENV 70 | - name: Cache Nuitka cache directory 71 | if: ${{ !inputs.disable-cache }} 72 | uses: actions/cache@v4 73 | with: 74 | path: ${{ env.NUITKA_CACHE_DIR }} 75 | key: ${{ runner.os }}-${{ runner.arch }}-python-${{ env.PYTHON_VERSION }}-nuitka-${{ github.sha }} 76 | restore-keys: | 77 | ${{ runner.os }}-${{ runner.arch }}-python-${{ env.PYTHON_VERSION }}- 78 | ${{ runner.os }}-${{ runner.arch }}-python- 79 | ${{ runner.os }}-${{ runner.arch }}- 80 | 81 | - name: Setup dotnet for building Windows installer 82 | if: runner.os == 'Windows' 83 | uses: actions/setup-dotnet@v4 84 | with: 85 | dotnet-version: 8.0.x 86 | 87 | - name: Build 88 | run: python -m build 89 | - name: Rename artifact 90 | run: mv build/out/${{ matrix.artifact_path_name }} build/out/${{ matrix.artifact_rename }} 91 | - name: Upload build artifact 92 | uses: actions/upload-artifact@v4 93 | with: 94 | name: ${{ matrix.artifact_rename }} 95 | path: build/out/${{ matrix.artifact_rename }} 96 | if-no-files-found: error 97 | release: 98 | # Only make a release for new tags 99 | if: startsWith(github.ref, 'refs/tags/') 100 | needs: [build] 101 | runs-on: ubuntu-latest 102 | name: Make draft release and upload artifacts 103 | steps: 104 | - name: Download build artifacts 105 | uses: actions/download-artifact@v4 106 | with: 107 | path: build_artifacts/ 108 | - name: Release 109 | uses: softprops/action-gh-release@v2 110 | with: 111 | draft: true 112 | fail_on_unmatched_files: true 113 | files: "${{ github.workspace }}/build_artifacts/*/*" 114 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | *.xml.backup 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | *.build/ 30 | *.dist/ 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # asdf 89 | .tool-versions 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # celery beat schedule file 99 | celerybeat-schedule 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # VSCode 132 | .vscode/ 133 | 134 | # Nix 135 | .direnv/ -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | Lord of the Rings Online and Dungeons & Dragons Online 4 | Launcher for Linux, Mac OS X, and Windows. 5 | 6 | - [OneLauncher](https://github.com/JuneStepp/OneLauncher) 7 | (C) 2019-2025 June Stepp \ 8 | - Based on [PyLotRO](https://github.com/nwestfal/pylotro) 9 | (C) 2009 AJackson 10 | - Based on [LotROLinux](https://web.archive.org/web/20120424132519/http://www.lotrolinux.com/) 11 | (C) 2007-2008 AJackson 12 | - Based on [CLI launcher for LOTRO](https://sny.name/LOTRO/) 13 | (C) 2007-2009 SNy 14 | 15 | ## Acknowledgements 16 | 17 | - Many thanks to the many users at Ubuntu Forums, 18 | CodeWeavers forums and the official game forums 19 | for helping to test and improve LotROLinux and 20 | providing ideas that have lead to the development 21 | of PyLotRO. 22 | - The various Github users who kept PyLotRO up to date for a while after it was no longer maintained by AJackson 23 | - Cool people of the Lotro Discord 24 | - [@lunarwtr](https://github.com/lunarwtr) for the creation of [LOTRO Plugin Compendium](https://www.lotrointerface.com/downloads/info663-LOTROPluginCompendium.html) 25 | 26 | ## Resources Used 27 | 28 | - [Flag icons](https://github.com/markjames/famfamfam-flag-icons) by Mark James at [famfamfam.com](https://famfamfam.com) 29 | - [Font Awesome Icons](https://fontawesome.com) 30 | - [Material Design Icons](https://fonts.google.com/icons) 31 | - XML Schemas from [vscode-lotro-api](https://github.com/lunarwtr/vscode-lotro-api) by [@lunawtr](https://github.com/lunarwtr) (the creator of [LOTRO Plugin Compendium](https://www.lotrointerface.com/downloads/info663-LOTROPluginCompendium.html)) 32 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to OneLauncher 2 | 3 | Contributions and questions are always welcome! Here's just a couple of things to keep in mind: 4 | 5 | - Remember to search the [GitHub Issues](https://github.com/JuneStepp/OneLauncher/issues) before making a bug report or feature request. 6 | - Please [open an issue](https://github.com/JuneStepp/OneLauncher/issues/new/choose) to discuss changes before creating a pull request. 7 | 8 | ## Development Install 9 | 10 | OneLauncher uses [uv](https://docs.astral.sh/uv/getting-started/installation/) for dependency management. Run `uv run onelauncher` in the root folder of this repository to install and start OneLauncher. Alternatively, [Nix can be used](#nix). 11 | 12 | For game patching support, extra C code must be compiled. Run `make -C src/run_patch_client` with mingw-w64 installed. Your mingw-w64 installation must have support for i686 builds. 13 | 14 | ### Nix 15 | 16 | OneLauncher comes with a [Nix](https://nixos.org/) flake for easily replicating the standard development environment. It can be used with [direnv](https://github.com/direnv/direnv) or the `nix develop` command. 17 | 18 | The compiled builds can be tested on NixOS with `nix run .#fhs-run build/out/onelauncher.bin`. 19 | 20 | ## Building 21 | 22 | Build by running `uv run python -m build` in the project's root directory. This will output everything to "build/out". 23 | Individual scripts can also be called to skip parts of the build or pass arguments to the build tool. 24 | 25 | The .NET CLI is required for building the Windows installer. 26 | 27 | ## Translation 28 | 29 | OneLauncher uses [Weblate](weblate.org) for translations. You can make an account and contribute translations through their site. See the project page [here](https://hosted.weblate.org/projects/onelauncher/). 30 | 31 | ## Coding Conventions 32 | 33 | All code is strictly type checked with Mypy and both linted and formatted with Ruff. Pytest unit tests are encouraged. 34 | 35 | ## UI Changes 36 | 37 | User interfaces are defined in `.ui` files that can be visually edited in pyside6-designer. You can use the command `onelauncher designer` to launch pyside6-designer with OneLauncher's plugins enabled. 38 | 39 | UI files must be compiled into Python to be used in OneLauncher. This can be done with `pyside6-uic src/onelauncher/ui/example_window.ui -o src/onelauncher/ui/example_window_uic.py`, replacing "example_window" with the one being updated. 40 | 41 | ### QSS Classes 42 | 43 | OneLauncher uses a system similar to [Tailwind CSS](https://tailwindcss.com/) for styling UIs responsively. 44 | 45 | Here's how it works: 46 | 47 | - The [dynamic property](https://doc.qt.io/qt-6/designer-widget-mode.html#dynamic-properties) `qssClass` is added to the widget that needs to be styled. This property should always be of the type `StringList`. 48 | - Each string in `qssClass` is a "class" that will affect the widget's styling. This works by selecting for them in dynamically generated [Qt Style Sheets](https://doc.qt.io/qt-6/stylesheet.html). 49 | - The classes are mainly used to set sizes and margins relative to the system font size. Changes can be previewed live in pyside6-designer. 50 | - See the [Tailwind Docs](https://tailwindcss.com/docs/utility-first) for specific class names. The currently supported types are [padding](https://tailwindcss.com/docs/padding), [margin](https://tailwindcss.com/docs/margin), [width](https://tailwindcss.com/docs/width), [min-width](https://tailwindcss.com/docs/min-width), [max-width](https://tailwindcss.com/docs/max-width), [height](https://tailwindcss.com/docs/height), [min-height](https://tailwindcss.com/docs/min-height), [max-height](https://tailwindcss.com/docs/max-height), [font-size](https://tailwindcss.com/docs/font-size), and icon-size. 51 | -------------------------------------------------------------------------------- /build/.gitignore: -------------------------------------------------------------------------------- 1 | out/ -------------------------------------------------------------------------------- /build/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/build/__init__.py -------------------------------------------------------------------------------- /build/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | from . import convert_readme_to_bbcode, nuitka_compile 5 | 6 | out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent / "out" 7 | out_dir.mkdir(exist_ok=True) 8 | 9 | bbcode_readme = convert_readme_to_bbcode.convert( 10 | (Path(__file__).parent.parent / "README.md").read_text(), 11 | ) 12 | (out_dir / "README_BBCode.txt").write_text(bbcode_readme) 13 | 14 | nuitka_compile.main( 15 | out_dir=out_dir, onefile_mode=sys.platform == "linux", nuitka_deployment_mode=True 16 | ) 17 | if sys.platform == "win32": 18 | from .windows_installer import build_installer 19 | 20 | build_installer.main( 21 | input_dist_dir=out_dir / nuitka_compile.get_dist_dir_name(), out_dir=out_dir 22 | ) 23 | -------------------------------------------------------------------------------- /build/convert_readme_to_bbcode.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from html.parser import HTMLParser 4 | from typing import cast 5 | from urllib.parse import urlparse, urlunparse 6 | 7 | import marko 8 | from typing_extensions import override 9 | 10 | from onelauncher.__about__ import __project_url__ 11 | 12 | 13 | class HTMLToBBCodeParser(HTMLParser): 14 | @override 15 | def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: 16 | if tag not in self.attrs: 17 | self.attrs[tag] = [] 18 | 19 | attrs_dict: dict[str, str | None] = dict(attrs) 20 | if tag == "p" and attrs_dict.get("align") == "center": 21 | self.data.append("[CENTER]") 22 | self.attrs[tag].append(attrs_dict) 23 | elif tag == "a": 24 | self.data.append(f"[URL={attrs_dict.get('href', '')}]") 25 | elif tag == "img": 26 | self.data.append(f"[IMG]{attrs_dict.get('src', '')}") 27 | elif tag == "span": 28 | return 29 | else: 30 | string_attrs = "".join( 31 | f" {key}={value}" for key, value in attrs_dict.items() 32 | ) 33 | self.data.append(f"<{tag}{string_attrs}>") 34 | 35 | @override 36 | def handle_endtag(self, tag: str) -> None: 37 | if self.attrs.get(tag): 38 | attrs = self.attrs[tag][-1] 39 | if attrs.get("align") == "center": 40 | self.data.append("[/CENTER]") 41 | 42 | del self.attrs[tag][-1] 43 | elif tag == "a": 44 | self.data.append("[/URL]") 45 | elif tag == "img": 46 | self.data.append("[/IMG]") 47 | elif tag == "span": 48 | return 49 | else: 50 | self.data.append(f"") 51 | 52 | @override 53 | def handle_data(self, data: str) -> None: 54 | self.data.append(data) 55 | 56 | @override 57 | def handle_comment(self, data: str) -> None: 58 | """Don't render comments""" 59 | return 60 | 61 | def get_bbcode(self, data: str) -> str: 62 | self.attrs: dict[str, list[dict[str, str | None]]] = {} 63 | self.data: list[str] = [] 64 | super().feed(data) 65 | return "".join(self.data) 66 | 67 | 68 | class BBCodeRenderer(marko.renderer.Renderer): 69 | """Render as LotroInterface and NexusMods compatible BBCode""" 70 | 71 | html_parser = HTMLToBBCodeParser() 72 | parsed_github_project_url = urlparse(__project_url__) 73 | 74 | def render_paragraph(self, element: marko.block.Paragraph) -> str: 75 | children = self.render_children(element) 76 | return f"{children}\n" 77 | 78 | def render_list(self, element: marko.block.List) -> str: 79 | return f"[LIST{'=1' if element.ordered else ''}]\n{self.render_children(element)}[/LIST]" 80 | 81 | def render_list_item(self, element: marko.block.ListItem) -> str: 82 | return f"[*]{self.render_children(element)}" 83 | 84 | def render_quote(self, element: marko.block.Quote) -> str: 85 | return f"[QUOTE]\n{self.render_children(element)}\n" 86 | 87 | def render_fenced_code(self, element: marko.block.FencedCode) -> str: 88 | return f"[CODE]{self.render_children(element)}[/CODE]\n" # type 89 | 90 | def render_code_block(self, element: marko.block.CodeBlock) -> str: 91 | return self.render_fenced_code(cast(marko.block.FencedCode, element)) 92 | 93 | def render_html_block(self, element: marko.block.HTMLBlock) -> str: 94 | return self.html_parser.get_bbcode(element.body) 95 | 96 | def render_thematic_break(self, element: marko.block.ThematicBreak) -> str: 97 | return "" 98 | 99 | def render_heading(self, element: marko.block.Heading) -> str: 100 | bbcode_size = max(7 - element.level, 1) 101 | return f"[SIZE={bbcode_size}][B]{self.render_children(element)}[/B][/SIZE]\n" 102 | 103 | def render_setext_heading(self, element: marko.block.SetextHeading) -> str: 104 | return self.render_heading(cast(marko.block.Heading, element)) 105 | 106 | def render_blank_line(self, element: marko.block.BlankLine) -> str: 107 | return "\n" 108 | 109 | def render_link_ref_def(self, element: marko.block.LinkRefDef) -> str: 110 | return f"[URL={element.dest}]{element.title or element.dest}[/URL]" 111 | 112 | def render_emphasis(self, element: marko.inline.Emphasis) -> str: 113 | return f"[I]{self.render_children(element)}[/I]" 114 | 115 | def render_strong_emphasis(self, element: marko.inline.StrongEmphasis) -> str: 116 | return f"[B]{self.render_children(element)}[/B]" 117 | 118 | def render_inline_html(self, element: marko.inline.InlineHTML) -> str: 119 | return cast(str, element.children) 120 | 121 | def render_link(self, element: marko.inline.Link) -> str: 122 | dest = element.dest 123 | parsed_dest = urlparse(element.dest) 124 | if not parsed_dest.scheme and not parsed_dest.netloc: 125 | dest = urlunparse( 126 | self.parsed_github_project_url._replace( 127 | path=f"{self.parsed_github_project_url.path}{f'/blob/HEAD/{parsed_dest.path}' if parsed_dest.path else ''}", 128 | fragment=parsed_dest.fragment, 129 | ) 130 | ) 131 | return f"[URL={dest}]{element.title or ''}{self.render_children(element)}[/URL]" 132 | 133 | def render_auto_link(self, element: marko.inline.AutoLink) -> str: 134 | return self.render_link(cast(marko.inline.Link, element)) 135 | 136 | def render_image(self, element: marko.inline.Image) -> str: 137 | return f"[IMG]{element.dest}[/IMG]" 138 | 139 | def render_literal(self, element: marko.inline.Literal) -> str: 140 | return f"[NOPARSE]{element.children}[/NOPARSE]" 141 | 142 | def render_raw_text(self, element: marko.inline.RawText) -> str: 143 | return f"{element.children}" 144 | 145 | def render_line_break(self, element: marko.inline.LineBreak) -> str: 146 | return "\n" if element.soft else "\\\n" 147 | 148 | def render_code_span(self, element: marko.inline.CodeSpan) -> str: 149 | return f"[B][FONT=Courier New]{element.children}[/FONT][/B]" 150 | 151 | 152 | def convert(readme_text: str) -> str: 153 | markdown_class = marko.Markdown(renderer=BBCodeRenderer) 154 | return markdown_class.convert(readme_text) 155 | -------------------------------------------------------------------------------- /build/nuitka_compile.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | from collections.abc import Iterable 4 | from pathlib import Path 5 | 6 | from onelauncher import __about__ 7 | 8 | 9 | def get_dist_executable_stem() -> str: 10 | return __about__.__package__ 11 | 12 | 13 | def get_dist_dir_name() -> str: 14 | return f"{get_dist_executable_stem()}.dist" 15 | 16 | 17 | def main( 18 | out_dir: Path | None = None, 19 | onefile_mode: bool = False, 20 | nuitka_deployment_mode: bool = False, 21 | extra_args: Iterable[str] = (), 22 | ) -> None: 23 | nuitka_arguments = [ 24 | f"--output-dir={Path(__file__) / 'out'}", 25 | "--onefile" if onefile_mode else "--standalone", 26 | "--python-flag=-m", # Package mode. Compile as "pakcage.__main__" 27 | "--python-flag=isolated", 28 | "--python-flag=no_docstrings", 29 | "--warn-unusual-code", 30 | "--nofollow-import-to=tkinter,pydoc,pdb,PySide6.QtOpenGL,PySide6.QtOpenGLWidgets,zstandard,asyncio,anyio._backends._asyncio,smtplib,requests,requests_file", 31 | "--noinclude-setuptools-mode=nofollow", 32 | "--noinclude-unittest-mode=nofollow", 33 | "--noinclude-pytest-mode=nofollow", 34 | "--enable-plugins=pyside6", 35 | "--include-data-files=src/run_patch_client/run_ptch_client.exe=run_patch_client/run_ptch_client.exe", 36 | "--include-data-files=src/onelauncher/=onelauncher/=**/*.xsd", 37 | "--include-data-dir=src/onelauncher/images=onelauncher/images", 38 | "--include-data-dir=src/onelauncher/locale=onelauncher/locale", 39 | f"--product-name={__about__.__title__}", 40 | # Base version, because no strings are allowed. 41 | f"--product-version={__about__.version_parsed.base_version}", 42 | f"--file-description={__about__.__title__}", 43 | f"--copyright={__about__.__copyright__}", 44 | ] 45 | if out_dir: 46 | nuitka_arguments.append(f"--output-dir={out_dir}") 47 | if nuitka_deployment_mode: 48 | nuitka_arguments.append("--deployment") 49 | if sys.platform != "win32": 50 | # Can't use static libpython on Windows currently as far as I know 51 | nuitka_arguments.append("--static-libpython=yes") 52 | if sys.platform == "win32": 53 | nuitka_arguments.extend( 54 | [ 55 | "--assume-yes-for-downloads", 56 | "--windows-console-mode=attach", 57 | "--windows-icon-from-ico=src/onelauncher/images/OneLauncherIcon.ico", 58 | ] 59 | ) 60 | elif sys.platform == "darwin": 61 | nuitka_arguments.extend( 62 | [ 63 | f"--macos-app-name={__about__.__title__}", 64 | f"--macos-app-version={__about__.__version__}", 65 | "--macos-app-icon=src/onelauncher/images/OneLauncherIcon.png", 66 | ] 67 | ) 68 | elif sys.platform == "linux": 69 | nuitka_arguments.extend( 70 | [ 71 | "--linux-icon=src/onelauncher/images/OneLauncherIcon.png", 72 | ] 73 | ) 74 | subprocess.run( # noqa: S603 75 | [ 76 | sys.executable or "python", 77 | "-m", 78 | "nuitka", 79 | f"src/{__about__.__package__}", 80 | *nuitka_arguments, 81 | *extra_args, 82 | ], 83 | check=True, 84 | ) 85 | 86 | 87 | if __name__ == "__main__": 88 | main(extra_args=sys.argv[1:]) 89 | -------------------------------------------------------------------------------- /build/windows_installer/.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ -------------------------------------------------------------------------------- /build/windows_installer/OneLauncher.wixproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | $(PRODUCT_NAME) 4 | x64 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /build/windows_installer/Package.wxs: -------------------------------------------------------------------------------- 1 | 8 | 16 | 17 | 19 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 58 | 63 | 64 | 65 | 66 | 67 | 73 | 79 | 85 | 86 | 87 | 90 | 96 | 97 | 98 | 99 | 103 | 112 | 120 | 121 | 129 | 130 | 131 | 132 | 133 | 137 | 146 | 151 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /build/windows_installer/build_installer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | from collections.abc import Iterable 5 | from pathlib import Path 6 | 7 | from onelauncher import __about__, resources 8 | 9 | 10 | def main( 11 | input_dist_dir: Path | None = None, 12 | out_dir: Path | None = None, 13 | extra_args: Iterable[str] = (), 14 | ) -> None: 15 | env = os.environ.copy() | { 16 | "PRODUCT_NAME": __about__.__title__, 17 | "AUTHOR": __about__.__author__, 18 | "VERSION": __about__.version_parsed.base_version, 19 | "WEBSITE": __about__.__project_url__, 20 | "ICON_PATH": str(resources.data_dir / "images/OneLauncherIcon.ico"), 21 | "EXECUTABLE_NAME": f"{__about__.__package__}.exe", 22 | } 23 | if input_dist_dir: 24 | env["DIST_PATH"] = str(input_dist_dir) 25 | 26 | args = ["dotnet", "build", "-c", "Release"] 27 | if out_dir: 28 | args.extend(("--output", str(out_dir))) 29 | args.extend(extra_args) 30 | subprocess.run( # noqa: S603 31 | args, 32 | env=env, 33 | cwd=Path(__file__).parent, 34 | check=True, 35 | ) 36 | 37 | 38 | if __name__ == "__main__": 39 | main(extra_args=sys.argv[1:]) 40 | -------------------------------------------------------------------------------- /build/windows_installer/installer_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/build/windows_installer/installer_background.png -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1748190013, 24 | "narHash": "sha256-R5HJFflOfsP5FBtk+zE8FpL8uqE7n62jqOsADvVshhE=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "62b852f6c6742134ade1abdd2a21685fd617a291", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "pyproject-build-systems": { 38 | "inputs": { 39 | "nixpkgs": [ 40 | "nixpkgs" 41 | ], 42 | "pyproject-nix": [ 43 | "pyproject-nix" 44 | ], 45 | "uv2nix": [ 46 | "uv2nix" 47 | ] 48 | }, 49 | "locked": { 50 | "lastModified": 1744599653, 51 | "narHash": "sha256-nysSwVVjG4hKoOjhjvE6U5lIKA8sEr1d1QzEfZsannU=", 52 | "owner": "pyproject-nix", 53 | "repo": "build-system-pkgs", 54 | "rev": "7dba6dbc73120e15b558754c26024f6c93015dd7", 55 | "type": "github" 56 | }, 57 | "original": { 58 | "owner": "pyproject-nix", 59 | "repo": "build-system-pkgs", 60 | "type": "github" 61 | } 62 | }, 63 | "pyproject-nix": { 64 | "inputs": { 65 | "nixpkgs": [ 66 | "nixpkgs" 67 | ] 68 | }, 69 | "locked": { 70 | "lastModified": 1746540146, 71 | "narHash": "sha256-QxdHGNpbicIrw5t6U3x+ZxeY/7IEJ6lYbvsjXmcxFIM=", 72 | "owner": "pyproject-nix", 73 | "repo": "pyproject.nix", 74 | "rev": "e09c10c24ebb955125fda449939bfba664c467fd", 75 | "type": "github" 76 | }, 77 | "original": { 78 | "owner": "pyproject-nix", 79 | "repo": "pyproject.nix", 80 | "type": "github" 81 | } 82 | }, 83 | "root": { 84 | "inputs": { 85 | "flake-utils": "flake-utils", 86 | "nixpkgs": "nixpkgs", 87 | "pyproject-build-systems": "pyproject-build-systems", 88 | "pyproject-nix": "pyproject-nix", 89 | "uv2nix": "uv2nix" 90 | } 91 | }, 92 | "systems": { 93 | "locked": { 94 | "lastModified": 1681028828, 95 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 96 | "owner": "nix-systems", 97 | "repo": "default", 98 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 99 | "type": "github" 100 | }, 101 | "original": { 102 | "owner": "nix-systems", 103 | "repo": "default", 104 | "type": "github" 105 | } 106 | }, 107 | "uv2nix": { 108 | "inputs": { 109 | "nixpkgs": [ 110 | "nixpkgs" 111 | ], 112 | "pyproject-nix": [ 113 | "pyproject-nix" 114 | ] 115 | }, 116 | "locked": { 117 | "lastModified": 1747949765, 118 | "narHash": "sha256-1v8SFHOwUCvHDXFmQRjHZYawY19nxmtZ7zH/kwAGgj0=", 119 | "owner": "pyproject-nix", 120 | "repo": "uv2nix", 121 | "rev": "ec0502250b48116fd3aa8e1347a2d0254bacd05e", 122 | "type": "github" 123 | }, 124 | "original": { 125 | "owner": "pyproject-nix", 126 | "repo": "uv2nix", 127 | "type": "github" 128 | } 129 | } 130 | }, 131 | "root": "root", 132 | "version": 7 133 | } 134 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "OneLauncher" 3 | version = "2.0.2" 4 | description = "The OneLauncher to rule them all" 5 | authors = [{ name = "June Stepp", email = "contact@junestepp.me" }] 6 | requires-python = ">=3.11,<3.12" 7 | readme = "README.md" 8 | license = "GPL-3.0-or-later" 9 | keywords = ["LOTRO", "DDO", "launcher", "addon-manager", "custom-launcher"] 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Intended Audience :: End Users/Desktop", 13 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 14 | "Operating System :: OS Independent", 15 | "Topic :: Games/Entertainment :: Role-Playing", 16 | "Topic :: Utilities", 17 | ] 18 | dependencies = [ 19 | "PySide6-Essentials>=6.7.2", 20 | "qtawesome>=1.3.1", 21 | "pysidesix-frameless-window>=0.3.12", 22 | 23 | # Keyring packages 24 | "keyring>=25.3.0", 25 | "cryptography>=43.0.0", 26 | "SecretStorage>=3.3.3 ; sys_platform == 'linux'", 27 | 28 | "platformdirs>=4.2.2", 29 | "defusedxml>=0.7.1", 30 | "xmlschema>=3.3.2", 31 | "feedparser>=6.0.11", 32 | "Babel>=2.16.0", 33 | "trio>=0.26.2", 34 | "httpx>=0.27.0", 35 | "zeep", 36 | "cachetools>=5.4.0", 37 | "asyncache", 38 | "attrs>=24.2.0", 39 | "cattrs[tomlkit]>=23.2.3", 40 | "typer>=0.12.3", 41 | "packaging>=24.1", 42 | ] 43 | 44 | [project.urls] 45 | Repository = "https://github.com/JuneStepp/OneLauncher" 46 | Issues = "https://github.com/JuneStepp/OneLauncher/issues" 47 | ChangeLog = "https://github.com/JuneStepp/OneLauncher/blob/main/CHANGES.md" 48 | 49 | [project.scripts] 50 | onelauncher = "onelauncher.cli:app" 51 | 52 | [dependency-groups] 53 | lint = [ 54 | "mypy>=1.11.1", 55 | "types-cachetools>=5.3.0.7", 56 | "ruff>=0.11.11", 57 | # Newer versions have better types 58 | "PySide6-Essentials>=6.9.0", 59 | ] 60 | test = [ 61 | "pytest>=8.3.2", 62 | "pytest-randomly>=3.15.0", 63 | # Used to test mypy plugin 64 | "mypy", 65 | ] 66 | build = ["Nuitka>=2.4.8", "marko>=2.1.2"] 67 | dev = [ 68 | { include-group = "lint" }, 69 | { include-group = "test" }, 70 | { include-group = "build" }, 71 | ] 72 | 73 | [tool.uv.sources] 74 | zeep = { git = "https://github.com/JuneStepp/python-zeep.git" } 75 | asyncache = { git = "https://github.com/JuneStepp/asyncache.git" } 76 | 77 | [tool.hatch.build.targets.sdist] 78 | packages = ["src/onelauncher"] 79 | 80 | [tool.hatch.build.targets.wheel] 81 | packages = ["src/onelauncher"] 82 | 83 | [build-system] 84 | requires = ["hatchling"] 85 | build-backend = "hatchling.build" 86 | 87 | [tool.ruff] 88 | extend-exclude = ["*_uic.py"] # Ignore autogenerated UI files 89 | 90 | [tool.ruff.lint] 91 | select = [ 92 | "E4", # pycodestyle 93 | "E7", 94 | "E9", 95 | "F", # Pyflakes 96 | "UP", # pyupgrade 97 | "B", # flake8-bugbear 98 | "SIM", # flake8-simplify 99 | "I", # isort, 100 | "RUF", # Ruff 101 | "S", # flake8-bandit 102 | "ASYNC", # flake8-async 103 | "ANN", # flake8-annotations 104 | "A", # flake8-builtins 105 | "FA", # flake8-future-annotations 106 | "T20", # flake8-print 107 | "FIX", # flake8-fixme 108 | "ERA", # eradicate 109 | "PL", # Pylint 110 | "PT", # flake8-pytest-style 111 | ] 112 | ignore = [ 113 | "PLR0913", # too-many-arguments 114 | "PLR0915", # too-many-statements 115 | "PLR0912", # too-many-branches 116 | "S113", # request-without-timeout. httpx has default timeouts 117 | ] 118 | per-file-ignores."tests/**.py" = ["S101"] # assert 119 | flake8-annotations.mypy-init-return = true 120 | flake8-bugbear.extend-immutable-calls = ["onelauncher.config.config_field"] 121 | 122 | [tool.ruff.format] 123 | docstring-code-format = true 124 | 125 | [tool.pytest.ini_options] 126 | addopts = ["--import-mode=importlib", "--strict-markers", "--strict-config"] 127 | testpaths = ["tests"] 128 | filterwarnings = ["error"] 129 | xfail_strict = true 130 | 131 | [tool.mypy] 132 | plugins = ["onelauncher.mypy_plugin"] 133 | mypy_path = "stubs" 134 | 135 | strict = true 136 | warn_redundant_casts = true 137 | 138 | # Disallow most dynamic typing 139 | warn_return_any = true 140 | disallow_any_unimported = true 141 | disallow_any_decorated = true 142 | disallow_any_generics = true 143 | disallow_subclassing_any = true 144 | 145 | warn_unreachable = true 146 | implicit_reexport = false 147 | 148 | enable_error_code = "redundant-expr,possibly-undefined,truthy-bool,truthy-iterable,ignore-without-code,unused-awaitable,explicit-override,mutable-override,unimported-reveal" 149 | exclude_gitignore = true 150 | exclude = ["\\.mypy_test_data\\.py"] 151 | 152 | [[tool.mypy.overrides]] 153 | # TODO: Future version will have typing, 154 | # once https://github.com/kurtmckee/feedparser/blob/develop/changelog.d/20210801_133300_palfrey_typing.rst 155 | # makes it to a release 156 | module = ["feedparser"] 157 | ignore_missing_imports = true 158 | -------------------------------------------------------------------------------- /src/onelauncher/__about__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | from packaging.version import Version 4 | 5 | # Metadata has been temporily manually entered to work with Nuitka. 6 | # See https://github.com/Nuitka/Nuitka/issues/2965 7 | 8 | # metadata = importlib.metadata.metadata(__package__) # noqa: ERA001 9 | __title__ = "OneLauncher" 10 | __version__ = importlib.metadata.version(__package__) 11 | __description__ = "The OneLauncher to rule them all" 12 | __project_url__ = "https://github.com/JuneStepp/OneLauncher" 13 | __author__ = "June Stepp" 14 | __author_email__ = "contact@junestepp.me" 15 | __license__ = "GPL-3.0-or-later" 16 | # __title__ = metadata["Name"] # noqa: ERA001 17 | # __version__ = metadata["Version"] # noqa: ERA001 18 | version_parsed = Version(__version__) 19 | # __description__ = metadata["Summary"] # noqa: ERA001 20 | # # Update checks only work with a repository hosted on GitHub. 21 | # __project_url__ = metadata.get("Home-page") # noqa: ERA001 22 | # __author__ = metadata.get("Author") # noqa: ERA001 23 | # __author_email__ = metadata.get("Author-email") # noqa: ERA001 24 | # __license__ = metadata.get("License") # noqa: ERA001 25 | __copyright__ = "(C) 2019-2025 June Stepp" 26 | __copyright_history__ = ( 27 | "Based on PyLotRO\n(C) 2009-2010 AJackson\n" 28 | "Based on LotROLinux\n(C) 2007-2008 AJackson\n" 29 | "Based on CLI launcher for LOTRO\n(C) 2007-2010 SNy" 30 | ) 31 | -------------------------------------------------------------------------------- /src/onelauncher/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/__init__.py -------------------------------------------------------------------------------- /src/onelauncher/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import app 2 | 3 | app() 4 | -------------------------------------------------------------------------------- /src/onelauncher/addons/config.py: -------------------------------------------------------------------------------- 1 | import attrs 2 | 3 | from ..config import config_field 4 | from .startup_script import StartupScript 5 | 6 | 7 | @attrs.frozen 8 | class AddonsConfigSection: 9 | enabled_startup_scripts: tuple[StartupScript, ...] = config_field( 10 | default=(), 11 | help="Python scripts run before game launch. Paths are relative to the game's documents config directory", 12 | ) 13 | -------------------------------------------------------------------------------- /src/onelauncher/addons/schemas/lotrointerface_feed.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/onelauncher/addons/schemas/vscode-lotro-api/LICENSE.md: -------------------------------------------------------------------------------- 1 | # License Info 2 | 3 | The XML schemas in this folder were originally taken from [vscode-lotro-api](https://github.com/lunarwtr/vscode-lotro-api). Below is a copy of the project's license at the time these files were taken (2022-04-28). 4 | 5 | >MIT License 6 | > 7 | >Copyright (c) 2022 lunarwtr 8 | > 9 | >Permission is hereby granted, free of charge, to any person obtaining a copy 10 | >of this software and associated documentation files (the "Software"), to deal 11 | >in the Software without restriction, including without limitation the rights 12 | >to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | >copies of the Software, and to permit persons to whom the Software is 14 | >furnished to do so, subject to the following conditions: 15 | > 16 | >The above copyright notice and this permission notice shall be included in all 17 | >copies or substantial portions of the Software. 18 | > 19 | >THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | >IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | >FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | >AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | >LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | >OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | >SOFTWARE. 26 | -------------------------------------------------------------------------------- /src/onelauncher/addons/schemas/vscode-lotro-api/compendium-basetypes.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | The ID on lotrointerface.com for your plugin, skin, or music. This is the numeric portion in the URL that points to your resource. 6 | For Example: https://www.lotrointerface.com/downloads/info640-Waypoint.html would be 640 7 | 8 | 9 | 10 | 11 | 12 | 13 | The name of the plugin, skin, or music. For a plugin, this should match the value in your .plugin file 14 | 15 | 16 | 17 | 18 | 19 | 20 | The Version of the plugin, skin, or music. For a plugin, this should match the value in your .plugin file 21 | 22 | 23 | 24 | 25 | 26 | 27 | The author of the plugin, skin, or music. For a plugin, this should match the value in your .plugin file 28 | 29 | 30 | 31 | 32 | 33 | 34 | The url to view information about the plugin, skin, or music on lotrointerface.com 35 | For Example: https://www.lotrointerface.com/downloads/info640-Waypoint.html 36 | 37 | 38 | 39 | 40 | 41 | 42 | The url to download the plugin, skin, or music on lotrointerface.com 43 | For Example: https://www.lotrointerface.com/downloads/download640-Waypoint 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | The path relative to a .plugin file that is to be installed 58 | Use backslash "\\" for path separators 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | The ID from lotrointerface.com of another plugin that is needed as a dependency for this plugin to operate. 73 | 74 | 75 | 76 | 77 | 78 | 79 | Relative path to an optional python script ran during startup 80 | of One Launcher (https://github.com/JuneStepp/OneLauncher) 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/onelauncher/addons/schemas/vscode-lotro-api/lotroplugin.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | The name used to load the plugin with the "/plugins load PluginName" as well as how it will appear in game in the "/plugins list" and "/plugins refresh" commands. If you use a plugin manager (a plugin that controls loading other plugins) this is also the name that will be listed in the manager. 13 | 14 | 15 | 16 | 17 | 18 | 19 | The name of the plugin author and is only included for documentary/organizational purposes. This has no actual impact on the functioning of the plugin but can be accessed programatially using the Plugins table. 20 | 21 | 22 | 23 | 24 | 25 | 26 | The version that will be displayed in the "/plugins list", "/plugins refresh" and plugin manager lists. This value can also be used programatically for tagging saved data and automatically processing data updates. 27 | 28 | 29 | 30 | 31 | 32 | 33 | The text that will display in the Turbine Plugin Manager to describe the plugin 34 | 35 | 36 | 37 | 38 | 39 | 40 | The realtive path to a .JPG or .TGA file. Note, if the file is greater than 32x32 it will be cropped to 32x32. If the image is less than 32x32 it will be tiled. This image will be displayed in the Turbine Plugin Manager 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | The path relative to the Plugins folder to the main Lua code file. Note that the path uses "." as a folder separator instead of "\" or "/". This is the first file that will be loaded, parsed and processed. 51 | 52 | 53 | 54 | 55 | 56 | 57 | The Configuration setting is optional and will allow a plugin to run in its own Apartment or address space. 58 | 59 | 60 | 61 | 62 | 63 | 64 | Allows a plugin to be unloaded without affecting other plugins or to prevent other plugins from 65 | interfering with global values and event handlers. If your plugin does not need to be 66 | unloaded and if it uses safe event handlers (discussed later) then you probably do not 67 | need a separate apartment. Note that using a separate apartment will significantly increase 68 | the amount of memory used by the Lua system since multiple copies of the environment and 69 | global object must be created for each apartment. 70 | 71 | One important thing to remember, Plugins are not unloaded, Apartments are unloaded. 72 | That is, when you use the "/plugins unload ApartmentName" command you are unloading 73 | all of the plugins that share that apartment. 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /src/onelauncher/addons/schemas/vscode-lotro-api/musiccompendium.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/onelauncher/addons/schemas/vscode-lotro-api/plugincompendium.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/onelauncher/addons/schemas/vscode-lotro-api/skincompendium.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/onelauncher/addons/startup_script.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import attrs 4 | 5 | from ..utilities import CaseInsensitiveAbsolutePath 6 | 7 | 8 | @attrs.frozen 9 | class StartupScript: 10 | """Python script that runs whenever a game is started 11 | 12 | Args: 13 | relative_path (Path): Path from the game documents config dir to 14 | the startup script. 15 | """ 16 | 17 | relative_path: Path 18 | 19 | def get_absolute_path( 20 | self, documents_config_dir: CaseInsensitiveAbsolutePath 21 | ) -> CaseInsensitiveAbsolutePath: 22 | return documents_config_dir / self.relative_path 23 | 24 | 25 | def run_startup_script( 26 | script: StartupScript, 27 | game_directory: CaseInsensitiveAbsolutePath, 28 | documents_config_dir: CaseInsensitiveAbsolutePath, 29 | ) -> None: 30 | """ 31 | Run Python startup script file. 32 | Script is given access to globals with game information 33 | 34 | Raises: 35 | FileNotFoundError: Startup script does not exist. 36 | SyntaxError: Startup script has a syntax error. 37 | """ 38 | with script.get_absolute_path( 39 | documents_config_dir=documents_config_dir 40 | ).open() as file: 41 | code = file.read() 42 | 43 | exec( # noqa: S102 44 | code, 45 | { 46 | "__file__": str(script.get_absolute_path(documents_config_dir)), 47 | "__game_dir__": str(game_directory), 48 | "__game_config_dir__": str(documents_config_dir), 49 | }, 50 | ) 51 | -------------------------------------------------------------------------------- /src/onelauncher/async_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Awaitable, Callable 3 | from typing import Any 4 | 5 | import outcome 6 | import trio 7 | from PySide6 import QtCore, QtWidgets 8 | from typing_extensions import override 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | # Top-level cancel scope. Canceling it will exit the program. 13 | app_cancel_scope = trio.CancelScope() 14 | 15 | 16 | class AsyncHelper(QtCore.QObject): 17 | class ReenterQtObject(QtCore.QObject): 18 | """This is a QObject to which an event will be posted, allowing 19 | Trio to resume when the event is handled. event.fn() is the 20 | next entry point of the Trio event loop.""" 21 | 22 | @override 23 | def event(self, event: QtCore.QEvent) -> bool: 24 | if event.type() == QtCore.QEvent.Type.User + 1 and isinstance( 25 | event, AsyncHelper.ReenterQtEvent 26 | ): 27 | event.fn() 28 | return True 29 | return False 30 | 31 | class ReenterQtEvent(QtCore.QEvent): 32 | """This is the QEvent that will be handled by the ReenterQtObject. 33 | self.fn is the next entry point of the Trio event loop.""" 34 | 35 | def __init__(self, fn: Callable[[], Any]): 36 | super().__init__(QtCore.QEvent.Type(QtCore.QEvent.Type.User + 1)) 37 | self.fn = fn 38 | 39 | def __init__(self, entry: Callable[[], Awaitable[Any]]): 40 | super().__init__() 41 | self.reenter_qt = self.ReenterQtObject() 42 | self.entry = entry 43 | 44 | def launch_guest_run(self) -> None: 45 | """ 46 | To use Trio and Qt together, one must run the Trio event 47 | loop as a "guest" inside the Qt "host" event loop. 48 | """ 49 | trio.lowlevel.start_guest_run( 50 | self.entry, 51 | run_sync_soon_threadsafe=self.next_guest_run_schedule, 52 | done_callback=self.trio_done_callback, 53 | strict_exception_groups=True, 54 | ) 55 | 56 | def next_guest_run_schedule(self, fn: Callable[[], Any]) -> None: 57 | """ 58 | This function serves to re-schedule the guest (Trio) event 59 | loop inside the host (Qt) event loop. It is called by Trio 60 | at the end of an event loop run in order to relinquish back 61 | to Qt's event loop. By posting an event on the Qt event loop 62 | that contains Trio's next entry point, it ensures that Trio's 63 | event loop will be scheduled again by Qt. 64 | """ 65 | QtWidgets.QApplication.postEvent(self.reenter_qt, self.ReenterQtEvent(fn)) 66 | 67 | def trio_done_callback(self, run_outcome: outcome.Outcome[Any]) -> None: 68 | """This function is called by Trio when its event loop has 69 | finished.""" 70 | if isinstance(run_outcome, outcome.Error): 71 | error = run_outcome.error 72 | logger.error("Trio Event loop error", exc_info=error) 73 | 74 | if qapp := QtCore.QCoreApplication.instance(): 75 | qapp.exit() 76 | -------------------------------------------------------------------------------- /src/onelauncher/game_account_config.py: -------------------------------------------------------------------------------- 1 | import attrs 2 | from packaging.version import Version 3 | from typing_extensions import override 4 | 5 | from .__about__ import __title__ 6 | from .config import Config, config_field 7 | 8 | 9 | @attrs.frozen 10 | class GameAccountConfig: 11 | username: str = config_field(help="Login username") 12 | display_name: str | None = config_field( 13 | default=None, help="Name shown instead of account name" 14 | ) 15 | last_used_world_name: str | None = config_field( 16 | default=None, help="World last logged into. Will be the default at next login" 17 | ) 18 | 19 | 20 | @attrs.frozen 21 | class GameAcccountNoUsername(GameAccountConfig): 22 | username: str = attrs.field(default="", init=False) 23 | 24 | 25 | @attrs.frozen 26 | class GameAccountsConfig(Config): 27 | accounts: tuple[GameAccountConfig, ...] 28 | 29 | @override 30 | @staticmethod 31 | def get_config_version() -> Version: 32 | return Version("2.0") 33 | 34 | @override 35 | @staticmethod 36 | def get_config_file_description() -> str: 37 | return f"A game accounts config file for {__title__}" 38 | -------------------------------------------------------------------------------- /src/onelauncher/game_config.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from datetime import datetime 3 | from enum import StrEnum 4 | from typing import TypeAlias 5 | from uuid import uuid4 6 | 7 | import attrs 8 | from packaging.version import Version 9 | from typing_extensions import override 10 | 11 | from .__about__ import __title__ 12 | from .addons.config import AddonsConfigSection 13 | from .config import Config, config_field 14 | from .resources import OneLauncherLocale 15 | from .utilities import CaseInsensitiveAbsolutePath 16 | from .wine.config import WineConfigSection 17 | 18 | 19 | class ClientType(StrEnum): 20 | WIN64 = "WIN64" 21 | WIN32 = "WIN32" 22 | WIN32_LEGACY = "WIN32Legacy" 23 | WIN32Legacy = "WIN32Legacy" 24 | 25 | 26 | class GameType(StrEnum): 27 | LOTRO = "LOTRO" 28 | DDO = "DDO" 29 | 30 | 31 | @attrs.frozen(kw_only=True) 32 | class GameConfig(Config): 33 | sorting_priority: int = -1 34 | game_type: GameType 35 | is_preview_client: bool 36 | name: str = attrs.field() # Default is from `self._get_name_default` 37 | description: str = "" 38 | game_directory: CaseInsensitiveAbsolutePath = config_field( 39 | help="The game's install directory" 40 | ) 41 | locale: OneLauncherLocale | None = config_field( 42 | default=None, help="Language used for game" 43 | ) 44 | client_type: ClientType = config_field( 45 | default=ClientType.WIN64, help="Which version of the game client to use" 46 | ) 47 | high_res_enabled: bool = config_field( 48 | default=True, help="If the high resolution game files should be used" 49 | ) 50 | standard_game_launcher_filename: str | None = config_field( 51 | default=None, 52 | help=("Name of the standard game launcher executable. Ex. LotroLauncher.exe"), 53 | ) 54 | patch_client_filename: str = config_field( 55 | default="patchclient.dll", 56 | help="Name of the dll used for game patching. Ex. patchclient.dll", 57 | ) 58 | game_settings_directory: CaseInsensitiveAbsolutePath | None = config_field( 59 | default=None, 60 | help=( 61 | "Custom game settings directory. This is where user " 62 | "preferences, screenshots, and addons are stored." 63 | ), 64 | ) 65 | newsfeed: str | None = config_field( 66 | default=None, help="URL of the feed (RSS, ATOM, ect) to show in the launcher" 67 | ) 68 | environment: dict[str, str] = config_field( 69 | default={}, 70 | help="Environment variables to add to the game's environment", 71 | ) 72 | last_played: datetime | None = None 73 | addons: AddonsConfigSection = config_field( 74 | help="Configuration related to game addons" 75 | ) 76 | wine: WineConfigSection = config_field(help="WINE is not used on Windows") 77 | 78 | @name.default 79 | def _get_name_default(self) -> str: 80 | return generate_game_name(game_config=self) 81 | 82 | @override 83 | @staticmethod 84 | def get_config_version() -> Version: 85 | return Version("2.0") 86 | 87 | @override 88 | @staticmethod 89 | def get_config_file_description() -> str: 90 | return f"A game config file for {__title__}" 91 | 92 | 93 | GameConfigID: TypeAlias = str 94 | 95 | 96 | def generate_game_name( 97 | game_config: GameConfig, existing_game_names: Iterable[str] = () 98 | ) -> str: 99 | """ 100 | Generate default name for game based on its properties. The name can be made 101 | unique if `existing_game_names` are provided. 102 | """ 103 | name = ( 104 | f"{game_config.game_type}" 105 | f"{' - Preview' if game_config.is_preview_client else ''}" 106 | ) 107 | if name not in existing_game_names: 108 | return name 109 | 110 | name_modifier = 2 111 | while f"{name} {name_modifier}" in existing_game_names: 112 | name_modifier += 1 113 | return f"{name} {name_modifier}" 114 | 115 | 116 | def generate_game_config_id(game_config: GameConfig) -> GameConfigID: 117 | return ( 118 | f"{uuid4()}-{game_config.game_type}" 119 | f"{'-Preview' if game_config.is_preview_client else ''}" 120 | ) 121 | -------------------------------------------------------------------------------- /src/onelauncher/game_utilities.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | from .config import platform_dirs 4 | from .game_config import GameConfig, GameType 5 | from .game_launcher_local_config import ( 6 | GameLauncherLocalConfig, 7 | GameLauncherLocalConfigParseError, 8 | get_launcher_config_paths, 9 | ) 10 | from .utilities import CaseInsensitiveAbsolutePath 11 | 12 | 13 | class InvalidGameDirError(ValueError): 14 | """Path is not a valid game directory""" 15 | 16 | 17 | def find_game_dir_game_type(game_dir: CaseInsensitiveAbsolutePath) -> GameType: 18 | """Attempt to find the game type associated with a given folder. 19 | 20 | Raises: 21 | InvalidGameDirError: `game_dir` is not a valid game directory 22 | 23 | Returns: 24 | GameType: Game type of `game_dir` 25 | """ 26 | # Find any launcher config files. One is required for a game folder to be 27 | # valid. 28 | launcher_config_paths = get_launcher_config_paths(game_dir, None) 29 | if not launcher_config_paths: 30 | raise InvalidGameDirError("Game dir has no valid launcher config files") 31 | launcher_config_path = launcher_config_paths[0] 32 | 33 | # Try determining game type from launcher config filename 34 | with contextlib.suppress(ValueError): 35 | return GameType(launcher_config_path.stem.upper()) 36 | 37 | # Try determing game type from datacenter game name 38 | try: 39 | launcher_config = GameLauncherLocalConfig.from_config_xml( 40 | launcher_config_path.read_text(encoding="UTF-8") 41 | ) 42 | return GameType(launcher_config.datacenter_game_name) 43 | except (GameLauncherLocalConfigParseError, ValueError) as e: 44 | raise InvalidGameDirError("Game dir launcher config file wasn't valid") from e 45 | 46 | 47 | def get_default_game_settings_dir( 48 | launcher_local_config: GameLauncherLocalConfig, 49 | ) -> CaseInsensitiveAbsolutePath: 50 | """ 51 | See `get_game_settings_dir`. This is the default for when the user has not customized it. 52 | """ 53 | return CaseInsensitiveAbsolutePath( 54 | platform_dirs.user_documents_path 55 | / launcher_local_config.documents_config_dir_name 56 | ) 57 | 58 | 59 | def get_game_settings_dir( 60 | game_config: GameConfig, launcher_local_config: GameLauncherLocalConfig 61 | ) -> CaseInsensitiveAbsolutePath: 62 | """ 63 | The folder in the user documents dir that the game stores information in. 64 | This includes addons, screenshots, user config files, ect 65 | """ 66 | return game_config.game_settings_directory or get_default_game_settings_dir( 67 | launcher_local_config=launcher_local_config 68 | ) 69 | -------------------------------------------------------------------------------- /src/onelauncher/images/DDOSwitchIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/images/DDOSwitchIcon.png -------------------------------------------------------------------------------- /src/onelauncher/images/DDO_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/images/DDO_banner.png -------------------------------------------------------------------------------- /src/onelauncher/images/LOTROSwitchIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/images/LOTROSwitchIcon.png -------------------------------------------------------------------------------- /src/onelauncher/images/LOTRO_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/images/LOTRO_banner.png -------------------------------------------------------------------------------- /src/onelauncher/images/OneLauncherIcon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/images/OneLauncherIcon.ico -------------------------------------------------------------------------------- /src/onelauncher/images/OneLauncherIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/images/OneLauncherIcon.png -------------------------------------------------------------------------------- /src/onelauncher/images/ddo_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/images/ddo_icon.ico -------------------------------------------------------------------------------- /src/onelauncher/images/lotro_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/images/lotro_icon.ico -------------------------------------------------------------------------------- /src/onelauncher/images/placeholder_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/onelauncher/locale/de/images/LOTRO_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/locale/de/images/LOTRO_banner.png -------------------------------------------------------------------------------- /src/onelauncher/locale/de/images/flag_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/locale/de/images/flag_icon.png -------------------------------------------------------------------------------- /src/onelauncher/locale/de/language_info.toml: -------------------------------------------------------------------------------- 1 | # Text displayed in language switching UI. 2 | # This should be in the target language. 3 | display_name = "Deutsch" 4 | 5 | # Name that the game uses for this language. 6 | # The most obvious example of where this is 7 | # used is in the client_local_{game_language_name}.dat' 8 | # file in the game directory. 9 | game_language_name = "DE" 10 | 11 | -------------------------------------------------------------------------------- /src/onelauncher/locale/en-US/images/DDO_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/locale/en-US/images/DDO_banner.png -------------------------------------------------------------------------------- /src/onelauncher/locale/en-US/images/LOTRO_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/locale/en-US/images/LOTRO_banner.png -------------------------------------------------------------------------------- /src/onelauncher/locale/en-US/images/flag_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/locale/en-US/images/flag_icon.png -------------------------------------------------------------------------------- /src/onelauncher/locale/en-US/language_info.toml: -------------------------------------------------------------------------------- 1 | # Text displayed in language switching UI. 2 | # This should be in the target language. 3 | display_name = "English" 4 | 5 | # Name that the game uses for this language. 6 | # The most obvious example of where this is 7 | # used is in the client_local_{game_language_name}.dat' 8 | # file in the game directory. 9 | game_language_name = "English" 10 | 11 | -------------------------------------------------------------------------------- /src/onelauncher/locale/fr/images/LOTRO_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/locale/fr/images/LOTRO_banner.png -------------------------------------------------------------------------------- /src/onelauncher/locale/fr/images/flag_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/locale/fr/images/flag_icon.png -------------------------------------------------------------------------------- /src/onelauncher/locale/fr/language_info.toml: -------------------------------------------------------------------------------- 1 | # Text displayed in language switching UI. 2 | # This should be in the target language. 3 | display_name = "Français" 4 | 5 | # Name that the game uses for this language. 6 | # The most obvious example of where this is 7 | # used is in the client_local_{game_language_name}.dat' 8 | # file in the game directory. 9 | game_language_name = "FR" 10 | 11 | -------------------------------------------------------------------------------- /src/onelauncher/logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from collections.abc import Callable 4 | from functools import partial 5 | from logging.handlers import RotatingFileHandler 6 | from pathlib import Path 7 | from platform import platform 8 | from types import TracebackType 9 | from typing import Final 10 | 11 | from typing_extensions import override 12 | 13 | from .__about__ import __title__, __version__, version_parsed 14 | from .config import platform_dirs 15 | 16 | LOGS_DIR = platform_dirs.user_log_path 17 | MAIN_LOG_FILE_NAME = "main.log" 18 | 19 | 20 | def log_basic_info(logger: logging.Logger) -> None: 21 | logger.info("Logging started") 22 | logger.info(f"{__title__}: {__version__}") 23 | logger.info(platform()) 24 | 25 | 26 | def handle_uncaught_exceptions( 27 | exc_type: type[BaseException], 28 | exc_value: BaseException, 29 | exc_traceback: TracebackType | None, 30 | logger: logging.Logger, 31 | ) -> None: 32 | """Handler for uncaught exceptions that will write to the logs""" 33 | if issubclass(exc_type, KeyboardInterrupt): 34 | # call the default excepthook saved at __excepthook__ 35 | sys.__excepthook__(exc_type, exc_value, exc_traceback) 36 | return 37 | logger.critical( 38 | "Uncaught exception:", exc_info=(exc_type, exc_value, exc_traceback) 39 | ) 40 | 41 | 42 | class RedactHomeDirFormatter(logging.Formatter): 43 | """ 44 | Redact home directory from log messages. It often contains sensitive 45 | information like the users's name. 46 | """ 47 | 48 | @override 49 | def format(self, record: logging.LogRecord) -> str: 50 | unredacted = super().format(record) 51 | return unredacted.replace(str(Path.home()), "") 52 | 53 | 54 | def setup_application_logging() -> None: 55 | """Create root logger configured for running application""" 56 | if version_parsed.is_devrelease: 57 | file_logging_level = logging.DEBUG 58 | stream_logging_level = logging.DEBUG 59 | elif version_parsed.is_prerelease: 60 | file_logging_level = logging.DEBUG 61 | stream_logging_level = logging.WARNING 62 | else: 63 | file_logging_level = logging.INFO 64 | stream_logging_level = logging.WARNING 65 | 66 | # Make sure logs dir exists 67 | LOGS_DIR.mkdir(exist_ok=True, parents=True) 68 | 69 | # Create or get custom logger 70 | logger = logging.getLogger() 71 | 72 | # This is for the logger globally. Different handlers 73 | # attached to it have their own levels. 74 | logger.setLevel(logging.DEBUG) 75 | 76 | # Create handlers 77 | stream_handler = logging.StreamHandler() 78 | stream_handler.setLevel(stream_logging_level) 79 | 80 | log_file = LOGS_DIR / MAIN_LOG_FILE_NAME 81 | file_handler = RotatingFileHandler( 82 | filename=log_file, 83 | mode="a", 84 | maxBytes=10 * 1024 * 1024, 85 | backupCount=2, 86 | encoding=None, 87 | ) 88 | file_handler.setLevel(file_logging_level) 89 | 90 | # Create formatters and add it to handlers 91 | stream_format = logging.Formatter("%(name)s - %(levelname)s - %(message)s") 92 | stream_handler.setFormatter(stream_format) 93 | file_format = RedactHomeDirFormatter( 94 | "%(asctime)s - %(process)d - %(name)s - %(levelname)s - %(lineno)d - %(message)s" 95 | ) 96 | file_handler.setFormatter(file_format) 97 | 98 | # Add handlers to the logger 99 | logger.addHandler(stream_handler) 100 | logger.addHandler(file_handler) 101 | 102 | # Setup handling of uncaught exceptions 103 | sys.excepthook = partial(handle_uncaught_exceptions, logger=logger) 104 | 105 | log_basic_info(logger=logger) 106 | 107 | 108 | class ForwardLogsHandler(logging.Handler): 109 | """ 110 | Send new log records to `new_log_callback`. Useful for showing log messages in a UI 111 | """ 112 | 113 | def __init__( 114 | self, new_log_callback: Callable[[logging.LogRecord], None], level: int = 0 115 | ) -> None: 116 | super().__init__(level) 117 | self.new_log_callback = new_log_callback 118 | 119 | @override 120 | def emit(self, record: logging.LogRecord) -> None: 121 | self.format(record=record) 122 | self.new_log_callback(record) 123 | 124 | 125 | class ExternalProcessLogsFilter(logging.Filter): 126 | """ 127 | Filter that sets the `LogRecord` process ID to the value for the key 128 | `EXTERNAL_PROCESS_ID_KEY` in the `extra` logging keyward argument. 129 | Used when logging output from external processes. 130 | """ 131 | 132 | EXTERNAL_PROCESS_ID_KEY: Final = "externalProcessID" 133 | 134 | @override 135 | def filter(self, record: logging.LogRecord) -> bool: 136 | if hasattr(record, self.EXTERNAL_PROCESS_ID_KEY): 137 | record.process = getattr(record, self.EXTERNAL_PROCESS_ID_KEY) 138 | return True 139 | -------------------------------------------------------------------------------- /src/onelauncher/mypy_plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mypy plugin that makes mypy recognize the the OneLauncher `config_field` `attrs.field()` wrapper. 3 | """ 4 | 5 | from mypy.options import Options 6 | from mypy.plugin import Plugin 7 | from mypy.plugins.attrs import attr_attrib_makers 8 | 9 | ATTRS_MAKER = "onelauncher.config.config_field" 10 | 11 | 12 | class AttrsFieldWrapperPlugin(Plugin): 13 | def __init__(self, options: Options) -> None: 14 | super().__init__(options) 15 | # These are our `attr.ib` makers. 16 | attr_attrib_makers.add(ATTRS_MAKER) 17 | 18 | 19 | def plugin(version: str) -> type[AttrsFieldWrapperPlugin]: 20 | """ 21 | Return the class for our plugin. 22 | """ 23 | return AttrsFieldWrapperPlugin 24 | -------------------------------------------------------------------------------- /src/onelauncher/network/game_newsfeed.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import html 3 | import logging 4 | from datetime import datetime 5 | from io import StringIO 6 | 7 | import feedparser 8 | from babel import Locale 9 | from babel.dates import format_datetime 10 | from PySide6 import QtCore 11 | 12 | from onelauncher.qtapp import get_qapp 13 | 14 | from .httpx_client import get_httpx_client 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | async def newsfeed_url_to_html(url: str, babel_locale: Locale) -> str: 20 | """ 21 | Raises: 22 | HTTPError: Network error while downloading newsfeed 23 | """ 24 | response = await get_httpx_client(url).get(url) 25 | response.raise_for_status() 26 | 27 | return newsfeed_xml_to_html(response.text, babel_locale, url) 28 | 29 | 30 | def _escape_feed_val(details: feedparser.util.FeedParserDict) -> str: # type: ignore[no-any-unimported] 31 | """Return escaped value if the type is 'text/plain'. Otherwise, return the original value. 32 | See https://github.com/kurtmckee/feedparser/blame/b6917f83354a58348a16cf1106d64ea6622e24df/docs/html-sanitization.rst#L24-L31 33 | Summary is that values marked as 'text/plain' aren't sanitized. 34 | Args: 35 | details (feedparser.util.FeedParserDict): Value details dict. Ex. entry.title_detail 36 | """ 37 | details_val: str = details["value"] 38 | if details["type"] != "text/plain": 39 | return details_val 40 | 41 | return html.escape(details_val) 42 | 43 | 44 | def get_newsfeed_css() -> str: 45 | news_entry_header_color = ( 46 | "#ffd100" 47 | if get_qapp().styleHints().colorScheme() == QtCore.Qt.ColorScheme.Dark 48 | else "#be9b00" 49 | ) 50 | return f""" 51 | .news-entry-header {{ 52 | margin: 0; 53 | margin-bottom: 0.2em; 54 | font-weight: 600; 55 | color: {news_entry_header_color}; 56 | }} 57 | .news-entry-header a {{ 58 | text-decoration: none; 59 | color: {news_entry_header_color}; 60 | }} 61 | .news-entry-content {{ 62 | margin: 0; 63 | margin-top:0.25em; 64 | }} 65 | .news-entries-break {{ 66 | margin: 0; 67 | margin-top: 0.35em; 68 | }} 69 | """ 70 | 71 | 72 | def newsfeed_xml_to_html( 73 | newsfeed_string: str, babel_locale: Locale, original_feed_url: str | None = None 74 | ) -> str: 75 | with StringIO(initial_value=newsfeed_string) as feed_text_stream: 76 | feed_dict = feedparser.parse(feed_text_stream.getvalue()) 77 | 78 | entries_html = "" 79 | for entry in feed_dict.entries: 80 | title = ( 81 | _escape_feed_val(entry["title_detail"]) if "title_detail" in entry else "" 82 | ) 83 | description = ( 84 | _escape_feed_val(entry["description_detail"]) 85 | if "description_detail" in entry 86 | else "" 87 | ) 88 | if "published_parsed" in entry: 89 | timestamp = calendar.timegm(entry["published_parsed"]) 90 | datetime_object = datetime.fromtimestamp(timestamp) 91 | date = format_datetime( 92 | datetime_object, format="medium", locale=babel_locale 93 | ) 94 | else: 95 | date = "" 96 | entry_url = entry.get("link", "") 97 | 98 | # Make sure description doesn't have extra padding 99 | description = description.strip() 100 | description = description.removeprefix("

") 101 | description = description.removesuffix("

") 102 | 103 | entries_html += f""" 104 |
105 |

106 | 107 | {title} 108 | 109 |

110 | 111 | 112 | {date} 113 | 114 | 115 |

{description}

116 |
117 |
118 | """ 119 | 120 | feed_url = feed_dict.feed.get("link") or original_feed_url 121 | return f""" 122 | 123 | 124 |
125 | {entries_html} 126 |
127 | 128 | {"..." if feed_url else ""} 129 | 130 |
131 |
132 | 133 | 134 | """ 135 | -------------------------------------------------------------------------------- /src/onelauncher/network/game_services_info.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Self 3 | 4 | import zeep.exceptions 5 | from asyncache import cached 6 | from cachetools import TTLCache 7 | from httpx import HTTPError 8 | 9 | from ..game_config import GameConfig 10 | from ..game_launcher_local_config import GameLauncherLocalConfig 11 | from .soap import GLSServiceError, get_soap_client 12 | from .world import World 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class GameServicesInfo: 18 | def __init__( 19 | self, 20 | gls_datacenter_service: str, 21 | game_datacenter_name: str, 22 | auth_server: str, 23 | patch_server: str, 24 | launcher_config_url: str, 25 | worlds: set[World], 26 | ) -> None: 27 | self._gls_datacenter_service = gls_datacenter_service 28 | self._game_datacenter_name = game_datacenter_name 29 | self._auth_server = auth_server 30 | self._patch_server = patch_server 31 | self._launcher_config_url = launcher_config_url 32 | self._worlds = worlds 33 | 34 | @classmethod 35 | @cached(cache=TTLCache(maxsize=48, ttl=60 * 2)) 36 | async def from_url( 37 | cls: type[Self], gls_datacenter_service: str, game_datacenter_name: str 38 | ) -> Self: 39 | """ 40 | Raises: 41 | HTTPError: Network error 42 | GLSServiceError: Non-network issue with the GLS service 43 | """ 44 | datacenter_dict = await cls._get_datacenter_dict( 45 | gls_datacenter_service, game_datacenter_name 46 | ) 47 | try: 48 | return cls( 49 | gls_datacenter_service, 50 | game_datacenter_name, 51 | datacenter_dict["AuthServer"], 52 | datacenter_dict["PatchServer"], 53 | datacenter_dict["LauncherConfigurationServer"], 54 | cls._get_worlds(datacenter_dict, gls_datacenter_service), 55 | ) 56 | except KeyError as e: 57 | raise GLSServiceError( 58 | "GetDatacenters response missing required value" 59 | ) from e 60 | 61 | @classmethod 62 | async def from_game_config(cls: type[Self], game_config: GameConfig) -> Self | None: 63 | """Simplified shortcut for getting `GameServicesInfo` object. 64 | Will return `None` if any exceptions are raised.""" 65 | game_launcher_local_config = await GameLauncherLocalConfig.from_game_dir( 66 | game_directory=game_config.game_directory, game_type=game_config.game_type 67 | ) 68 | if game_launcher_local_config is None: 69 | return None 70 | try: 71 | return await cls.from_url( 72 | gls_datacenter_service=game_launcher_local_config.gls_datacenter_service, 73 | game_datacenter_name=game_launcher_local_config.datacenter_game_name, 74 | ) 75 | except (HTTPError, GLSServiceError, AttributeError): 76 | return None 77 | 78 | @property 79 | def gls_datacenter_service(self) -> str: 80 | return self._gls_datacenter_service 81 | 82 | @property 83 | def game_datacenter_name(self) -> str: 84 | return self._game_datacenter_name 85 | 86 | @property 87 | def auth_server(self) -> str: 88 | return self._auth_server 89 | 90 | @property 91 | def patch_server(self) -> str: 92 | return self._patch_server 93 | 94 | @property 95 | def launcher_config_url(self) -> str: 96 | return self._launcher_config_url 97 | 98 | @property 99 | def worlds(self) -> set[World]: 100 | return self._worlds 101 | 102 | @staticmethod 103 | def _get_worlds( 104 | datacenter_dict: dict[str, Any], gls_datacenter_service: str | None 105 | ) -> set[World]: 106 | """Return set of game `World` objects 107 | 108 | Raises: 109 | KeyError: GetDatacenters response missing Worlds details 110 | """ 111 | world_dicts = datacenter_dict["Worlds"]["World"] 112 | return { 113 | World( 114 | world_dict["Name"], 115 | world_dict["ChatServerUrl"], 116 | world_dict["StatusServerUrl"], 117 | gls_datacenter_service, 118 | ) 119 | for world_dict in world_dicts 120 | } 121 | 122 | @staticmethod 123 | async def _get_datacenter_dict( 124 | gls_datacenter_service: str, game_datacenter_name: str 125 | ) -> dict[str, Any]: 126 | """Return dictionary of GetDatacenters SOAP operation response. 127 | 128 | Raises: 129 | HTTPError: Network error 130 | GLSServiceError: Non-network issue with the GLS service 131 | 132 | Returns: 133 | dict: Parsed GetDatacenters response 134 | """ 135 | client = await get_soap_client(gls_datacenter_service) 136 | 137 | try: 138 | return (await client.service.GetDatacenters(game=game_datacenter_name))[0] # type: ignore[no-any-return] 139 | except zeep.exceptions.Error as e: 140 | raise GLSServiceError("Error while parsing GetDatacenters response") from e 141 | except AttributeError as e: 142 | raise GLSServiceError("Service has no GetDatacenters operation") from e 143 | -------------------------------------------------------------------------------- /src/onelauncher/network/httpx_client.py: -------------------------------------------------------------------------------- 1 | from functools import cache 2 | from typing import Final 3 | 4 | import httpx 5 | 6 | from ..official_clients import ( 7 | get_official_servers_httpx_client, 8 | get_official_servers_httpx_client_sync, 9 | is_official_game_server, 10 | ) 11 | 12 | CONNECTION_RETRIES: Final[int] = 3 13 | 14 | 15 | @cache 16 | def _get_default_httpx_client() -> httpx.AsyncClient: 17 | transport = httpx.AsyncHTTPTransport(retries=CONNECTION_RETRIES) 18 | return httpx.AsyncClient(transport=transport) 19 | 20 | 21 | @cache 22 | def _get_default_httpx_client_sync() -> httpx.Client: 23 | transport = httpx.HTTPTransport(retries=CONNECTION_RETRIES) 24 | return httpx.Client(transport=transport) 25 | 26 | 27 | def get_httpx_client(url: str) -> httpx.AsyncClient: 28 | return ( 29 | get_official_servers_httpx_client() 30 | if is_official_game_server(url) 31 | else _get_default_httpx_client() 32 | ) 33 | 34 | 35 | def get_httpx_client_sync(url: str) -> httpx.Client: 36 | return ( 37 | get_official_servers_httpx_client_sync() 38 | if is_official_game_server(url) 39 | else _get_default_httpx_client_sync() 40 | ) 41 | -------------------------------------------------------------------------------- /src/onelauncher/network/login_account.py: -------------------------------------------------------------------------------- 1 | from typing import Any, NamedTuple, Self 2 | 3 | import zeep.exceptions 4 | 5 | from .soap import GLSServiceError, get_soap_client 6 | 7 | 8 | class GameSubscription(NamedTuple): 9 | datacenter_game_name: str 10 | name: str 11 | description: str 12 | product_tokens: list[str] | None 13 | customer_service_tokens: list[str] | None 14 | expiration_date: str | None 15 | status: str | None 16 | next_billing_date: str | None 17 | pending_cancel_date: str | None 18 | auto_renew: str | None 19 | billing_system_time: str | None 20 | additional_info: str | None 21 | 22 | @classmethod 23 | def from_dict(cls: type[Self], subscription_dict: dict[str, Any]) -> Self: 24 | """ 25 | Construct from a `subscription_dict` of the "GameSubscription" list 26 | in the dictionary SOAP response of LoginAccount operation. 27 | See `login_account`. 28 | """ 29 | try: 30 | product_tokens: list[str] = [] 31 | if ( 32 | "ProductTokens" in subscription_dict 33 | and subscription_dict["ProductTokens"] is not None 34 | ): 35 | product_tokens = subscription_dict["ProductTokens"]["string"] 36 | 37 | customer_service_tokens: list[str] = [] 38 | if ( 39 | "CustomerServiceTokens" in subscription_dict 40 | and subscription_dict["CustomerServiceTokens"] is not None 41 | ): 42 | customer_service_tokens = subscription_dict["CustomerServiceTokens"][ 43 | "string" 44 | ] 45 | 46 | return cls( 47 | subscription_dict["Game"], 48 | subscription_dict["Name"], 49 | subscription_dict["Description"], 50 | product_tokens or None, 51 | customer_service_tokens or None, 52 | subscription_dict["ExpirationDate"], 53 | subscription_dict["Status"], 54 | subscription_dict["NextBillingDate"], 55 | subscription_dict["PendingCancelDate"], 56 | subscription_dict["AutoRenew"], 57 | subscription_dict["BillingSystemTime"], 58 | subscription_dict["AdditionalInfo"], 59 | ) 60 | except KeyError as e: 61 | raise GLSServiceError("LoginAccount response missing required value") from e 62 | 63 | 64 | class AccountLoginResponse: 65 | def __init__( 66 | self, subscriptions: list[GameSubscription], session_ticket: str 67 | ) -> None: 68 | self._subscriptions = subscriptions 69 | self._session_ticket = session_ticket 70 | 71 | @property 72 | def subscriptions(self) -> list[GameSubscription]: 73 | """All subscriptions in the account. Not all of these are used 74 | for logging into the game. There can also be subscriptions for 75 | multiple game types on a single account. 76 | 77 | Using `get_game_subscriptions` is recommended for most use cases.""" 78 | return self._subscriptions 79 | 80 | def get_game_subscriptions( 81 | self, datacenter_game_name: str 82 | ) -> list[GameSubscription]: 83 | return [ 84 | subscription 85 | for subscription in self.subscriptions 86 | if subscription.datacenter_game_name == datacenter_game_name 87 | ] 88 | 89 | @property 90 | def session_ticket(self) -> str: 91 | return self._session_ticket 92 | 93 | @classmethod 94 | def from_soap_response_dict( 95 | cls: type[Self], login_response_dict: dict[str, Any] 96 | ) -> Self: 97 | """Construct from dictionary SOAP response 98 | of LoginAccount operation. See `login_account`.""" 99 | 100 | try: 101 | subscriptions = [ 102 | GameSubscription.from_dict(sub_dict) 103 | for sub_dict in login_response_dict["Subscriptions"]["GameSubscription"] 104 | ] 105 | 106 | return cls(subscriptions, login_response_dict["Ticket"]) 107 | except KeyError as e: 108 | raise GLSServiceError("LoginAccount response missing required value") from e 109 | 110 | 111 | class WrongUsernameOrPasswordError(Exception): 112 | """Either the username does not exist, or the password was incorrect.""" 113 | 114 | 115 | async def login_account( 116 | auth_server: str, username: str, password: str 117 | ) -> AccountLoginResponse: 118 | """Login to game account using SOAP API 119 | 120 | Args: 121 | auth_server (str): Authentication server. Normally found in 122 | `GameServicesInfo`. 123 | username (str): Account username 124 | password (str): Account password 125 | 126 | Raises: 127 | WrongUsernameOrPasswordError: Username doesn't exist or password is 128 | wrong. 129 | HTTPError: Network error 130 | GLSServiceError: Non-network issue with the GLS service 131 | 132 | Returns: 133 | AccountLoginResponse 134 | """ 135 | client = await get_soap_client(auth_server) 136 | 137 | try: 138 | return AccountLoginResponse.from_soap_response_dict( 139 | await client.service.LoginAccount(username, password, "") 140 | ) 141 | except zeep.exceptions.Fault as e: 142 | if e.message == "No Subscriber Formal Entity was found.": 143 | raise WrongUsernameOrPasswordError("") from e 144 | else: 145 | raise GLSServiceError("") from e 146 | except zeep.exceptions.Error as e: 147 | raise GLSServiceError("Error while parsing LoginAccount response") from e 148 | except AttributeError as e: 149 | raise GLSServiceError("Service has no LoginAccount operation") from e 150 | -------------------------------------------------------------------------------- /src/onelauncher/network/schemas/world_queue_result.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/onelauncher/network/schemas/world_status.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/onelauncher/network/soap.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from urllib.parse import urlparse, urlunparse 3 | 4 | import httpx 5 | import trio 6 | import zeep.exceptions 7 | from typing_extensions import override 8 | from zeep import AsyncClient, Settings 9 | from zeep.cache import Base, InMemoryCache 10 | from zeep.loader import load_external_async 11 | from zeep.transports import AsyncTransport 12 | from zeep.wsdl.wsdl import Definition, Document 13 | 14 | from .httpx_client import get_httpx_client 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | # `zeep.transports` includes full web requests in debug logs. That means sensitive 19 | # information like user passwords can end up in logs. 20 | logging.getLogger("zeep.transports").setLevel(logging.INFO) 21 | 22 | 23 | class GLSServiceError(Exception): 24 | """Non-network error with the GLS service""" 25 | 26 | 27 | class FullyAsyncTransport(AsyncTransport): 28 | """Async transport that loads remote data like wsdl async.""" 29 | 30 | def __init__( 31 | self, 32 | client: httpx.AsyncClient, 33 | cache: Base | None = None, 34 | timeout: int = 300, 35 | operation_timeout: int | None = None, 36 | verify_ssl: bool = True, 37 | proxy: httpx.Proxy | None = None, 38 | ): 39 | super().__init__( # type: ignore[no-untyped-call] 40 | client=client, 41 | wsdl_client=client, 42 | cache=cache, 43 | timeout=timeout, 44 | operation_timeout=operation_timeout, 45 | verify_ssl=verify_ssl, 46 | proxy=proxy, 47 | ) 48 | 49 | @override 50 | async def load(self, url: str) -> bytes: 51 | if not url: 52 | raise ValueError("No url given to load") 53 | 54 | scheme = urlparse(url).scheme 55 | if scheme in ("http", "https", "file"): 56 | if self.cache: 57 | response = self.cache.get(url) 58 | if response: 59 | return bytes(response) 60 | 61 | content = await self._async_load_remote_data(url) 62 | 63 | if self.cache: 64 | self.cache.add(url, content) 65 | 66 | return content 67 | else: 68 | path = await trio.Path(url).expanduser() 69 | return await path.read_bytes() 70 | 71 | async def _async_load_remote_data(self, url: str) -> bytes: 72 | response = await self.client.get(url) 73 | result = response.read() 74 | 75 | try: 76 | response.raise_for_status() 77 | except httpx.HTTPStatusError as exc: 78 | raise zeep.exceptions.TransportError( # type: ignore[no-untyped-call] 79 | status_code=response.status_code 80 | ) from exc 81 | return result 82 | 83 | 84 | class AsyncDocument(Document): 85 | def __init__( 86 | self, 87 | location: str, 88 | transport: AsyncTransport, 89 | base: str | None = None, 90 | settings: Settings | None = None, 91 | ): 92 | super().__init__( 93 | location=location, 94 | transport=transport, # type: ignore[arg-type] 95 | base=base, 96 | settings=settings, 97 | ) 98 | 99 | @override 100 | def load(self, location: str) -> None: 101 | return 102 | 103 | async def load_async(self, location: str) -> None: 104 | document = await load_external_async( 105 | url=location, # type: ignore[arg-type] 106 | transport=self.transport, 107 | base_url=self.location, 108 | settings=self.settings, 109 | ) 110 | 111 | root_definitions = Definition(self, document, self.location) # type: ignore[no-untyped-call] 112 | root_definitions.resolve_imports() 113 | 114 | # Make the wsdl definitions public 115 | self.messages = root_definitions.messages 116 | self.port_types = root_definitions.port_types 117 | self.bindings = root_definitions.bindings 118 | self.services = root_definitions.services 119 | 120 | 121 | async def get_soap_client(gls_service: str) -> AsyncClient: 122 | """Return configured SOAP client from GLS service URL 123 | 124 | Args: 125 | gls_service (str): GLS service URL 126 | 127 | Raises: 128 | HTTPError: Network error while downloading the service description 129 | GLSServiceError: Error while parsing the service description 130 | 131 | Returns: 132 | Client: Zeep SOAP client 133 | """ 134 | parsed_url = urlparse(gls_service) 135 | # Transform base service link into link to the service description 136 | wsdl_url = urlunparse(parsed_url._replace(query="WSDL")) 137 | 138 | cache = InMemoryCache(timeout=5 * 60) # type: ignore[no-untyped-call] 139 | # Make transport to use caching and the SSL configs in `session` 140 | transport = FullyAsyncTransport( 141 | client=get_httpx_client(wsdl_url), 142 | cache=cache, 143 | ) 144 | settings = Settings() 145 | try: 146 | document = AsyncDocument( 147 | location=wsdl_url, transport=transport, settings=settings 148 | ) 149 | await document.load_async(wsdl_url) 150 | return AsyncClient(wsdl=document, transport=transport, settings=settings) # type: ignore[no-untyped-call] 151 | except zeep.exceptions.Error as e: 152 | raise GLSServiceError("Error while parsing the service description") from e 153 | -------------------------------------------------------------------------------- /src/onelauncher/network/world.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Final 3 | from urllib.parse import urlparse, urlunparse 4 | 5 | import httpx 6 | import xmlschema 7 | from asyncache import cached 8 | from cachetools import TTLCache 9 | from typing_extensions import override 10 | 11 | from ..resources import data_dir 12 | from .httpx_client import get_httpx_client 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class WorldUnavailableError(Exception): 18 | """World is unavailable.""" 19 | 20 | 21 | class WorldStatus: 22 | def __init__(self, queue_url: str, login_server: str) -> None: 23 | self._queue_url = queue_url 24 | self._login_server = login_server 25 | 26 | @property 27 | def queue_url(self) -> str: 28 | """URL used to queue for world login. 29 | Will be an empty string, if no queueing is needed.""" 30 | return self._queue_url 31 | 32 | @property 33 | def login_server(self) -> str: 34 | return self._login_server 35 | 36 | 37 | class World: 38 | _WORLD_STATUS_SCHEMA: Final = xmlschema.XMLSchema( 39 | data_dir / "network" / "schemas" / "world_status.xsd" 40 | ) 41 | 42 | def __init__( 43 | self, 44 | name: str, 45 | chat_server_url: str, 46 | status_server_url: str, 47 | gls_datacenter_service: str | None = None, 48 | ): 49 | self._name = name 50 | self._chat_server_url = chat_server_url 51 | self._status_server_url = status_server_url 52 | self._gls_datacenter_service = gls_datacenter_service 53 | 54 | @property 55 | def name(self) -> str: 56 | return self._name 57 | 58 | @property 59 | def chat_server_url(self) -> str: 60 | return self._chat_server_url 61 | 62 | @property 63 | def status_server_url(self) -> str: 64 | return self._status_server_url 65 | 66 | @cached(cache=TTLCache(maxsize=1, ttl=60)) 67 | async def get_status(self) -> WorldStatus: 68 | """Return current world status info 69 | 70 | Raises: 71 | HTTPError: Network error while downloading the status XML 72 | WorldUnavailableError: World is unavailable 73 | XMLSchemaValidationError: Status XML doesn't match schema 74 | """ 75 | status_dict = await self._get_status_dict(self.status_server_url) 76 | queue_urls: tuple[str, ...] = tuple( 77 | url for url in status_dict["queueurls"].split(";") if url 78 | ) 79 | login_servers: tuple[str, ...] = tuple( 80 | server for server in status_dict["loginservers"].split(";") if server 81 | ) 82 | return WorldStatus(queue_urls[0], login_servers[0]) 83 | 84 | async def _get_status_dict(self, status_server_url: str) -> dict[str, Any]: 85 | """Return world status dictionary 86 | 87 | Raises: 88 | HTTPError: Network error while downloading the status XML 89 | WorldUnavailableError: World is unavailable 90 | XMLSchemaValidationError: Status XML doesn't match schema 91 | 92 | Returns: 93 | dict: Dictionary representation of world status. 94 | See `self._WORLD_STATUS_SCHEMA` schema file for what to expect. 95 | """ 96 | response = await get_httpx_client(status_server_url).get(status_server_url) 97 | 98 | if response.status_code == httpx.codes.NOT_FOUND or not response.text: 99 | # Fix broken status URLs for some LOTRO legendary servers 100 | if self._gls_datacenter_service: 101 | parsed_status_url = urlparse(status_server_url) 102 | parsed_gls_service = urlparse(self._gls_datacenter_service) 103 | if ( 104 | parsed_status_url.path.lower().endswith("/statusserver.aspx") 105 | and parsed_status_url.netloc.lower() 106 | != parsed_gls_service.netloc.lower() 107 | ): 108 | # Some legendary servers have an IP that doesn't work instead of 109 | # a domain for the netloc. Having the domain also helps OneLauncher 110 | # enforce HTTPS correctly. 111 | url_fixed_netloc = parsed_status_url._replace( 112 | netloc=parsed_gls_service.netloc 113 | ) 114 | # The "Mordor" legendary server path starts with "GLS.STG.DataCenterServer" 115 | # instead of "GLS.DataCenterServer". 116 | gls_path_prefix = parsed_gls_service.path.lower().split( 117 | "/service.asmx", maxsplit=1 118 | )[0] 119 | url_fixed_path = url_fixed_netloc._replace( 120 | path=f"{gls_path_prefix}/StatusServer.aspx" 121 | ) 122 | return await self._get_status_dict(urlunparse(url_fixed_path)) 123 | 124 | # 404 response generally means world is unavailable. 125 | # Empty `response.text` also means the world is unavailable. Got an empty but 126 | # successful response during an unexpected worlds downtime on 2024/30/31. 127 | raise WorldUnavailableError(f"{self} world unavailable") 128 | 129 | response.raise_for_status() 130 | 131 | return self._WORLD_STATUS_SCHEMA.to_dict(response.text) # type: ignore[return-value] 132 | 133 | @override 134 | def __str__(self) -> str: 135 | return self.name 136 | -------------------------------------------------------------------------------- /src/onelauncher/network/world_login_queue.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Final, NamedTuple 2 | 3 | import xmlschema 4 | 5 | from ..resources import data_dir 6 | from .httpx_client import get_httpx_client 7 | 8 | 9 | class JoinWorldQueueResult(NamedTuple): 10 | queue_number: int 11 | now_serving_number: int 12 | 13 | 14 | class WorldQueueResultXMLParseError(Exception): 15 | """Error with content/formatting of world queue respone XML""" 16 | 17 | 18 | class JoinWorldQueueFailedError(Exception): 19 | """Failed to join world login queue""" 20 | 21 | 22 | class WorldLoginQueue: 23 | _WORLD_QUEUE_RESULT_SCHEMA: Final = xmlschema.XMLSchema( 24 | data_dir / "network" / "schemas" / "world_queue_result.xsd" 25 | ) 26 | 27 | def __init__( 28 | self, 29 | login_queue_url: str, 30 | login_queue_params_template: str, 31 | subscription_name: str, 32 | session_ticket: str, 33 | world_queue_url: str, 34 | ) -> None: 35 | self._login_queue_url = login_queue_url 36 | self._login_queue_arguments_dict = self.get_login_queue_arguments_dict( 37 | login_queue_params_template, 38 | subscription_name, 39 | session_ticket, 40 | world_queue_url, 41 | ) 42 | 43 | def get_login_queue_arguments_dict( 44 | self, 45 | login_queue_params_template: str, 46 | subscription_name: str, 47 | session_ticket: str, 48 | world_queue_url: str, 49 | ) -> dict[str, str]: 50 | arguments_dict: dict[str, str] = {} 51 | for param_template in login_queue_params_template.split("&"): 52 | param_name, param_value = param_template.split("=") 53 | # Replace known template values 54 | param_value = ( 55 | param_value.replace("{0}", subscription_name) 56 | .replace("{1}", session_ticket) 57 | .replace("{2}", world_queue_url) 58 | ) 59 | arguments_dict[param_name] = param_value 60 | return arguments_dict 61 | 62 | async def join_queue(self) -> JoinWorldQueueResult: 63 | """ 64 | Raises: 65 | HTTPError: Network error 66 | WorldQueueResultXMLParseError: Error with content/formatting of 67 | world queue respone XML 68 | JoinWorldQueueFailedError: Failed to join world login queue 69 | """ 70 | response = await get_httpx_client(self._login_queue_url).post( 71 | self._login_queue_url, data=self._login_queue_arguments_dict 72 | ) 73 | 74 | try: 75 | queue_result_dict: dict[str, Any] = self._WORLD_QUEUE_RESULT_SCHEMA.to_dict( 76 | response.text 77 | ) # type: ignore[assignment] 78 | except xmlschema.XMLSchemaValidationError as e: 79 | raise WorldQueueResultXMLParseError( 80 | "Queue XML result doesn't match schema" 81 | ) from e 82 | 83 | hresult = int(queue_result_dict["HResult"], base=16) 84 | # Check if joining queue failed. See 85 | # https://en.wikipedia.org/wiki/HRESULT 86 | if hresult >> 31 & 1: 87 | raise JoinWorldQueueFailedError( 88 | f"Joining world login queue failed with HRESULT: {hex(hresult)}" 89 | ) 90 | 91 | try: 92 | return JoinWorldQueueResult( 93 | int(queue_result_dict["QueueNumber"], base=16), 94 | int(queue_result_dict["NowServingNumber"], base=16), 95 | ) 96 | except KeyError as e: 97 | raise WorldQueueResultXMLParseError( 98 | "World queue result missing required value" 99 | ) from e 100 | -------------------------------------------------------------------------------- /src/onelauncher/official_clients.py: -------------------------------------------------------------------------------- 1 | ########################################################################### 2 | # Information and configuration specific to official game clients. 3 | # 4 | # Based on PyLotRO 5 | # (C) 2009 AJackson 6 | # 7 | # Based on LotROLinux 8 | # (C) 2007-2008 AJackson 9 | # 10 | # 11 | # (C) 2019-2025 June Stepp 12 | # 13 | # This file is part of OneLauncher 14 | # 15 | # OneLauncher is free software; you can redistribute it and/or modify 16 | # it under the terms of the GNU General Public License as published by 17 | # the Free Software Foundation; either version 3 of the License, or 18 | # (at your option) any later version. 19 | # 20 | # OneLauncher is distributed in the hope that it will be useful, 21 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 22 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 23 | # GNU General Public License for more details. 24 | # 25 | # You should have received a copy of the GNU General Public License 26 | # along with OneLauncher. If not, see . 27 | ########################################################################### 28 | import logging 29 | import socket 30 | import ssl 31 | from functools import cache 32 | from pathlib import Path 33 | from typing import Final, assert_never 34 | from urllib.parse import urlparse 35 | 36 | import httpx 37 | 38 | from onelauncher import resources 39 | from onelauncher.game_config import GameType 40 | 41 | logger = logging.getLogger(__name__) 42 | 43 | LOTRO_GLS_PREVIEW_DOMAIN = "gls-bullroarer.lotro.com" 44 | LOTRO_GLS_DOMAINS: Final = [ 45 | "gls.lotro.com", 46 | "gls-auth.lotro.com", # Same as gls.lotro.com 47 | LOTRO_GLS_PREVIEW_DOMAIN, 48 | ] 49 | # Same as main gls domain, but ssl certificate isn't valid for this domain. 50 | LOTRO_GLS_INVALID_SSL_DOMAIN: Final = "moria.gls.lotro.com" 51 | 52 | DDO_GLS_PREVIEW_DOMAIN: Final = "gls-lm.ddo.com" 53 | DDO_GLS_PREVIEW_IP: Final = "198.252.160.33" 54 | DDO_GLS_DOMAINS: Final = [ 55 | "gls.ddo.com", 56 | "gls-auth.ddo.com", # Same as gls.ddo.com 57 | DDO_GLS_PREVIEW_DOMAIN, 58 | ] 59 | 60 | # Forums where RSS feeds used as newsfeeds are 61 | LOTRO_FORMS_DOMAINS: Final = [ 62 | "forums.lotro.com", 63 | "forums-old.lotro.com", 64 | ] 65 | DDO_FORMS_DOMAINS: Final = [ 66 | "forums.ddo.com", 67 | "forums-old.ddo.com", 68 | ] 69 | # DDO preview client provides broken news URL template. This info is used 70 | # to fix it. 71 | DDO_PREVIEW_BROKEN_NEWS_URL_TEMPLATE: Final = ( 72 | "http://www.ddo.com/index.php?option=com_bca-rss-syndicator&feed_id=3" 73 | ) 74 | DDO_PREVIEW_NEWS_URL_TEMPLATE: Final = "https://forums.ddo.com/index.php?forums/lamannia-news-and-official-discussions.20/index.rss" 75 | 76 | 77 | # There may be specific better ciphers that can be used instead of just 78 | # lowering the security level. I'm not knowledgable on this topic though. 79 | OFFICIAL_CLIENT_CIPHERS: Final = "DEFAULT@SECLEVEL=1" 80 | 81 | CONNECTION_RETRIES: Final[int] = 3 82 | TIMEOUT: Final = httpx.Timeout(timeout=6.0, read=10.0) 83 | 84 | 85 | def is_official_game_server(url: str) -> bool: 86 | netloc = urlparse(url).netloc.lower() 87 | return ( 88 | netloc 89 | in LOTRO_GLS_DOMAINS 90 | + LOTRO_FORMS_DOMAINS 91 | + DDO_GLS_DOMAINS 92 | + DDO_FORMS_DOMAINS 93 | + [LOTRO_GLS_INVALID_SSL_DOMAIN, DDO_GLS_PREVIEW_IP] 94 | ) 95 | 96 | 97 | def is_gls_url_for_preview_client(url: str) -> bool: 98 | netloc = urlparse(url).netloc.lower() 99 | return netloc in [ 100 | LOTRO_GLS_PREVIEW_DOMAIN, 101 | DDO_GLS_PREVIEW_DOMAIN, 102 | DDO_GLS_PREVIEW_IP, 103 | ] 104 | 105 | 106 | class DDOPreviewIPDoesNotMatchDomainError(httpx.RequestError): 107 | """Expected IP to match the DDO preview GLS server domain IP""" 108 | 109 | 110 | def _httpx_request_hook_sync(request: httpx.Request) -> None: 111 | # Force HTTPS. It's supported by all the official servers, but most URLs 112 | # default to HTTP. 113 | request.url = request.url.copy_with(scheme="https") 114 | 115 | # Change "moria.gls.lotro.com" domains to "gls.lotro.com". 116 | # This is necessary, because the SSL certificate used by 117 | # "moria.gls.lotro.com" is only valid for "*.lotro.com" and "lotro.com". 118 | if request.url.host.lower().startswith(LOTRO_GLS_INVALID_SSL_DOMAIN): 119 | request.url = request.url.copy_with( 120 | host=request.url.host.lower().replace( 121 | LOTRO_GLS_INVALID_SSL_DOMAIN, LOTRO_GLS_DOMAINS[0], 1 122 | ) 123 | ) 124 | 125 | # Change DDO preview server IP to domain name. 126 | # This is to make HTTPS work properly. 127 | if request.url.host.lower().startswith(DDO_GLS_PREVIEW_IP): 128 | try: 129 | # Verify that DDO preview server still matches the expected IP 130 | if socket.gethostbyname(DDO_GLS_PREVIEW_DOMAIN) != DDO_GLS_PREVIEW_IP: 131 | raise DDOPreviewIPDoesNotMatchDomainError( 132 | "IP doesn't match the DDO preview GLS server domain IP" 133 | ) 134 | except OSError as e: 135 | raise httpx.RequestError( 136 | "Connection error while verifying DDO preview GLS server IP" 137 | ) from e 138 | 139 | request.url = request.url.copy_with(host=DDO_GLS_PREVIEW_DOMAIN) 140 | 141 | 142 | async def _httpx_request_hook(request: httpx.Request) -> None: 143 | _httpx_request_hook_sync(request) 144 | 145 | 146 | def get_official_servers_ssl_context() -> ssl.SSLContext: 147 | """ 148 | Return SSLContext configured for the lower security of the official servers 149 | """ 150 | ssl_context = httpx.create_ssl_context() 151 | ssl_context.verify_mode = ssl.CERT_REQUIRED 152 | ssl_context.set_ciphers(OFFICIAL_CLIENT_CIPHERS) 153 | return ssl_context 154 | 155 | 156 | @cache 157 | def get_official_servers_httpx_client() -> httpx.AsyncClient: 158 | """Return httpx client configured to work with official game servers""" 159 | transport = httpx.AsyncHTTPTransport( 160 | verify=get_official_servers_ssl_context(), retries=CONNECTION_RETRIES 161 | ) 162 | return httpx.AsyncClient( 163 | timeout=TIMEOUT, 164 | verify=get_official_servers_ssl_context(), 165 | event_hooks={"request": [_httpx_request_hook]}, 166 | transport=transport, 167 | ) 168 | 169 | 170 | @cache 171 | def get_official_servers_httpx_client_sync() -> httpx.Client: 172 | """Return httpx client configured to work with official game servers""" 173 | transport = httpx.HTTPTransport( 174 | verify=get_official_servers_ssl_context(), retries=CONNECTION_RETRIES 175 | ) 176 | return httpx.Client( 177 | timeout=TIMEOUT, 178 | verify=get_official_servers_ssl_context(), 179 | event_hooks={"request": [_httpx_request_hook_sync]}, 180 | transport=transport, 181 | ) 182 | 183 | 184 | def get_game_icon(game_type: GameType) -> Path: 185 | match game_type: 186 | case GameType.LOTRO: 187 | return resources.data_dir / "images/lotro_icon.ico" 188 | case GameType.DDO: 189 | return resources.data_dir / "images/ddo_icon.ico" 190 | case _: 191 | assert_never(game_type) 192 | -------------------------------------------------------------------------------- /src/onelauncher/patching_progress_monitor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ########################################################################### 3 | # Patching progress analyzer for OneLauncher. 4 | # 5 | # Based on PyLotRO 6 | # (C) 2009 AJackson 7 | # 8 | # Based on LotROLinux 9 | # (C) 2007-2008 AJackson 10 | # 11 | # 12 | # (C) 2019-2025 June Stepp 13 | # 14 | # This file is part of OneLauncher 15 | # 16 | # OneLauncher is free software; you can redistribute it and/or modify 17 | # it under the terms of the GNU General Public License as published by 18 | # the Free Software Foundation; either version 3 of the License, or 19 | # (at your option) any later version. 20 | # 21 | # OneLauncher is distributed in the hope that it will be useful, 22 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 23 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 24 | # GNU General Public License for more details. 25 | # 26 | # You should have received a copy of the GNU General Public License 27 | # along with OneLauncher. If not, see . 28 | ########################################################################### 29 | 30 | from typing import Literal 31 | 32 | import attrs 33 | 34 | 35 | @attrs.frozen 36 | class PatchingProgress: 37 | total_iterations: int 38 | current_iterations: int 39 | 40 | 41 | class PatchingProgressMonitor: 42 | def __init__(self) -> None: 43 | self.reset() 44 | 45 | def reset(self) -> None: 46 | self.patching_type = None 47 | 48 | @property 49 | def patching_type(self) -> Literal["file", "data"] | None: 50 | return self._patching_type 51 | 52 | @patching_type.setter 53 | def patching_type(self, patching_type: Literal["file", "data"] | None) -> None: 54 | self._patching_type = patching_type 55 | self.total_iterations: int = 0 56 | self.current_iterations: int = 0 57 | self.applying_forward_iterations: bool = False 58 | 59 | def get_patching_progress(self) -> PatchingProgress: 60 | return PatchingProgress( 61 | total_iterations=self.total_iterations, 62 | current_iterations=self.current_iterations, 63 | ) 64 | 65 | def feed_line(self, line: str) -> PatchingProgress: 66 | cleaned_line = line.strip().lower() 67 | 68 | # Beginning of a patching type 69 | if cleaned_line.startswith("checking files"): 70 | self.patching_type = "file" 71 | return self.get_patching_progress() 72 | elif cleaned_line.startswith("checking data"): 73 | self.patching_type = "data" 74 | return self.get_patching_progress() 75 | # Right after a patching type begins. Find out how many iterations there will be. 76 | if cleaned_line.startswith("files to patch:"): 77 | self.total_iterations = int( 78 | cleaned_line.split("files to patch:")[1].strip().split()[0] 79 | ) 80 | elif cleaned_line.startswith("data patches:"): 81 | self.total_iterations = int( 82 | cleaned_line.split("data patches:")[1].strip().split()[0] 83 | ) 84 | # Data patching has two parts. 85 | # "Applying x forward iterations....(continues for x dots)" and the actual file 86 | # downloading which is the originally set `self.total_iterations` 87 | elif ( 88 | self.patching_type == "data" 89 | and cleaned_line.startswith("applying") 90 | and "forward iterations" in cleaned_line 91 | ): 92 | self.applying_forward_iterations = True 93 | self.total_iterations += int( 94 | cleaned_line.split("applying")[1].strip().split("forward iterations")[0] 95 | ) 96 | 97 | if cleaned_line.startswith("downloading"): 98 | self.applying_forward_iterations = False 99 | self.current_iterations += 1 100 | # During forward iterations, each "." represents one iteration 101 | elif self.applying_forward_iterations and "." in cleaned_line: 102 | self.current_iterations += len(cleaned_line.split(".")) 103 | 104 | return self.get_patching_progress() 105 | -------------------------------------------------------------------------------- /src/onelauncher/program_config.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import attrs 4 | from packaging.version import Version 5 | from typing_extensions import override 6 | 7 | from .__about__ import __title__ 8 | from .config import Config, config_field 9 | from .resources import ( 10 | OneLauncherLocale, 11 | get_default_locale, 12 | ) 13 | 14 | 15 | class GamesSortingMode(Enum): 16 | """ 17 | - priority: The manual order the user set in the setup wizard. 18 | - alphabetical: Alphabetical order. 19 | - last_played: Order of the most recently played games. 20 | """ 21 | 22 | PRIORITY = "priority" 23 | LAST_PLAYED = "last_played" 24 | ALPHABETICAL = "alphabetical" 25 | 26 | 27 | @attrs.frozen 28 | class ProgramConfig(Config): 29 | default_locale: OneLauncherLocale = config_field( 30 | default=get_default_locale(), 31 | help="The default language for games and UI.", 32 | ) 33 | always_use_default_locale_for_ui: bool = config_field( 34 | default=False, help="Use default language for UI regardless of game language" 35 | ) 36 | games_sorting_mode: GamesSortingMode = config_field( 37 | default=GamesSortingMode.PRIORITY, help="Order to show games in UI" 38 | ) 39 | 40 | @override 41 | @staticmethod 42 | def get_config_version() -> Version: 43 | return Version("2.0") 44 | 45 | @override 46 | @staticmethod 47 | def get_config_file_description() -> str: 48 | return ( 49 | f"The primary config file for {__title__}. " 50 | f"Game specific configs are in separate files." 51 | ) 52 | -------------------------------------------------------------------------------- /src/onelauncher/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/py.typed -------------------------------------------------------------------------------- /src/onelauncher/qtapp.py: -------------------------------------------------------------------------------- 1 | ########################################################################### 2 | # Runner for OneLauncher. 3 | # 4 | # Based on PyLotRO 5 | # (C) 2009 AJackson 6 | # 7 | # Based on LotROLinux 8 | # (C) 2007-2008 AJackson 9 | # 10 | # 11 | # (C) 2019-2025 June Stepp 12 | # 13 | # This file is part of OneLauncher 14 | # 15 | # OneLauncher is free software; you can redistribute it and/or modify 16 | # it under the terms of the GNU General Public License as published by 17 | # the Free Software Foundation; either version 3 of the License, or 18 | # (at your option) any later version. 19 | # 20 | # OneLauncher is distributed in the hope that it will be useful, 21 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 22 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 23 | # GNU General Public License for more details. 24 | # 25 | # You should have received a copy of the GNU General Public License 26 | # along with OneLauncher. If not, see . 27 | ########################################################################### 28 | import os 29 | import sys 30 | from functools import cache 31 | from pathlib import Path 32 | 33 | import qtawesome 34 | from PySide6 import QtCore, QtGui, QtWidgets 35 | 36 | from onelauncher.ui.style import ApplicationStyle 37 | 38 | from .__about__ import __title__, __version__ 39 | from .resources import data_dir 40 | 41 | 42 | @cache 43 | def _setup_qapplication() -> QtWidgets.QApplication: 44 | application = QtWidgets.QApplication(sys.argv) 45 | # See https://github.com/zhiyiYo/PyQt-Frameless-Window/issues/50 46 | application.setAttribute( 47 | QtCore.Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings 48 | ) 49 | # Will be quit after Trio event loop finishes 50 | application.setQuitOnLastWindowClosed(False) 51 | application.setApplicationName(__title__) 52 | application.setApplicationDisplayName(__title__) 53 | application.setApplicationVersion(__version__) 54 | application.setWindowIcon( 55 | QtGui.QIcon( 56 | str( 57 | data_dir / Path("images/OneLauncherIcon.png"), 58 | ) 59 | ) 60 | ) 61 | 62 | # The Qt "Windows" style doesn't work with dark mode 63 | if os.name == "nt": 64 | application.setStyle("Fusion") 65 | 66 | def set_qtawesome_defaults() -> None: 67 | qtawesome.reset_cache() 68 | qtawesome.set_defaults(color=application.palette().windowText().color()) 69 | 70 | set_qtawesome_defaults() 71 | application.styleHints().colorSchemeChanged.connect(set_qtawesome_defaults) 72 | 73 | return application 74 | 75 | 76 | @cache 77 | def get_qapp() -> QtWidgets.QApplication: 78 | application = _setup_qapplication() 79 | # Setup ApplicationStyle 80 | _ = get_app_style() 81 | return application 82 | 83 | 84 | @cache 85 | def get_app_style() -> ApplicationStyle: 86 | return ApplicationStyle(_setup_qapplication()) 87 | -------------------------------------------------------------------------------- /src/onelauncher/resources.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import tomllib 4 | from functools import cache, cached_property 5 | from pathlib import Path 6 | from typing import Self 7 | 8 | import attrs 9 | import babel 10 | from PySide6.QtCore import QLocale 11 | from typing_extensions import override 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @attrs.frozen 17 | class OneLauncherLocale: 18 | """ 19 | Args: 20 | lang_tag (str): An IETF BCP 47 language tag for the locale. 21 | data_dir: 22 | display_name (str): Text displayed in language switching UI. This should be in the 23 | target language. 24 | game_language_name (str): Name that the game uses for this language. 25 | The most obvious example of where this is used is in the 26 | client_local_{game_language_name}.dat file in the game directory. 27 | """ 28 | 29 | lang_tag: str 30 | data_dir: Path 31 | display_name: str 32 | game_language_name: str 33 | 34 | @override 35 | def __str__(self) -> str: 36 | return self.lang_tag 37 | 38 | @classmethod 39 | def from_data_dir(cls: type[Self], data_dir: Path) -> Self: 40 | file = data_dir / "language_info.toml" 41 | if not file.exists(): 42 | raise FileNotFoundError( 43 | f"The language_info.toml file is missing for {data_dir.name}" 44 | ) 45 | 46 | settings_dict = tomllib.loads(file.read_text(encoding="UTF-8")) 47 | 48 | display_name = settings_dict["display_name"] 49 | game_language_name = settings_dict["game_language_name"] 50 | return cls(data_dir.name, data_dir, display_name, game_language_name) 51 | 52 | def get_resource(self, relative_path: Path) -> Path: 53 | """Returns the localized resource for path 54 | 55 | Args: 56 | relative_path (Path): Relative path from data_dir to resource. 57 | Example: "images/LOTRO_banner.png" 58 | 59 | Returns: 60 | Path: Full path to resource, localized if a generic version isn't 61 | available. 62 | """ 63 | return get_resource(relative_path, self) 64 | 65 | @cached_property 66 | def flag_icon(self) -> Path: 67 | return self.get_resource(Path("images/flag_icon.png")) 68 | 69 | @cached_property 70 | def babel_locale(self) -> babel.Locale: 71 | return babel.Locale.parse(self.lang_tag, sep="-") 72 | 73 | 74 | def get_data_dir() -> Path: 75 | """Return directory equivalent to `src/onelauncher` in the source code.""" 76 | if getattr(sys, "frozen", False): 77 | # Data location for frozen programs 78 | return Path(sys.executable).parent 79 | else: 80 | # This file is located in the data dir 81 | return Path(__file__).parent 82 | 83 | 84 | def get_resource(relative_path: Path, locale: OneLauncherLocale) -> Path: 85 | """Returns the localized resource for path 86 | 87 | Args: 88 | relative_path (Path): Relative path from data_dir to resource. 89 | Example: "images/LOTRO_banner.png" 90 | locale (OneLauncherLocale): the OneLauncherLocale to get the resource 91 | from if there is no standard version. 92 | 93 | Returns: 94 | Path: Full path to resource, localized if a generic version isn't 95 | available. 96 | """ 97 | generic_path = data_dir / relative_path 98 | 99 | localized_path = locale.data_dir / relative_path 100 | if localized_path.exists(): 101 | return localized_path 102 | elif generic_path.exists(): 103 | return generic_path 104 | else: 105 | raise FileNotFoundError( 106 | f"There is no generic or localized version of {relative_path} " 107 | f"for the language {locale}" 108 | ) 109 | 110 | 111 | @cache 112 | def get_available_locales() -> dict[str, OneLauncherLocale]: 113 | data_dir = get_data_dir() 114 | locales: dict[str, OneLauncherLocale] = {} 115 | 116 | for path in (data_dir / "locale").glob("*/"): 117 | if path.is_dir(): 118 | lang_tag: str = path.name 119 | locales[lang_tag] = OneLauncherLocale.from_data_dir(path) 120 | 121 | return locales 122 | 123 | 124 | @cache 125 | def get_system_locale() -> OneLauncherLocale | None: 126 | """ 127 | Return locale from available_locales that matches the system. 128 | None will be returned if none match. 129 | """ 130 | available_locales = get_available_locales() 131 | 132 | system_lang_tag = QLocale.system().bcp47Name() 133 | 134 | # Return locale for exact match if present. 135 | if system_lang_tag in available_locales: 136 | return available_locales[system_lang_tag] 137 | 138 | # Get locales that match the base language. 139 | if matching_langs := [ 140 | locale 141 | for locale in available_locales.values() 142 | if locale.lang_tag.split("-")[0] == system_lang_tag.split("-")[0] 143 | ]: 144 | return matching_langs[0] 145 | else: 146 | return None 147 | 148 | 149 | def get_default_locale() -> OneLauncherLocale: 150 | return get_system_locale() or get_available_locales()["en-US"] 151 | 152 | 153 | def get_game_dir_available_locales(game_dir: Path) -> list[OneLauncherLocale]: 154 | available_game_locales: list[OneLauncherLocale] = [] 155 | 156 | available_locales_game_names = { 157 | locale.game_language_name: locale for locale in available_locales.values() 158 | } 159 | language_data_files = game_dir.glob("client_local_*.dat") 160 | for file in language_data_files: 161 | # remove "client_local_" (13 chars) and ".dat" (4 chars) from filename 162 | game_language_name = str(file.name)[13:-4] 163 | 164 | try: 165 | available_game_locales.append( 166 | available_locales_game_names[game_language_name] 167 | ) 168 | except KeyError: 169 | logger.error( 170 | f"{game_language_name} does not match a game language name for" 171 | f" an available locale." 172 | ) 173 | 174 | return available_game_locales 175 | 176 | 177 | data_dir = get_data_dir() 178 | available_locales = get_available_locales() 179 | system_locale = get_system_locale() 180 | -------------------------------------------------------------------------------- /src/onelauncher/schemas/v1x_config.xsd: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/onelauncher/standard_game_launcher.py: -------------------------------------------------------------------------------- 1 | from .game_config import GameConfig, GameType 2 | from .network.game_launcher_config import GameLauncherConfig 3 | from .utilities import CaseInsensitiveAbsolutePath 4 | 5 | 6 | async def _get_launcher_path_based_on_client_filename( 7 | game_config: GameConfig, 8 | ) -> CaseInsensitiveAbsolutePath | None: 9 | game_launcher_config = await GameLauncherConfig.from_game_config(game_config) 10 | if game_launcher_config is None: 11 | return None 12 | 13 | game_client_filename = game_launcher_config.get_client_filename()[0] 14 | lowercase_launcher_filename = ( 15 | game_client_filename.lower().split("client")[0] + "launcher.exe" 16 | ) 17 | launcher_path = game_config.game_directory / lowercase_launcher_filename 18 | return launcher_path if launcher_path.exists() else None 19 | 20 | 21 | def _get_launcher_path_with_hardcoded_filenames( 22 | game_config: GameConfig, 23 | ) -> CaseInsensitiveAbsolutePath | None: 24 | match game_config.game_type: 25 | case GameType.LOTRO: 26 | filenames = {"LotroLauncher.exe"} 27 | case GameType.DDO: 28 | filenames = {"DNDLauncher.exe"} 29 | case _: 30 | raise ValueError("Unexpected game type") 31 | 32 | for filename in filenames: 33 | launcher_path = game_config.game_directory / filename 34 | if launcher_path.exists(): 35 | return launcher_path 36 | 37 | # No hard-coded launcher filenames existed 38 | return None 39 | 40 | 41 | def _get_launcher_path_with_search( 42 | game_directory: CaseInsensitiveAbsolutePath, 43 | ) -> CaseInsensitiveAbsolutePath | None: 44 | return next( 45 | ( 46 | file 47 | for file in game_directory.iterdir() 48 | if file.name.lower().endswith("launcher.exe") 49 | ), 50 | None, 51 | ) 52 | 53 | 54 | def _get_launcher_path_from_config( 55 | game_config: GameConfig, 56 | ) -> CaseInsensitiveAbsolutePath | None: 57 | if game_config.standard_game_launcher_filename: 58 | launcher_path = ( 59 | game_config.game_directory / game_config.standard_game_launcher_filename 60 | ) 61 | if launcher_path.exists(): 62 | return launcher_path 63 | 64 | return None 65 | 66 | 67 | async def get_standard_game_launcher_path( 68 | game_config: GameConfig, 69 | ) -> CaseInsensitiveAbsolutePath | None: 70 | launcher_path = _get_launcher_path_from_config(game_config) 71 | if launcher_path is not None: 72 | return launcher_path 73 | 74 | launcher_path = await _get_launcher_path_based_on_client_filename(game_config) 75 | if launcher_path is not None: 76 | return launcher_path 77 | 78 | launcher_path = _get_launcher_path_with_hardcoded_filenames(game_config) 79 | if launcher_path is not None: 80 | return launcher_path 81 | 82 | launcher_path = _get_launcher_path_with_search(game_config.game_directory) 83 | return launcher_path 84 | -------------------------------------------------------------------------------- /src/onelauncher/ui/about.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | dlgAbout 4 | 5 | 6 | Qt::WindowModality::ApplicationModal 7 | 8 | 9 | 10 | 0 11 | 0 12 | 400 13 | 250 14 | 15 | 16 | 17 | About 18 | 19 | 20 | true 21 | 22 | 23 | 24 | 25 | 26 | 9 27 | 28 | 29 | 12 30 | 31 | 32 | 12 33 | 34 | 35 | 12 36 | 37 | 38 | 12 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | Qt::AlignmentFlag::AlignCenter 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Qt::AlignmentFlag::AlignCenter 57 | 58 | 59 | true 60 | 61 | 62 | Qt::TextInteractionFlag::TextBrowserInteraction 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | Qt::AlignmentFlag::AlignCenter 73 | 74 | 75 | 76 | 77 | 78 | 79 | false 80 | 81 | 82 | 83 | 84 | 85 | Qt::AlignmentFlag::AlignCenter 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | Qt::AlignmentFlag::AlignCenter 96 | 97 | 98 | 99 | 100 | 101 | 102 | Qt::Orientation::Vertical 103 | 104 | 105 | 106 | 20 107 | 40 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | Qt::Orientation::Horizontal 118 | 119 | 120 | QDialogButtonBox::StandardButton::Close 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | buttonBox 130 | clicked(QAbstractButton*) 131 | dlgAbout 132 | accept() 133 | 134 | 135 | 259 136 | 273 137 | 138 | 139 | 259 140 | 149 141 | 142 | 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /src/onelauncher/ui/about_uic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ################################################################################ 4 | ## Form generated from reading UI file 'about.ui' 5 | ## 6 | ## Created by: Qt User Interface Compiler version 6.7.2 7 | ## 8 | ## WARNING! All changes made in this file will be lost when recompiling UI file! 9 | ################################################################################ 10 | 11 | from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, 12 | QMetaObject, QObject, QPoint, QRect, 13 | QSize, QTime, QUrl, Qt) 14 | from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, 15 | QFont, QFontDatabase, QGradient, QIcon, 16 | QImage, QKeySequence, QLinearGradient, QPainter, 17 | QPalette, QPixmap, QRadialGradient, QTransform) 18 | from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox, 19 | QLabel, QSizePolicy, QSpacerItem, QVBoxLayout, 20 | QWidget) 21 | 22 | class Ui_dlgAbout(object): 23 | def setupUi(self, dlgAbout: QDialog) -> None: 24 | if not dlgAbout.objectName(): 25 | dlgAbout.setObjectName(u"dlgAbout") 26 | dlgAbout.setWindowModality(Qt.WindowModality.ApplicationModal) 27 | dlgAbout.resize(400, 250) 28 | dlgAbout.setModal(True) 29 | self.verticalLayout_2 = QVBoxLayout(dlgAbout) 30 | self.verticalLayout_2.setObjectName(u"verticalLayout_2") 31 | self.verticalLayout = QVBoxLayout() 32 | self.verticalLayout.setSpacing(9) 33 | self.verticalLayout.setObjectName(u"verticalLayout") 34 | self.verticalLayout.setContentsMargins(12, 12, 12, 12) 35 | self.lblDescription = QLabel(dlgAbout) 36 | self.lblDescription.setObjectName(u"lblDescription") 37 | self.lblDescription.setAlignment(Qt.AlignmentFlag.AlignCenter) 38 | 39 | self.verticalLayout.addWidget(self.lblDescription) 40 | 41 | self.lblRepoWebsite = QLabel(dlgAbout) 42 | self.lblRepoWebsite.setObjectName(u"lblRepoWebsite") 43 | self.lblRepoWebsite.setAlignment(Qt.AlignmentFlag.AlignCenter) 44 | self.lblRepoWebsite.setOpenExternalLinks(True) 45 | self.lblRepoWebsite.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) 46 | 47 | self.verticalLayout.addWidget(self.lblRepoWebsite) 48 | 49 | self.lblCopyright = QLabel(dlgAbout) 50 | self.lblCopyright.setObjectName(u"lblCopyright") 51 | self.lblCopyright.setAlignment(Qt.AlignmentFlag.AlignCenter) 52 | 53 | self.verticalLayout.addWidget(self.lblCopyright) 54 | 55 | self.lblCopyrightHistory = QLabel(dlgAbout) 56 | self.lblCopyrightHistory.setObjectName(u"lblCopyrightHistory") 57 | self.lblCopyrightHistory.setAcceptDrops(False) 58 | self.lblCopyrightHistory.setAlignment(Qt.AlignmentFlag.AlignCenter) 59 | 60 | self.verticalLayout.addWidget(self.lblCopyrightHistory) 61 | 62 | self.lblVersion = QLabel(dlgAbout) 63 | self.lblVersion.setObjectName(u"lblVersion") 64 | self.lblVersion.setAlignment(Qt.AlignmentFlag.AlignCenter) 65 | 66 | self.verticalLayout.addWidget(self.lblVersion) 67 | 68 | self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) 69 | 70 | self.verticalLayout.addItem(self.verticalSpacer) 71 | 72 | 73 | self.verticalLayout_2.addLayout(self.verticalLayout) 74 | 75 | self.buttonBox = QDialogButtonBox(dlgAbout) 76 | self.buttonBox.setObjectName(u"buttonBox") 77 | self.buttonBox.setOrientation(Qt.Orientation.Horizontal) 78 | self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Close) 79 | 80 | self.verticalLayout_2.addWidget(self.buttonBox) 81 | 82 | 83 | self.retranslateUi(dlgAbout) 84 | self.buttonBox.clicked.connect(dlgAbout.accept) 85 | 86 | QMetaObject.connectSlotsByName(dlgAbout) 87 | # setupUi 88 | 89 | def retranslateUi(self, dlgAbout: QDialog) -> None: 90 | dlgAbout.setWindowTitle(QCoreApplication.translate("dlgAbout", u"About", None)) 91 | self.lblDescription.setText("") 92 | self.lblRepoWebsite.setText("") 93 | self.lblCopyright.setText("") 94 | self.lblCopyrightHistory.setText("") 95 | self.lblVersion.setText("") 96 | # retranslateUi 97 | 98 | -------------------------------------------------------------------------------- /src/onelauncher/ui/custom_widgets.py: -------------------------------------------------------------------------------- 1 | from PySide6 import QtCore, QtGui, QtWidgets 2 | from qframelesswindow import FramelessDialog, FramelessMainWindow 3 | from typing_extensions import override 4 | 5 | from onelauncher.qtapp import get_qapp 6 | from onelauncher.ui.qtdesigner.custom_widgets import ( 7 | QDialogWithStylePreview, 8 | QMainWindowWithStylePreview, 9 | ) 10 | 11 | from ..network.game_newsfeed import get_newsfeed_css 12 | 13 | 14 | class FramelessQDialogWithStylePreview(FramelessDialog, QDialogWithStylePreview): ... 15 | 16 | 17 | class FramelessQMainWindowWithStylePreview( 18 | FramelessMainWindow, QMainWindowWithStylePreview 19 | ): ... 20 | 21 | 22 | class GameNewsfeedBrowser(QtWidgets.QTextBrowser): 23 | def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: 24 | super().__init__(parent) 25 | self.setOpenExternalLinks(True) 26 | self.setOpenLinks(True) 27 | self.html: str | None = None 28 | get_qapp().styleHints().colorSchemeChanged.connect(self.updateStyling) 29 | 30 | @override 31 | def setHtml(self, text: str) -> None: 32 | self.document().setDefaultStyleSheet(get_newsfeed_css()) 33 | self.html = text 34 | return super().setHtml(text) 35 | 36 | def updateStyling(self) -> None: 37 | """Update CSS styling""" 38 | if self.html is not None: 39 | self.setHtml(self.html) 40 | 41 | 42 | class NoOddSizesQToolButton(QtWidgets.QToolButton): 43 | """ 44 | Helps icon only buttons keep their icon better centered. 45 | 46 | Credit to [this](https://stackoverflow.com/a/75229629) answer on stackoverflow by 47 | musicamante. 48 | """ 49 | 50 | @override 51 | def sizeHint(self) -> QtCore.QSize: 52 | hint = super().sizeHint() 53 | if hint.width() % 2: 54 | hint.setWidth(hint.width() + 1) 55 | if hint.height() % 2: 56 | hint.setHeight(hint.height() + 1) 57 | return hint 58 | 59 | 60 | class QResizingPixmapLabel(QtWidgets.QLabel): 61 | """ 62 | `QLabel` for displaying `QPixmap`s that scales the image, keeping aspect ratio. 63 | 64 | Based on [this](https://stackoverflow.com/a/71436950) answer by iblanco on stackoverflow. 65 | """ 66 | 67 | def __init__(self, parent: QtWidgets.QWidget | None = None): 68 | super().__init__(parent) 69 | self.setMinimumSize(1, 1) 70 | self.setScaledContents(False) 71 | self._pixmap: QtGui.QPixmap | None = None 72 | 73 | @override 74 | def heightForWidth(self, width: int) -> int: 75 | if self._pixmap is None: 76 | return self.height() 77 | else: 78 | return self._pixmap.height() * width // self._pixmap.width() 79 | 80 | @override 81 | def setPixmap(self, pixmap: QtGui.QPixmap | QtGui.QImage | str) -> None: 82 | if not isinstance(pixmap, QtGui.QPixmap): 83 | self._pixmap = QtGui.QPixmap(pixmap) 84 | else: 85 | self._pixmap = pixmap 86 | super().setPixmap(self._pixmap) 87 | 88 | @override 89 | def sizeHint(self) -> QtCore.QSize: 90 | width = self.width() 91 | return QtCore.QSize(width, self.heightForWidth(width)) 92 | 93 | @override 94 | def resizeEvent(self, event: QtGui.QResizeEvent) -> None: 95 | if self._pixmap is not None: 96 | scaled = self._pixmap.scaled( 97 | self.size() * self.devicePixelRatioF(), 98 | QtCore.Qt.AspectRatioMode.KeepAspectRatio, 99 | QtCore.Qt.TransformationMode.SmoothTransformation, 100 | ) 101 | scaled.setDevicePixelRatio(self.devicePixelRatioF()) 102 | super().setPixmap(scaled) 103 | self.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) 104 | 105 | 106 | class FixedWordWrapQLabel(QtWidgets.QLabel): 107 | """ 108 | `QLabel` that calculates size correctly when word wrapping is enabled. 109 | 110 | The label will still be the same width that it would have been 111 | initially, but the height will be correct. Works good enough for 112 | the current specific settings window use case. 113 | """ 114 | 115 | @override 116 | def minimumSizeHint(self) -> QtCore.QSize: 117 | bounding_rect = self.fontMetrics().boundingRect( 118 | QtCore.QRect(QtCore.QPoint(), self.sizeHint()), 119 | QtCore.Qt.TextFlag.TextWordWrap, 120 | self.text(), 121 | ) 122 | return bounding_rect.size() 123 | 124 | # I tried reimplementing `heightForWidth` like was done for `minimumSizeHint`, but 125 | # that still gave the original QLabel behavior. 126 | @override 127 | def hasHeightForWidth(self) -> bool: 128 | return False 129 | -------------------------------------------------------------------------------- /src/onelauncher/ui/error_message.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | errorDialog 4 | 5 | 6 | Qt::WindowModality::ApplicationModal 7 | 8 | 9 | 10 | 0 11 | 0 12 | 400 13 | 300 14 | 15 | 16 | 17 | Error 18 | 19 | 20 | true 21 | 22 | 23 | 24 | 25 | 26 | Error: 27 | 28 | 29 | 30 | 31 | 32 | 33 | false 34 | 35 | 36 | QPlainTextEdit::LineWrapMode::NoWrap 37 | 38 | 39 | true 40 | 41 | 42 | 43 | 44 | 45 | 46 | Qt::Orientation::Horizontal 47 | 48 | 49 | QDialogButtonBox::StandardButton::Close 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | buttonBox 59 | accepted() 60 | errorDialog 61 | accept() 62 | 63 | 64 | 248 65 | 254 66 | 67 | 68 | 157 69 | 274 70 | 71 | 72 | 73 | 74 | buttonBox 75 | rejected() 76 | errorDialog 77 | reject() 78 | 79 | 80 | 316 81 | 260 82 | 83 | 84 | 286 85 | 274 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/onelauncher/ui/error_message_uic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ################################################################################ 4 | ## Form generated from reading UI file 'error_message.ui' 5 | ## 6 | ## Created by: Qt User Interface Compiler version 6.7.2 7 | ## 8 | ## WARNING! All changes made in this file will be lost when recompiling UI file! 9 | ################################################################################ 10 | 11 | from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, 12 | QMetaObject, QObject, QPoint, QRect, 13 | QSize, QTime, QUrl, Qt) 14 | from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, 15 | QFont, QFontDatabase, QGradient, QIcon, 16 | QImage, QKeySequence, QLinearGradient, QPainter, 17 | QPalette, QPixmap, QRadialGradient, QTransform) 18 | from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox, 19 | QLabel, QPlainTextEdit, QSizePolicy, QVBoxLayout, 20 | QWidget) 21 | 22 | class Ui_errorDialog(object): 23 | def setupUi(self, errorDialog: QDialog) -> None: 24 | if not errorDialog.objectName(): 25 | errorDialog.setObjectName(u"errorDialog") 26 | errorDialog.setWindowModality(Qt.WindowModality.ApplicationModal) 27 | errorDialog.resize(400, 300) 28 | errorDialog.setModal(True) 29 | self.verticalLayout = QVBoxLayout(errorDialog) 30 | self.verticalLayout.setObjectName(u"verticalLayout") 31 | self.textLabel = QLabel(errorDialog) 32 | self.textLabel.setObjectName(u"textLabel") 33 | 34 | self.verticalLayout.addWidget(self.textLabel) 35 | 36 | self.detailsTextEdit = QPlainTextEdit(errorDialog) 37 | self.detailsTextEdit.setObjectName(u"detailsTextEdit") 38 | self.detailsTextEdit.setUndoRedoEnabled(False) 39 | self.detailsTextEdit.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) 40 | self.detailsTextEdit.setReadOnly(True) 41 | 42 | self.verticalLayout.addWidget(self.detailsTextEdit) 43 | 44 | self.buttonBox = QDialogButtonBox(errorDialog) 45 | self.buttonBox.setObjectName(u"buttonBox") 46 | self.buttonBox.setOrientation(Qt.Orientation.Horizontal) 47 | self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Close) 48 | 49 | self.verticalLayout.addWidget(self.buttonBox) 50 | 51 | 52 | self.retranslateUi(errorDialog) 53 | self.buttonBox.accepted.connect(errorDialog.accept) 54 | self.buttonBox.rejected.connect(errorDialog.reject) 55 | 56 | QMetaObject.connectSlotsByName(errorDialog) 57 | # setupUi 58 | 59 | def retranslateUi(self, errorDialog: QDialog) -> None: 60 | errorDialog.setWindowTitle(QCoreApplication.translate("errorDialog", u"Error", None)) 61 | self.textLabel.setText(QCoreApplication.translate("errorDialog", u"Error:", None)) 62 | # retranslateUi 63 | 64 | -------------------------------------------------------------------------------- /src/onelauncher/ui/log_window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | logDialog 4 | 5 | 6 | Qt::WindowModality::ApplicationModal 7 | 8 | 9 | 10 | 0 11 | 0 12 | 400 13 | 300 14 | 15 | 16 | 17 | Logs 18 | 19 | 20 | true 21 | 22 | 23 | 24 | 25 | 26 | false 27 | 28 | 29 | true 30 | 31 | 32 | 33 | 34 | 35 | 36 | Qt::Orientation::Horizontal 37 | 38 | 39 | QDialogButtonBox::StandardButton::Close 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | buttonBox 49 | accepted() 50 | logDialog 51 | accept() 52 | 53 | 54 | 248 55 | 254 56 | 57 | 58 | 157 59 | 274 60 | 61 | 62 | 63 | 64 | buttonBox 65 | rejected() 66 | logDialog 67 | reject() 68 | 69 | 70 | 316 71 | 260 72 | 73 | 74 | 286 75 | 274 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /src/onelauncher/ui/log_window_uic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ################################################################################ 4 | ## Form generated from reading UI file 'log_window.ui' 5 | ## 6 | ## Created by: Qt User Interface Compiler version 6.7.2 7 | ## 8 | ## WARNING! All changes made in this file will be lost when recompiling UI file! 9 | ################################################################################ 10 | 11 | from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, 12 | QMetaObject, QObject, QPoint, QRect, 13 | QSize, QTime, QUrl, Qt) 14 | from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, 15 | QFont, QFontDatabase, QGradient, QIcon, 16 | QImage, QKeySequence, QLinearGradient, QPainter, 17 | QPalette, QPixmap, QRadialGradient, QTransform) 18 | from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox, 19 | QPlainTextEdit, QSizePolicy, QVBoxLayout, QWidget) 20 | 21 | class Ui_logDialog(object): 22 | def setupUi(self, logDialog: QDialog) -> None: 23 | if not logDialog.objectName(): 24 | logDialog.setObjectName(u"logDialog") 25 | logDialog.setWindowModality(Qt.WindowModality.ApplicationModal) 26 | logDialog.resize(400, 300) 27 | logDialog.setModal(True) 28 | self.verticalLayout = QVBoxLayout(logDialog) 29 | self.verticalLayout.setObjectName(u"verticalLayout") 30 | self.detailsTextEdit = QPlainTextEdit(logDialog) 31 | self.detailsTextEdit.setObjectName(u"detailsTextEdit") 32 | self.detailsTextEdit.setUndoRedoEnabled(False) 33 | self.detailsTextEdit.setReadOnly(True) 34 | 35 | self.verticalLayout.addWidget(self.detailsTextEdit) 36 | 37 | self.buttonBox = QDialogButtonBox(logDialog) 38 | self.buttonBox.setObjectName(u"buttonBox") 39 | self.buttonBox.setOrientation(Qt.Orientation.Horizontal) 40 | self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Close) 41 | 42 | self.verticalLayout.addWidget(self.buttonBox) 43 | 44 | 45 | self.retranslateUi(logDialog) 46 | self.buttonBox.accepted.connect(logDialog.accept) 47 | self.buttonBox.rejected.connect(logDialog.reject) 48 | 49 | QMetaObject.connectSlotsByName(logDialog) 50 | # setupUi 51 | 52 | def retranslateUi(self, logDialog: QDialog) -> None: 53 | logDialog.setWindowTitle(QCoreApplication.translate("logDialog", u"Logs", None)) 54 | # retranslateUi 55 | 56 | -------------------------------------------------------------------------------- /src/onelauncher/ui/patching_window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | patchingDialog 4 | 5 | 6 | Qt::WindowModality::ApplicationModal 7 | 8 | 9 | 10 | 0 11 | 0 12 | 720 13 | 400 14 | 15 | 16 | 17 | MainWindow 18 | 19 | 20 | true 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 24 32 | 33 | 34 | %p% (%v/%m) 35 | 36 | 37 | 38 | 39 | 40 | 41 | Start 42 | 43 | 44 | 45 | 46 | 47 | 48 | Stop 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | btnStart 58 | btnStop 59 | txtLog 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/onelauncher/ui/patching_window_uic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ################################################################################ 4 | ## Form generated from reading UI file 'patching_window.ui' 5 | ## 6 | ## Created by: Qt User Interface Compiler version 6.7.2 7 | ## 8 | ## WARNING! All changes made in this file will be lost when recompiling UI file! 9 | ################################################################################ 10 | 11 | from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, 12 | QMetaObject, QObject, QPoint, QRect, 13 | QSize, QTime, QUrl, Qt) 14 | from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, 15 | QFont, QFontDatabase, QGradient, QIcon, 16 | QImage, QKeySequence, QLinearGradient, QPainter, 17 | QPalette, QPixmap, QRadialGradient, QTransform) 18 | from PySide6.QtWidgets import (QApplication, QDialog, QHBoxLayout, QProgressBar, 19 | QPushButton, QSizePolicy, QTextBrowser, QVBoxLayout, 20 | QWidget) 21 | 22 | class Ui_patchingDialog(object): 23 | def setupUi(self, patchingDialog: QDialog) -> None: 24 | if not patchingDialog.objectName(): 25 | patchingDialog.setObjectName(u"patchingDialog") 26 | patchingDialog.setWindowModality(Qt.WindowModality.ApplicationModal) 27 | patchingDialog.resize(720, 400) 28 | patchingDialog.setModal(True) 29 | self.verticalLayout = QVBoxLayout(patchingDialog) 30 | self.verticalLayout.setObjectName(u"verticalLayout") 31 | self.txtLog = QTextBrowser(patchingDialog) 32 | self.txtLog.setObjectName(u"txtLog") 33 | 34 | self.verticalLayout.addWidget(self.txtLog) 35 | 36 | self.horizontalLayout = QHBoxLayout() 37 | self.horizontalLayout.setObjectName(u"horizontalLayout") 38 | self.progressBar = QProgressBar(patchingDialog) 39 | self.progressBar.setObjectName(u"progressBar") 40 | self.progressBar.setValue(24) 41 | 42 | self.horizontalLayout.addWidget(self.progressBar) 43 | 44 | self.btnStart = QPushButton(patchingDialog) 45 | self.btnStart.setObjectName(u"btnStart") 46 | 47 | self.horizontalLayout.addWidget(self.btnStart) 48 | 49 | self.btnStop = QPushButton(patchingDialog) 50 | self.btnStop.setObjectName(u"btnStop") 51 | 52 | self.horizontalLayout.addWidget(self.btnStop) 53 | 54 | 55 | self.verticalLayout.addLayout(self.horizontalLayout) 56 | 57 | QWidget.setTabOrder(self.btnStart, self.btnStop) 58 | QWidget.setTabOrder(self.btnStop, self.txtLog) 59 | 60 | self.retranslateUi(patchingDialog) 61 | 62 | QMetaObject.connectSlotsByName(patchingDialog) 63 | # setupUi 64 | 65 | def retranslateUi(self, patchingDialog: QDialog) -> None: 66 | patchingDialog.setWindowTitle(QCoreApplication.translate("patchingDialog", u"MainWindow", None)) 67 | self.progressBar.setFormat(QCoreApplication.translate("patchingDialog", u"%p% (%v/%m)", None)) 68 | self.btnStart.setText(QCoreApplication.translate("patchingDialog", u"Start", None)) 69 | self.btnStop.setText(QCoreApplication.translate("patchingDialog", u"Stop", None)) 70 | # retranslateUi 71 | 72 | -------------------------------------------------------------------------------- /src/onelauncher/ui/qtdesigner/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuneStepp/OneLauncher/503994be5f9264c122c8cf45534ef02134e25eb3/src/onelauncher/ui/qtdesigner/__init__.py -------------------------------------------------------------------------------- /src/onelauncher/ui/qtdesigner/custom_widgets.py: -------------------------------------------------------------------------------- 1 | from PySide6 import QtWidgets 2 | 3 | 4 | class QWidgetWithStylePreview(QtWidgets.QWidget): 5 | """ 6 | QWidget that is used in QtDesigner plugin for stylesheet previewing. 7 | Acts identical to a normal QWidget during runtime. 8 | """ 9 | 10 | 11 | class QDialogWithStylePreview(QtWidgets.QDialog): 12 | """ 13 | QDialog that is used in QtDesigner plugin for stylesheet previewing. 14 | Acts identical to a normal QDialog during runtime. 15 | """ 16 | 17 | 18 | class QMainWindowWithStylePreview(QtWidgets.QMainWindow): 19 | """ 20 | QMainWindow that is used in QtDesigner plugin for stylesheet previewing. 21 | Acts identical to a normal QMainWindow during runtime. 22 | """ 23 | -------------------------------------------------------------------------------- /src/onelauncher/ui/qtdesigner/register_plugin.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection 2 | 3 | from onelauncher.ui.qtdesigner.custom_widgets import ( 4 | QDialogWithStylePreview, 5 | QMainWindowWithStylePreview, 6 | QWidgetWithStylePreview, 7 | ) 8 | from onelauncher.ui.qtdesigner.style_preview_plugin import ( 9 | CustomStylesheetPlugin, 10 | ) 11 | 12 | if __name__ == "__main__": 13 | QPyDesignerCustomWidgetCollection.addCustomWidget( 14 | CustomStylesheetPlugin(widget_type=QWidgetWithStylePreview) 15 | ) 16 | QPyDesignerCustomWidgetCollection.addCustomWidget( 17 | CustomStylesheetPlugin(widget_type=QDialogWithStylePreview) 18 | ) 19 | QPyDesignerCustomWidgetCollection.addCustomWidget( 20 | CustomStylesheetPlugin(widget_type=QMainWindowWithStylePreview) 21 | ) 22 | -------------------------------------------------------------------------------- /src/onelauncher/ui/qtdesigner/style_preview_plugin.py: -------------------------------------------------------------------------------- 1 | from PySide6 import QtDesigner, QtGui, QtWidgets 2 | from typing_extensions import override 3 | 4 | from onelauncher.ui.style import ApplicationStyle 5 | 6 | 7 | class CustomStylesheetPlugin(QtDesigner.QDesignerCustomWidgetInterface): 8 | def __init__(self, widget_type: type[QtWidgets.QWidget]) -> None: 9 | super().__init__() 10 | self.widget_type = widget_type 11 | self._form_editor: QtDesigner.QDesignerFormEditorInterface | None = None 12 | 13 | @override 14 | def createWidget(self, parent: QtWidgets.QWidget | None) -> QtWidgets.QWidget: 15 | widget = self.widget_type(parent) 16 | widget.setStyleSheet(self._app_stylesheet) 17 | return widget 18 | 19 | @override 20 | def domXml(self) -> str: 21 | return f""" 22 | 23 | 24 | 25 | 26 | """ 27 | 28 | @override 29 | def group(self) -> str: 30 | return "" 31 | 32 | @override 33 | def icon(self) -> QtGui.QIcon: 34 | return QtGui.QIcon() 35 | 36 | @override 37 | def includeFile(self) -> str: 38 | return ".qtdesigner.custom_widgets" 39 | 40 | @override 41 | def initialize(self, form_editor: QtDesigner.QDesignerFormEditorInterface) -> None: 42 | self._form_editor = form_editor 43 | qapp = QtWidgets.QApplication.instance() 44 | if not qapp or not isinstance(qapp, QtWidgets.QApplication): 45 | raise RuntimeError("No QApplication found.") 46 | self._app_stylesheet = ApplicationStyle(qapp).generate_stylesheet( 47 | qtdesigner_version=True 48 | ) 49 | 50 | @override 51 | def isInitialized(self) -> bool: 52 | return self._form_editor is not None 53 | 54 | @override 55 | def isContainer(self) -> bool: 56 | return True 57 | 58 | @override 59 | def name(self) -> str: 60 | return self.widget_type.__name__ 61 | 62 | @override 63 | def toolTip(self) -> str: 64 | return f"{self.widget_type.mro()[1].__name__} with special stylesheet set for proper display in QtDesigner" 65 | 66 | @override 67 | def whatsThis(self) -> str: 68 | return self.toolTip() 69 | -------------------------------------------------------------------------------- /src/onelauncher/ui/select_subscription.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | dlgSelectSubscription 4 | 5 | 6 | Qt::WindowModality::ApplicationModal 7 | 8 | 9 | 10 | 0 11 | 0 12 | 320 13 | 169 14 | 15 | 16 | 17 | Select Subscription 18 | 19 | 20 | true 21 | 22 | 23 | 24 | 9 25 | 26 | 27 | 28 | 29 | Multiple game sub-accounts found 30 | 31 | Please select one 32 | 33 | 34 | Qt::AlignmentFlag::AlignCenter 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | Qt::Orientation::Horizontal 45 | 46 | 47 | QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | buttonBox 57 | accepted() 58 | dlgSelectSubscription 59 | accept() 60 | 61 | 62 | 227 63 | 148 64 | 65 | 66 | 159 67 | 84 68 | 69 | 70 | 71 | 72 | buttonBox 73 | rejected() 74 | dlgSelectSubscription 75 | reject() 76 | 77 | 78 | 227 79 | 148 80 | 81 | 82 | 159 83 | 84 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/onelauncher/ui/select_subscription_uic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ################################################################################ 4 | ## Form generated from reading UI file 'select_subscription.ui' 5 | ## 6 | ## Created by: Qt User Interface Compiler version 6.7.2 7 | ## 8 | ## WARNING! All changes made in this file will be lost when recompiling UI file! 9 | ################################################################################ 10 | 11 | from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, 12 | QMetaObject, QObject, QPoint, QRect, 13 | QSize, QTime, QUrl, Qt) 14 | from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, 15 | QFont, QFontDatabase, QGradient, QIcon, 16 | QImage, QKeySequence, QLinearGradient, QPainter, 17 | QPalette, QPixmap, QRadialGradient, QTransform) 18 | from PySide6.QtWidgets import (QAbstractButton, QApplication, QComboBox, QDialog, 19 | QDialogButtonBox, QLabel, QSizePolicy, QVBoxLayout, 20 | QWidget) 21 | 22 | class Ui_dlgSelectSubscription(object): 23 | def setupUi(self, dlgSelectSubscription: QDialog) -> None: 24 | if not dlgSelectSubscription.objectName(): 25 | dlgSelectSubscription.setObjectName(u"dlgSelectSubscription") 26 | dlgSelectSubscription.setWindowModality(Qt.WindowModality.ApplicationModal) 27 | dlgSelectSubscription.resize(320, 169) 28 | dlgSelectSubscription.setModal(True) 29 | self.verticalLayout = QVBoxLayout(dlgSelectSubscription) 30 | self.verticalLayout.setSpacing(9) 31 | self.verticalLayout.setObjectName(u"verticalLayout") 32 | self.label = QLabel(dlgSelectSubscription) 33 | self.label.setObjectName(u"label") 34 | self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) 35 | 36 | self.verticalLayout.addWidget(self.label) 37 | 38 | self.subscriptionsComboBox = QComboBox(dlgSelectSubscription) 39 | self.subscriptionsComboBox.setObjectName(u"subscriptionsComboBox") 40 | 41 | self.verticalLayout.addWidget(self.subscriptionsComboBox) 42 | 43 | self.buttonBox = QDialogButtonBox(dlgSelectSubscription) 44 | self.buttonBox.setObjectName(u"buttonBox") 45 | self.buttonBox.setOrientation(Qt.Orientation.Horizontal) 46 | self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok) 47 | 48 | self.verticalLayout.addWidget(self.buttonBox) 49 | 50 | 51 | self.retranslateUi(dlgSelectSubscription) 52 | self.buttonBox.accepted.connect(dlgSelectSubscription.accept) 53 | self.buttonBox.rejected.connect(dlgSelectSubscription.reject) 54 | 55 | QMetaObject.connectSlotsByName(dlgSelectSubscription) 56 | # setupUi 57 | 58 | def retranslateUi(self, dlgSelectSubscription: QDialog) -> None: 59 | dlgSelectSubscription.setWindowTitle(QCoreApplication.translate("dlgSelectSubscription", u"Select Subscription", None)) 60 | self.label.setText(QCoreApplication.translate("dlgSelectSubscription", u"Multiple game sub-accounts found\n" 61 | "\n" 62 | "Please select one", None)) 63 | # retranslateUi 64 | 65 | -------------------------------------------------------------------------------- /src/onelauncher/ui/start_game.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | startGameDialog 4 | 5 | 6 | Qt::WindowModality::ApplicationModal 7 | 8 | 9 | 10 | 0 11 | 0 12 | 720 13 | 400 14 | 15 | 16 | 17 | MainWindow 18 | 19 | 20 | true 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | Qt::Orientation::Horizontal 32 | 33 | 34 | 35 | 40 36 | 20 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | Abort 45 | 46 | 47 | 48 | 49 | 50 | 51 | Quit 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | btnAbort 61 | btnQuit 62 | txtLog 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/onelauncher/ui/start_game_uic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ################################################################################ 4 | ## Form generated from reading UI file 'start_game.ui' 5 | ## 6 | ## Created by: Qt User Interface Compiler version 6.7.2 7 | ## 8 | ## WARNING! All changes made in this file will be lost when recompiling UI file! 9 | ################################################################################ 10 | 11 | from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, 12 | QMetaObject, QObject, QPoint, QRect, 13 | QSize, QTime, QUrl, Qt) 14 | from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, 15 | QFont, QFontDatabase, QGradient, QIcon, 16 | QImage, QKeySequence, QLinearGradient, QPainter, 17 | QPalette, QPixmap, QRadialGradient, QTransform) 18 | from PySide6.QtWidgets import (QApplication, QDialog, QHBoxLayout, QPushButton, 19 | QSizePolicy, QSpacerItem, QTextBrowser, QVBoxLayout, 20 | QWidget) 21 | 22 | class Ui_startGameDialog(object): 23 | def setupUi(self, startGameDialog: QDialog) -> None: 24 | if not startGameDialog.objectName(): 25 | startGameDialog.setObjectName(u"startGameDialog") 26 | startGameDialog.setWindowModality(Qt.WindowModality.ApplicationModal) 27 | startGameDialog.resize(720, 400) 28 | startGameDialog.setModal(True) 29 | self.verticalLayout = QVBoxLayout(startGameDialog) 30 | self.verticalLayout.setObjectName(u"verticalLayout") 31 | self.txtLog = QTextBrowser(startGameDialog) 32 | self.txtLog.setObjectName(u"txtLog") 33 | 34 | self.verticalLayout.addWidget(self.txtLog) 35 | 36 | self.horizontalLayout = QHBoxLayout() 37 | self.horizontalLayout.setObjectName(u"horizontalLayout") 38 | self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) 39 | 40 | self.horizontalLayout.addItem(self.horizontalSpacer) 41 | 42 | self.btnAbort = QPushButton(startGameDialog) 43 | self.btnAbort.setObjectName(u"btnAbort") 44 | 45 | self.horizontalLayout.addWidget(self.btnAbort) 46 | 47 | self.btnQuit = QPushButton(startGameDialog) 48 | self.btnQuit.setObjectName(u"btnQuit") 49 | 50 | self.horizontalLayout.addWidget(self.btnQuit) 51 | 52 | 53 | self.verticalLayout.addLayout(self.horizontalLayout) 54 | 55 | QWidget.setTabOrder(self.btnAbort, self.btnQuit) 56 | QWidget.setTabOrder(self.btnQuit, self.txtLog) 57 | 58 | self.retranslateUi(startGameDialog) 59 | 60 | QMetaObject.connectSlotsByName(startGameDialog) 61 | # setupUi 62 | 63 | def retranslateUi(self, startGameDialog: QDialog) -> None: 64 | startGameDialog.setWindowTitle(QCoreApplication.translate("startGameDialog", u"MainWindow", None)) 65 | self.btnAbort.setText(QCoreApplication.translate("startGameDialog", u"Abort", None)) 66 | self.btnQuit.setText(QCoreApplication.translate("startGameDialog", u"Quit", None)) 67 | # retranslateUi 68 | 69 | -------------------------------------------------------------------------------- /src/onelauncher/ui/style.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from textwrap import dedent 3 | from typing import Final, Literal, TypeAlias 4 | 5 | from PySide6 import QtCore, QtGui 6 | from PySide6.QtWidgets import QApplication 7 | 8 | from ..resources import data_dir 9 | 10 | # Dynamic property used for styling, like the class attribute in HTML 11 | CLASS_PROPERTY: str = "qssClass" 12 | 13 | Rem: TypeAlias = float 14 | """ 15 | Unit that is the application base font size multiplied by the Rem number when 16 | transformed into pixels. 17 | """ 18 | SPACING: Final[dict[str, Rem]] = { 19 | "0": 0.0, 20 | "0.5": 0.125, 21 | "1": 0.25, 22 | "1.5": 0.375, 23 | "2": 0.5, 24 | "2.5": 0.625, 25 | "3": 0.75, 26 | "3.5": 0.875, 27 | "4": 1.0, 28 | "5": 1.25, 29 | "6": 1.5, 30 | "7": 1.75, 31 | "8": 2.0, 32 | "9": 2.25, 33 | "10": 2.5, 34 | "11": 2.75, 35 | "12": 3.0, 36 | "14": 3.5, 37 | "16": 4.0, 38 | "20": 5.0, 39 | "24": 6.0, 40 | "28": 7.0, 41 | "32": 8.0, 42 | "36": 9.0, 43 | "40": 10.0, 44 | "44": 11.0, 45 | "48": 12.0, 46 | "52": 13.0, 47 | "56": 14.0, 48 | "60": 15.0, 49 | "64": 16.0, 50 | "72": 18.0, 51 | "80": 20.0, 52 | "96": 24.0, 53 | } 54 | TYPE_SCALE: Final[dict[str, Rem]] = { 55 | "xs": 0.75, 56 | "sm": 0.875, 57 | "base": 1.0, 58 | "lg": 1.125, 59 | "xl": 1.25, 60 | "2xl": 1.5, 61 | "3xl": 1.875, 62 | "4xl": 2.25, 63 | "5xl": 3.0, 64 | "6xl": 3.75, 65 | "7xl": 4.5, 66 | "8xl": 6.0, 67 | "9xl": 8.0, 68 | } 69 | Direction: TypeAlias = Literal["left", "right", "top", "bottom"] 70 | DIRECTIONS_SHORTHAND: tuple[tuple[tuple[Direction, ...], str], ...] = ( 71 | (("left",), "l"), 72 | (("right",), "r"), 73 | (("top",), "t"), 74 | (("bottom",), "b"), 75 | (("left", "right"), "x"), 76 | (("top", "bottom"), "y"), 77 | ) 78 | 79 | 80 | class ApplicationStyle(QtCore.QObject): 81 | """ 82 | Manages app stylesheet. 83 | There should only ever be one instance of this class for every QApplication 84 | """ 85 | 86 | def __init__(self, qapp: QApplication) -> None: 87 | super().__init__() 88 | self.qapp = qapp 89 | self.update_app_stylesheet() 90 | qapp.fontChanged.connect(self.update_base_font) 91 | qapp.styleHints().colorSchemeChanged.connect(self.update_app_stylesheet) 92 | 93 | def update_app_stylesheet(self) -> None: 94 | self.qapp.setStyleSheet(self.generate_stylesheet()) 95 | 96 | def update_base_font(self) -> None: 97 | if self.qapp.font() != self._font_base: 98 | self.update_app_stylesheet() 99 | 100 | def rem_to_px(self, rem: Rem) -> int: 101 | return round(self._base_font_metrics.height() * rem) 102 | 103 | def generate_stylesheet(self, qtdesigner_version: bool = False) -> str: 104 | self._font_base = self.qapp.font() 105 | self._base_font_metrics = QtGui.QFontMetricsF(self._font_base) 106 | stylesheet = "/* AUTOGENERATED. DO NOT EDIT. */" 107 | # Set defaults 108 | stylesheet += dedent(f""" 109 | * {{ 110 | font-size: {self._font_base.pointSizeF()}pt; 111 | icon-size: {self.rem_to_px(TYPE_SCALE["base"])}px; 112 | }} 113 | """) 114 | if qtdesigner_version: 115 | # Set placeholder banner for better style preview in QtDesigner 116 | stylesheet += dedent(f""" 117 | QLabel#imgGameBanner {{ 118 | qproperty-pixmap: url({((data_dir / Path("images/LOTRO_banner.png")).as_posix())}) 119 | }} 120 | """) 121 | stylesheet += self._get_font_size_qss() 122 | stylesheet += self._get_icon_size_qss(qtdesigner_version=qtdesigner_version) 123 | stylesheet += self._get_directional_spacing_qss("margin", "m") 124 | stylesheet += self._get_directional_spacing_qss("padding", "p") 125 | stylesheet += self._get_spacing_qss("width", "w") 126 | stylesheet += self._get_spacing_qss("min-width", "min-w") 127 | stylesheet += self._get_spacing_qss("max-width", "max-w") 128 | stylesheet += self._get_spacing_qss("height", "h") 129 | stylesheet += self._get_spacing_qss("min-height", "min-h") 130 | stylesheet += self._get_spacing_qss("max-height", "max-h") 131 | return stylesheet 132 | 133 | def _get_font_size_qss(self) -> str: 134 | stylesheet = "" 135 | for scale_prefix, rem in TYPE_SCALE.items(): 136 | stylesheet += dedent(f""" 137 | *[{CLASS_PROPERTY}~="text-{scale_prefix}"] {{ 138 | font-size: {self._font_base.pointSizeF() * rem}pt; 139 | }}""") 140 | return stylesheet 141 | 142 | def _get_icon_size_qss(self, qtdesigner_version: bool = False) -> str: 143 | stylesheet = "" 144 | for scale_prefix, rem in TYPE_SCALE.items(): 145 | stylesheet += dedent(f""" 146 | *[{CLASS_PROPERTY}~="icon-{scale_prefix}"] {{ 147 | qproperty-iconSize: {self.rem_to_px(rem)}px; 148 | {f"qproperty-icon: url({(data_dir / Path('images/placeholder_icon.svg')).as_posix()});" if qtdesigner_version else ""} 149 | }}""") 150 | return stylesheet 151 | 152 | def _get_spacing_qss(self, property_name: str, property_shorthand: str) -> str: 153 | stylesheet = "" 154 | for spacing_name, spacing_rem in SPACING.items(): 155 | spacing_px = self.rem_to_px(spacing_rem) 156 | stylesheet += dedent(f""" 157 | *[{CLASS_PROPERTY}~="{property_shorthand}-{spacing_name}"] {{ 158 | {property_name}: {spacing_px}px; 159 | }}""") 160 | return stylesheet 161 | 162 | def _get_directional_spacing_qss( 163 | self, property_name: str, property_shorthand: str 164 | ) -> str: 165 | stylesheet = "" 166 | for spacing_name, spacing_rem in SPACING.items(): 167 | spacing_px = self.rem_to_px(spacing_rem) 168 | stylesheet += dedent(f""" 169 | *[{CLASS_PROPERTY}~="{property_shorthand}-{spacing_name}"] {{ 170 | {property_name}: {spacing_px}px; 171 | }}""") 172 | for directions, directions_shorthand in DIRECTIONS_SHORTHAND: 173 | stylesheet += dedent(f""" 174 | *[{CLASS_PROPERTY}~="{property_shorthand}{directions_shorthand}-{spacing_name}"] {{ 175 | """) 176 | for direction in directions: 177 | stylesheet += f" {property_name}-{direction}: {spacing_px}px;\n" 178 | stylesheet += "}" 179 | return stylesheet 180 | -------------------------------------------------------------------------------- /src/onelauncher/ui_utilities.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PySide6 import QtCore, QtWidgets 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | def show_warning_message(message: str, parent: QtWidgets.QWidget | None) -> None: 9 | message_box = QtWidgets.QMessageBox(parent) 10 | message_box.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint) 11 | message_box.setIcon(QtWidgets.QMessageBox.Icon.Warning) 12 | message_box.setStandardButtons(message_box.StandardButton.Ok) 13 | message_box.setInformativeText(message) 14 | 15 | message_box.exec() 16 | 17 | 18 | def show_message_box_details_as_markdown(messageBox: QtWidgets.QMessageBox) -> None: 19 | """Makes the detailed text of messageBox display in Markdown format""" 20 | button_box: QtWidgets.QDialogButtonBox = messageBox.findChild( # type: ignore[assignment] 21 | QtWidgets.QDialogButtonBox, "qt_msgbox_buttonbox" 22 | ) 23 | for button in button_box.buttons(): 24 | if ( 25 | messageBox.buttonRole(button) == QtWidgets.QMessageBox.ButtonRole.ActionRole 26 | and button.text() == "Show Details..." 27 | ): 28 | button.click() 29 | detailed_text_widget: QtWidgets.QTextEdit = messageBox.findChild( # type: ignore[assignment] 30 | QtWidgets.QTextEdit 31 | ) 32 | detailed_text_widget.setMarkdown(messageBox.detailedText()) 33 | button.click() 34 | return 35 | 36 | 37 | def log_record_to_rich_text(record: logging.LogRecord) -> str: 38 | if record.levelno == logging.WARNING: 39 | return f"{record.message}" 40 | elif record.levelno >= logging.ERROR: 41 | return f'{record.message}' 42 | else: 43 | return record.message 44 | -------------------------------------------------------------------------------- /src/onelauncher/wine/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import attrs 4 | 5 | from ..config import config_field 6 | 7 | 8 | @attrs.frozen 9 | class WineConfigSection: 10 | builtin_prefix_enabled: bool = config_field( 11 | default=True, help="If WINE should be automatically managed" 12 | ) 13 | user_wine_executable_path: Path | None = config_field( # noqa: RUF009 14 | default=None, 15 | help=( 16 | "Path to the WINE executable to use when WINE isn't automatically managed" 17 | ), 18 | ) 19 | user_prefix_path: Path | None = config_field( # noqa: RUF009 20 | default=None, 21 | help="Path to the WINE prefix to use when WINE isn't automatically managed", 22 | ) 23 | debug_level: str | None = config_field( 24 | default=None, help="Value for the WINEDEBUG environment variable" 25 | ) 26 | -------------------------------------------------------------------------------- /src/run_patch_client/.gitignore: -------------------------------------------------------------------------------- 1 | *.exe -------------------------------------------------------------------------------- /src/run_patch_client/Makefile: -------------------------------------------------------------------------------- 1 | # "ptch" instead of "patch", b/c Windows assumes files with "patch" in the name 2 | # should be run as administrator. 3 | OUTPUT = run_ptch_client.exe 4 | 5 | # A 32-bit Windows executable should be the output regardless of build system. 6 | CC = i686-w64-mingw32-gcc 7 | 8 | # Build the executable 9 | all: run_patch_client.c 10 | $(CC) -o $(OUTPUT) run_patch_client.c -lkernel32 -static-libgcc -s 11 | 12 | clean: 13 | rm $(OUTPUT) 14 | -------------------------------------------------------------------------------- /src/run_patch_client/default.nix: -------------------------------------------------------------------------------- 1 | { pkgsCross }: 2 | pkgsCross.mingw32.stdenv.mkDerivation { 3 | name = "run-patch-client"; 4 | 5 | src = ./.; 6 | 7 | installPhase = '' 8 | mkdir -p $out/bin 9 | 10 | cp run_ptch_client.exe $out/bin/ 11 | ''; 12 | } 13 | -------------------------------------------------------------------------------- /src/run_patch_client/run_patch_client.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | int main(int argc, char *argv[]) { 5 | if (argc != 3) { 6 | fprintf(stderr, "Usage: \"\"\n"); 7 | return 1; 8 | } 9 | 10 | HMODULE lib = LoadLibrary(argv[1]); 11 | if (lib == NULL) { 12 | fprintf(stderr, "Failed to load patch client DLL\n"); 13 | return 1; 14 | } 15 | 16 | // Patch/PatchW use rundll32 style function signatures. 17 | // The first two arguments aren't relevant to our usage. 18 | typedef void (*PatchFunc)(void*, void*, const char*); 19 | PatchFunc Patch = (PatchFunc)GetProcAddress(lib, "Patch"); 20 | if (Patch == NULL) { 21 | fprintf(stderr, "No `Patch` function found in patch client DLL\n"); 22 | FreeLibrary(lib); 23 | return 1; 24 | } 25 | Patch(NULL, NULL, argv[2]); 26 | 27 | // Free the DLL module 28 | FreeLibrary(lib); 29 | return 0; 30 | } 31 | -------------------------------------------------------------------------------- /stubs/qframelesswindow/__init__.pyi: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import QDialog, QMainWindow, QWidget 2 | 3 | class TitleBarBase(QWidget): ... 4 | class TitleBar(TitleBarBase): ... 5 | 6 | class FramelessWindow: 7 | titleBar: TitleBar 8 | 9 | class FramelessDialog(FramelessWindow, QDialog): ... 10 | class FramelessMainWindow(FramelessWindow, QMainWindow): ... 11 | -------------------------------------------------------------------------------- /stubs/qtawesome/__init__.pyi: -------------------------------------------------------------------------------- 1 | from typing import Literal, TypedDict 2 | 3 | from qtpy.QtGui import QColor, QIcon 4 | from qtpy.QtWidgets import QWidget 5 | 6 | class Spin: 7 | def __init__( 8 | self, 9 | parent_widget: QWidget, 10 | interval: int = 10, 11 | step: int = 1, 12 | autostart: bool = True, 13 | ): ... 14 | 15 | class Pulse(Spin): 16 | def __init__(self, parent_widget: QWidget, autostart: bool = True): ... 17 | 18 | Color = str | QColor | tuple[str, int] 19 | 20 | def set_defaults( 21 | color: str | QColor = ..., 22 | offset: tuple[float, float] = ..., 23 | animation: Spin | Pulse | None = None, 24 | scale_factor: float = ..., 25 | active: str = ..., 26 | color_active: str | QColor = ..., 27 | disabled: str = ..., 28 | color_disabled: str | QColor = ..., 29 | selected: str = ..., 30 | color_selected: str | QColor = ..., 31 | ) -> None: ... 32 | 33 | class OptionsDict(TypedDict, total=False): 34 | color: str | QColor 35 | offset: tuple[float, float] 36 | animation: Spin | Pulse | None 37 | draw: Literal["text", "path", "glphrun", "image"] 38 | scale_factor: float 39 | active: str 40 | color_active: str | QColor 41 | disabled: str 42 | color_disabled: str | QColor 43 | selected: str 44 | color_selected: str | QColor 45 | 46 | def icon( 47 | *names: str, 48 | rotated: int = ..., 49 | hflip: bool = ..., 50 | vflip: bool = ..., 51 | options: list[OptionsDict] = ..., 52 | color: str | QColor = ..., 53 | offset: tuple[float, float] = ..., 54 | animation: Spin | Pulse | None = None, 55 | scale_factor: float = ..., 56 | active: str = ..., 57 | color_active: str | QColor = ..., 58 | disabled: str = ..., 59 | color_disabled: str | QColor = ..., 60 | selected: str = ..., 61 | color_selected: str | QColor = ..., 62 | ) -> QIcon: ... 63 | def load_font( 64 | prefix: str, ttf_filename: str, charmap_filename: str, directory: str | None = None 65 | ) -> None: ... 66 | def reset_cache() -> None: ... 67 | -------------------------------------------------------------------------------- /tests/onelauncher/_test_mypy_plugin.mypy_test_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | File analyzed with mypy as part of `test_mypy_plugin.py`. Has intentional type errors. 3 | """ 4 | 5 | from typing import Any 6 | 7 | import attrs 8 | 9 | from onelauncher.config import config_field 10 | 11 | 12 | @attrs.define 13 | class AttrsClassUsingConfigField: 14 | x: Any = config_field() 15 | 16 | 17 | _ = AttrsClassUsingConfigField() 18 | -------------------------------------------------------------------------------- /tests/onelauncher/network/test_game_launcher_config.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | 5 | from onelauncher.game_config import ClientType 6 | from onelauncher.network.game_launcher_config import GameLauncherConfig 7 | 8 | 9 | def get_mock_game_launcher_config_partial() -> partial[GameLauncherConfig]: 10 | return partial( 11 | GameLauncherConfig, 12 | client_win64_filename="lotroclient64.exe", 13 | client_win32_filename="lotroclient.exe", 14 | client_win32_legacy_filename="lotroclient_awesomium.exe", 15 | client_launch_args_template="-a {SUBSCRIPTION} -h {LOGIN} --glsticketdirect {GLS} --chatserver {CHAT} --rodat on --language {LANG} --gametype LOTRO --authserverurl {AUTHSERVERURL} --glsticketlifetime {GLSTICKETLIFETIME}", 16 | client_crash_server_arg="http://crash.lotro.com:8080/CrashReceiver-1.0", 17 | client_auth_server_arg="https://gls.lotro.com/gls.authserver/service.asmx", 18 | client_gls_ticket_lifetime_arg="21600", 19 | client_default_upload_throttle_mbps_arg="1", 20 | client_bug_url_arg=None, 21 | client_support_url_arg=None, 22 | client_support_service_url_arg=None, 23 | high_res_patch_arg=None, 24 | patching_product_code="LOTRO", 25 | login_queue_url="https://gls.lotro.com/GLS.AuthServer/LoginQueue.aspx", 26 | login_queue_params_template="command=TakeANumber&subscription={0}&ticket={1}&ticket_type=GLS&queue_url={2}", 27 | newsfeed_url_template="https://forums.lotro.com/{lang}/launcher-feed.xml", 28 | ) 29 | 30 | 31 | @pytest.fixture 32 | def mock_game_launcher_config() -> GameLauncherConfig: 33 | return get_mock_game_launcher_config_partial()() 34 | 35 | 36 | class TestGameLauncherConfig: 37 | # Test that get_specific_client_filename supports all client types 38 | @pytest.mark.parametrize("client_type", list(ClientType)) 39 | def test_get_specific_client_filename( 40 | self, mock_game_launcher_config: GameLauncherConfig, client_type: ClientType 41 | ) -> None: 42 | assert type( 43 | mock_game_launcher_config.get_specific_client_filename(client_type) 44 | ) in [None, str] 45 | 46 | @pytest.mark.parametrize( 47 | ( 48 | "client_win64_filename", 49 | "client_win32_filename", 50 | "client_win32_legacy_filename", 51 | "input_client_type", 52 | "expected_output_client_type", 53 | ), 54 | [ 55 | ( 56 | None, 57 | "lotroclient.exe", 58 | "lotroclient_awesomium.exe", 59 | ClientType.WIN64, 60 | ClientType.WIN32, 61 | ), 62 | ( 63 | "lotroclient64.exe", 64 | None, 65 | "lotroclient_awesomium.exe", 66 | ClientType.WIN32, 67 | ClientType.WIN32_LEGACY, 68 | ), 69 | ( 70 | "lotroclient64.exe", 71 | "lotroclient.exe", 72 | None, 73 | ClientType.WIN32_LEGACY, 74 | ClientType.WIN32, 75 | ), 76 | ( 77 | "lotroclient64.exe", 78 | "lotroclient.exe", 79 | "lotroclient_awesomium.exe", 80 | ClientType.WIN64, 81 | ClientType.WIN64, 82 | ), 83 | ( 84 | "lotroclient64.exe", 85 | "lotroclient.exe", 86 | "lotroclient_awesomium.exe", 87 | ClientType.WIN32, 88 | ClientType.WIN32, 89 | ), 90 | ( 91 | "lotroclient64.exe", 92 | "lotroclient.exe", 93 | "lotroclient_awesomium.exe", 94 | ClientType.WIN32_LEGACY, 95 | ClientType.WIN32_LEGACY, 96 | ), 97 | ], 98 | ) 99 | def test_get_client_filename( 100 | self, 101 | client_win64_filename: str, 102 | client_win32_filename: str, 103 | client_win32_legacy_filename: str, 104 | input_client_type: ClientType, 105 | expected_output_client_type: str, 106 | ) -> None: 107 | mock_game_launcher_config = get_mock_game_launcher_config_partial()( 108 | client_win64_filename=client_win64_filename, 109 | client_win32_filename=client_win32_filename, 110 | client_win32_legacy_filename=client_win32_legacy_filename, 111 | ) 112 | 113 | assert ( 114 | mock_game_launcher_config.get_client_filename(input_client_type)[1] 115 | == expected_output_client_type 116 | ) 117 | -------------------------------------------------------------------------------- /tests/onelauncher/test_config_manager.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | from pathlib import Path 3 | from textwrap import dedent 4 | from typing import Any 5 | 6 | import pytest 7 | import tomlkit 8 | 9 | import onelauncher.config_manager 10 | from onelauncher.config import ConfigFieldMetadata, ConfigValWithMetadata 11 | from onelauncher.program_config import ProgramConfig 12 | 13 | test_key_val_params: list[tuple[dict[str, Any], str]] = [ 14 | ({"key": "val"}, 'key = "val"\n'), 15 | ( 16 | {"key": ConfigValWithMetadata(value="val", metadata=ConfigFieldMetadata(None))}, 17 | 'key = "val"\n', 18 | ), 19 | ( 20 | { 21 | "key": ConfigValWithMetadata( 22 | value="val", metadata=ConfigFieldMetadata("Helpful words") 23 | ) 24 | }, 25 | dedent( 26 | text="""\ 27 | # Helpful words 28 | key = "val" 29 | """ 30 | ), 31 | ), 32 | ( 33 | {"key": ConfigValWithMetadata(value=None, metadata=ConfigFieldMetadata(None))}, 34 | "# key = \n", 35 | ), 36 | ( 37 | { 38 | "key": ConfigValWithMetadata( 39 | value=None, metadata=ConfigFieldMetadata("Helpful words") 40 | ) 41 | }, 42 | dedent( 43 | text="""\ 44 | # Helpful words 45 | # key = 46 | """ 47 | ), 48 | ), 49 | ] 50 | 51 | test_table_params = [ 52 | ({"table": data_dict}, f"[table]\n{final_output}") 53 | for data_dict, final_output in test_key_val_params 54 | ] 55 | 56 | test_val_types_params: list[tuple[dict[str, Any], str]] = [ 57 | ({"key": "val"}, 'key = "val"\n'), 58 | ({"key": 123}, "key = 123\n"), 59 | ({"key": 123.456}, "key = 123.456\n"), 60 | ({"key": True}, "key = true\n"), 61 | ({"key": False}, "key = false\n"), 62 | ({"key": ["a", 2, True]}, 'key = ["a", 2, true]\n'), 63 | ({"array": [1, 2]}, "array = [1, 2]\n"), 64 | ({"empty_array": []}, "empty_array = []\n"), 65 | ( 66 | { 67 | "key": datetime( 68 | year=2000, 69 | month=2, 70 | day=12, 71 | hour=2, 72 | minute=24, 73 | second=19, 74 | tzinfo=timezone(timedelta(hours=7)), 75 | ) 76 | }, 77 | "key = 2000-02-12T02:24:19+07:00\n", 78 | ), 79 | # Table 80 | ( 81 | {"table": {"key": "val"}}, 82 | dedent( 83 | text="""\ 84 | [table] 85 | key = "val" 86 | """ 87 | ), 88 | ), 89 | ({"empty_table": {}}, ""), 90 | # Table with description 91 | ( 92 | { 93 | "table": ConfigValWithMetadata( 94 | value={"key": "val"}, metadata=ConfigFieldMetadata(help="Helpful words") 95 | ) 96 | }, 97 | dedent( 98 | text="""\ 99 | [table] 100 | key = "val" 101 | """ 102 | ), 103 | ), 104 | # List of tables 105 | ( 106 | {"tables": [{"key": "value"}, {"key": "value"}]}, 107 | dedent( 108 | text="""\ 109 | [[tables]] 110 | key = "value" 111 | 112 | [[tables]] 113 | key = "value" 114 | """ 115 | ), 116 | ), 117 | ] 118 | 119 | 120 | @pytest.mark.parametrize( 121 | ("data_dict", "final_toml_output"), 122 | test_key_val_params + test_table_params + test_val_types_params, 123 | ) 124 | def test_convert_to_toml( # type: ignore[misc] 125 | data_dict: dict[str, Any | ConfigValWithMetadata], final_toml_output: str 126 | ) -> None: 127 | container = tomlkit.document() 128 | onelauncher.config_manager.convert_to_toml(data_dict=data_dict, container=container) 129 | assert container.as_string() == final_toml_output 130 | 131 | 132 | def test_allow_unknown_config_keys(tmp_path: Path) -> None: 133 | UNKNOWN_KEY_NAME = "test_unknown_key_name" 134 | assert not hasattr(ProgramConfig, UNKNOWN_KEY_NAME) 135 | 136 | config_path = tmp_path / "test_config.toml" 137 | # Initialize the config file 138 | onelauncher.config_manager.update_config_file( 139 | config=ProgramConfig(), config_file_path=config_path 140 | ) 141 | with config_path.open(mode="a") as f: 142 | f.write(f"{UNKNOWN_KEY_NAME} = 3.14") 143 | 144 | onelauncher.config_manager.read_config_file( 145 | config_class=ProgramConfig, config_file_path=config_path 146 | ) 147 | -------------------------------------------------------------------------------- /tests/onelauncher/test_mypy_plugin.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator 2 | from pathlib import Path 3 | 4 | import mypy.api 5 | import mypy.plugins.attrs 6 | import mypy.version 7 | import pytest 8 | from mypy.options import Options 9 | 10 | from onelauncher import mypy_plugin 11 | from onelauncher.config import config_field 12 | 13 | 14 | @pytest.fixture(autouse=True) 15 | def _reset_attrs_mypy_plugin() -> Generator[None, None, None]: 16 | initial_attrib_makers: set[str] = mypy.plugins.attrs.attr_attrib_makers.copy() 17 | yield 18 | mypy.plugins.attrs.attr_attrib_makers.clear() 19 | mypy.plugins.attrs.attr_attrib_makers.update(initial_attrib_makers) 20 | 21 | 22 | def test_attrs_attrib_maker_name() -> None: 23 | """ 24 | Test that the attrib maker name passed to mypy points to the right function in 25 | OneLauncher 26 | """ 27 | assert ( 28 | f"{config_field.__module__}.{config_field.__qualname__}" 29 | == mypy_plugin.ATTRS_MAKER 30 | ) 31 | 32 | 33 | def test_injecting_attrs_attrib_maker() -> None: 34 | assert mypy_plugin.ATTRS_MAKER not in mypy.plugins.attrs.attr_attrib_makers 35 | mypy_plugin.plugin(mypy.version.__version__)(Options()) 36 | assert mypy_plugin.ATTRS_MAKER in mypy.plugins.attrs.attr_attrib_makers 37 | 38 | 39 | def test_running_plugin_with_mypy() -> None: 40 | normal_report, error_report, exit_status = mypy.api.run( 41 | [str(Path(__file__).parent / "_test_mypy_plugin.mypy_test_data.py")] 42 | ) 43 | assert ( 44 | """error: Missing positional argument "x" in call to "AttrsClassUsingConfigField" [call-arg]""" 45 | in normal_report 46 | ) 47 | assert """Found 1 error in 1 file""" in normal_report 48 | -------------------------------------------------------------------------------- /tests/onelauncher/test_utilities.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | import onelauncher 8 | import onelauncher.utilities 9 | from onelauncher.utilities import CaseInsensitiveAbsolutePath 10 | 11 | 12 | class TestCaseInsensitiveAbsolutePath: 13 | def test_case_insensitive_path(self, tmp_path: Path) -> None: 14 | # These are added to a temp directory. They should not have a root! 15 | example_paths: list[tuple[str, str]] = [ 16 | ("Example/foO/bar/CAT/bees.txt", "Example/foo/bar/Cat/BEES.TXT"), 17 | ("doe/roe", "Doe/Roe"), 18 | ] 19 | 20 | for paths in example_paths: 21 | # Make real paths to check 22 | real_path = tmp_path / paths[1] 23 | real_path.parent.mkdir(parents=True) 24 | if real_path.suffix: 25 | real_path.touch() 26 | else: 27 | real_path.mkdir() 28 | 29 | assert CaseInsensitiveAbsolutePath(tmp_path / paths[0]) == real_path 30 | 31 | def test_no_matching_path(self, tmp_path: Path) -> None: 32 | """No changes are made to the path when any part of it can't be found""" 33 | (tmp_path / "afolder").mkdir() 34 | test_path = tmp_path / "AFOLDER" / "TOP_SECRET" 35 | assert (CaseInsensitiveAbsolutePath(test_path)) == (test_path) 36 | 37 | @pytest.mark.skipif( 38 | os.name == "nt", 39 | reason="Windows filesystems are case-insentive already, so there can only be one match.", 40 | ) 41 | def test_multiple_matches( 42 | self, tmp_path: Path, caplog: pytest.LogCaptureFixture 43 | ) -> None: 44 | """ 45 | Return first found when there are multiple matches of different case than the 46 | original path. 47 | """ 48 | (tmp_path / "file").touch() 49 | (tmp_path / "File").touch() 50 | assert CaseInsensitiveAbsolutePath(tmp_path / "FILE").name in ["file", "File"] 51 | assert caplog.record_tuples == [ 52 | ( 53 | onelauncher.utilities.__name__, 54 | logging.WARNING, 55 | "Multiple matches found for case-insensitive path name with no exact " 56 | "match. Using first one found.", 57 | ) 58 | ] 59 | caplog.clear() 60 | 61 | @pytest.mark.skipif( 62 | os.name == "nt", 63 | reason="Windows filesystems are case-insentive already, so there can only be one match.", 64 | ) 65 | def test_multiple_matches_with_one_exact_match( 66 | self, tmp_path: Path, caplog: pytest.LogCaptureFixture 67 | ) -> None: 68 | """Return exact match when there are multiple matches""" 69 | (tmp_path / "file").touch() 70 | (tmp_path / "File").touch() 71 | (tmp_path / "fIlE").touch() 72 | (tmp_path / "FILE").touch() 73 | assert CaseInsensitiveAbsolutePath(tmp_path / "fIlE") == tmp_path / "fIlE" 74 | assert caplog.record_tuples == [ 75 | ( 76 | onelauncher.utilities.__name__, 77 | logging.WARNING, 78 | "Multiple matches found for case-insensitive path name. One exact " 79 | "match found. Using exact match.", 80 | ) 81 | ] 82 | 83 | @pytest.mark.skipif( 84 | os.name == "nt", 85 | reason="Extra permisions are needed to make symlinks on Windows", 86 | ) 87 | def test_symlink(self, tmp_path: Path) -> None: 88 | folder = tmp_path / "folder" 89 | folder.mkdir() 90 | file = folder / "file" 91 | file.touch() 92 | (tmp_path / "EPIC FOLDER").symlink_to(folder, target_is_directory=True) 93 | assert CaseInsensitiveAbsolutePath(tmp_path / "epic folder" / "FILE").samefile( 94 | file 95 | ) 96 | 97 | @pytest.mark.skipif( 98 | os.name == "nt", 99 | reason="Extra permisions are needed to make symlinks on Windows", 100 | ) 101 | def test_broken_symlink(self, tmp_path: Path) -> None: 102 | folder = tmp_path / "folder" 103 | folder.mkdir() 104 | (tmp_path / "EPIC FOLDER").symlink_to(folder, target_is_directory=True) 105 | # Remove original folder, so symlink will be broken 106 | folder.rmdir() 107 | # Path stays the same, treated just like any other path that can't be found. 108 | # No error b/c of encountering the broken symlink 109 | assert ( 110 | CaseInsensitiveAbsolutePath(tmp_path / "epic folder" / "FILE") 111 | == tmp_path / "epic folder" / "FILE" 112 | ) 113 | 114 | def test_file_in_path(self, tmp_path: Path) -> None: 115 | """ 116 | Return original path when a match before the final path component 117 | is a file rather than folder 118 | """ 119 | (tmp_path / "shhiamsofolder").touch() 120 | test_path = tmp_path / "shhiamsofolder" / "place" 121 | assert CaseInsensitiveAbsolutePath(test_path) == test_path 122 | 123 | def test_home(self) -> None: 124 | assert CaseInsensitiveAbsolutePath.home() == Path.home() 125 | 126 | def test_case_insensitive_glob(self, tmp_path: Path) -> None: 127 | file_path = tmp_path / "FiLe23.TXT" 128 | file_path.touch() 129 | assert ( 130 | next(CaseInsensitiveAbsolutePath(tmp_path).glob("filE*.txt")) == file_path 131 | ) 132 | 133 | def test_case_insensitive_rglob(self, tmp_path: Path) -> None: 134 | (tmp_path / "folder").mkdir() 135 | file_path = tmp_path / "folder" / "FiLe24.TXT" 136 | file_path.touch() 137 | assert ( 138 | next(CaseInsensitiveAbsolutePath(tmp_path).rglob("filE*.txt")) == file_path 139 | ) 140 | 141 | def test_truediv_returns_case_insensitive_path(self, tmp_path: Path) -> None: 142 | (tmp_path / "exists").touch() 143 | assert isinstance( 144 | (CaseInsensitiveAbsolutePath(tmp_path) / "exists"), 145 | CaseInsensitiveAbsolutePath, 146 | ) 147 | assert isinstance( 148 | (CaseInsensitiveAbsolutePath(tmp_path) / "does_not_exist"), 149 | CaseInsensitiveAbsolutePath, 150 | ) 151 | 152 | def test_relative_to(self, tmp_path: Path) -> None: 153 | assert CaseInsensitiveAbsolutePath(tmp_path / "somepath").relative_to( 154 | CaseInsensitiveAbsolutePath(tmp_path) 155 | ) == Path("somepath") 156 | 157 | def test_relative_to_is_normal_path(self, tmp_path: Path) -> None: 158 | """ 159 | `PurePath.relative_to` by its nature generates non-absolute paths. 160 | Thus, `CaseInsenstivieAbsolutePath.relative_to` should a regular path 161 | """ 162 | relative_to_path = CaseInsensitiveAbsolutePath( 163 | tmp_path / "somepath" 164 | ).relative_to(CaseInsensitiveAbsolutePath(tmp_path)) 165 | assert isinstance(relative_to_path, Path) 166 | assert not isinstance(relative_to_path, CaseInsensitiveAbsolutePath) 167 | --------------------------------------------------------------------------------