├── .flake8 ├── .flake8.soft ├── .github ├── pull_request_template.md └── workflows │ ├── changelogtest.yml │ ├── pythontests.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── THIRD-PARTY-LICENSES.md ├── _config.yml ├── bin └── minigalaxy ├── data ├── icons │ ├── 128x128 │ │ └── io.github.sharkwouter.Minigalaxy.png │ └── 192x192 │ │ └── io.github.sharkwouter.Minigalaxy.png ├── images │ └── winehq_logo_glass.png ├── io.github.sharkwouter.Minigalaxy.desktop ├── io.github.sharkwouter.Minigalaxy.metainfo.xml ├── po │ ├── cs_CZ.po │ ├── de.po │ ├── el.po │ ├── es_AR.po │ ├── es_ES.po │ ├── fi.po │ ├── fr.po │ ├── it_IT.po │ ├── minigalaxy.pot │ ├── nb_NO.po │ ├── nl.po │ ├── nn_NO.po │ ├── pl.po │ ├── pt_BR.po │ ├── pt_PT.po │ ├── ro.po │ ├── ru_RU.po │ ├── sv_SE.po │ ├── tr.po │ ├── uk.po │ ├── zh_CN.po │ └── zh_TW.po ├── style.css ├── ui │ ├── about.ui │ ├── application.ui │ ├── categoryfilters.ui │ ├── download_action_buttons.ui │ ├── download_list.ui │ ├── download_list_entry.ui │ ├── filterswitch.ui │ ├── gametile.ui │ ├── gametilelist.ui │ ├── information.ui │ ├── library.ui │ ├── login.ui │ ├── preferences.ui │ └── properties.ui └── wine_resources │ └── disable_menubuilder.reg ├── debian ├── changelog ├── clean ├── control ├── copyright ├── minigalaxy.docs ├── minigalaxy.manpages ├── rules └── source │ └── format ├── minigalaxy ├── __init__.py ├── api.py ├── config.py ├── constants.py ├── css.py ├── download.py ├── download_manager.py ├── entity │ ├── __init__.py │ └── state.py ├── file_info.py ├── game.py ├── installer.py ├── launcher.py ├── logger.py ├── paths.py ├── translation.py ├── ui │ ├── __init__.py │ ├── about.py │ ├── categoryfilters.py │ ├── download_list.py │ ├── filterswitch.py │ ├── gametile.py │ ├── gametilelist.py │ ├── gtk.py │ ├── information.py │ ├── library.py │ ├── library_entry.py │ ├── login.py │ ├── preferences.py │ ├── properties.py │ ├── webkit.py │ └── window.py └── version.py ├── pyproject.toml ├── requirements-testing.txt ├── requirements.txt ├── screenshot.jpg ├── scripts ├── add-language.sh ├── check-changelog.sh ├── compile-translations.sh ├── create-release.sh ├── missing-translations.sh ├── sort-releases.xls ├── take-screenshot.sh └── update-translation-files.sh ├── setup.py └── tests ├── __init__.py ├── test_api.py ├── test_config.py ├── test_download.py ├── test_download_manager.py ├── test_game.py ├── test_installer.py ├── test_installer_queue.py ├── test_launcher.py ├── test_ui_library.py └── test_ui_window.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-complexity = 10 3 | max-line-length = 127 4 | 5 | exclude = 6 | .git, 7 | build, 8 | data, 9 | debian, 10 | dist, 11 | *.egg-info, 12 | site-packages 13 | -------------------------------------------------------------------------------- /.flake8.soft: -------------------------------------------------------------------------------- 1 | [flake8] 2 | per-file-ignores = 3 | # Because of their nature, tests have a lot of long strings 4 | # Perhaps the situation can be improved in the future 5 | tests/*: E501 6 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | ## Description 3 | 4 | 5 | 6 | ## Checklist 7 | 8 | - [ ] _CHANGELOG.md_ was updated (**format**: - Change made (thanks to github_username)) 9 | -------------------------------------------------------------------------------- /.github/workflows/changelogtest.yml: -------------------------------------------------------------------------------- 1 | name: Changelog test 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Check changelog format 13 | run: | 14 | ./scripts/check-changelog.sh 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/pythontests.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 3.8 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: 3.8 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install -r requirements-testing.txt 20 | - name: Lint with flake8 21 | run: | 22 | flake8 . --append-config=.flake8.soft --count --show-source --statistics 23 | - name: Lint with flake8 (strict, but non-fatal) 24 | run: | 25 | flake8 . --count --show-source --statistics --exit-zero 26 | - name: Run unit tests 27 | run: | 28 | python3 -m coverage run --source minigalaxy -m unittest discover -v tests && python3 -m coverage report -m 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Install dependencies 11 | run: | 12 | sudo apt-get update 13 | sudo apt-get install -y debhelper dh-python python3-all python3-setuptools help2man devscripts gettext lsb-release xmlstarlet git build-essential 14 | - uses: actions/checkout@v4 15 | with: 16 | ref: master 17 | - name: Prepare release files 18 | id: tag 19 | run: | 20 | ./scripts/create-release.sh 21 | env: 22 | DEBFULLNAME: ${{ secrets.DEBFULLNAME }} 23 | DEBEMAIL: ${{ secrets.DEBEMAIL }} 24 | - name: Build deb package 25 | run: | 26 | dpkg-buildpackage -us -uc 27 | - name: Commit changes 28 | run: | 29 | git config --global user.name 'Wouter Wijsman' 30 | git config --global user.email 'sharkwouter@users.noreply.github.com' 31 | git add pyproject.toml data/io.github.sharkwouter.Minigalaxy.metainfo.xml debian/changelog minigalaxy/version.py 32 | git commit -m "Add new release" 33 | git push 34 | - name: Release 35 | uses: softprops/action-gh-release@v2 36 | with: 37 | tag_name: ${{ steps.tag.outputs.VERSION }} 38 | name: Minigalaxy version ${{ steps.tag.outputs.VERSION }} 39 | body_path: release.md 40 | prerelease: true 41 | files: | 42 | ../minigalaxy_*.deb 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__/ 3 | dist/ 4 | .idea/ 5 | *~ 6 | *.pyc 7 | *.egg-info/ 8 | *.swp 9 | .eggs/ 10 | data/mo/ 11 | data/po/*.mo 12 | .project 13 | .pydevproject 14 | release.md 15 | .coverage 16 | 17 | # Files generated when building the deb package 18 | .pybuild/ 19 | build/ 20 | debian/minigalaxy.6 21 | debian/files 22 | debian/.debhelper/ 23 | debian/debhelper-build-stamp 24 | debian/minigalaxy/ 25 | debian/minigalaxy.substvars 26 | debian/minigalaxy.debhelper.log 27 | debian/minigalaxy.postinst.debhelper 28 | debian/minigalaxy.prerm.debhelper 29 | .vscode/settings.json 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minigalaxy 2 | 3 | A simple GOG client for Linux. 4 | 5 | ![screenshot](screenshot.jpg?raw=true) 6 | 7 | ## Features 8 | 9 | The most important features of Minigalaxy: 10 | 11 | - Log in with your GOG account 12 | - Download the Linux games you own on GOG 13 | - Launch them! 14 | 15 | In addition to that, Minigalaxy also allows you to: 16 | 17 | - Update your games 18 | - Install and update DLC 19 | - Select which language you'd prefer to download your games in 20 | - Change where games are installed 21 | - Search your GOG Linux library 22 | - Show all games or just the ones you've installed 23 | - View the error message if a game fails to launch 24 | - Enable displaying the FPS in games 25 | - Use the system's ScummVM or DOSBox installation 26 | - Install Windows games using Wine 27 | 28 | ### Backwards compatibility 29 | Minigalaxy version 1.3.2 and higher change some aspects of windows game installations through wine. 30 | It will try to adapt already installed games to the new concept when launched through Minigalaxy. 31 | 32 | The windows installer in wine now uses a 2-step attempt to install games. 33 | 1. An unattended installer. 34 | 2. In case this fails, the regular installation wizard will open. **Please do not change** the 35 | install directory 'c:\game' given in the wizard as this an elementary part of the wine fix. 36 | 37 | ## Supported languages 38 | 39 | Currently, Minigalaxy can be displayed in the following languages: 40 | 41 | - Brazilian Portuguese 42 | - Czech 43 | - English 44 | - Dutch 45 | - French 46 | - Finnish 47 | - German 48 | - Italian 49 | - Norwegian Bokmål 50 | - Norwegian Nynorsk 51 | - Polish 52 | - Portuguese 53 | - Russian 54 | - Simplified Chinese 55 | - Spanish 56 | - Swedish 57 | - Taiwanese Mandarin 58 | - Turkish 59 | - Ukranian 60 | 61 | ## System requirements 62 | 63 | Minigalaxy should work on the following distributions: 64 | 65 | - Debian 10 or newer 66 | - Ubuntu 18.10 or newer 67 | - Linux Mint 20 or newer 68 | - Arch Linux 69 | - Manjaro 70 | - Fedora Linux 31 or newer 71 | - openSUSE Tumbleweed and Leap 15.2 or newer 72 | - MX Linux 19 or newer 73 | - Solus 74 | - Void Linux 75 | 76 | Minigalaxy does **not** ship for the following distributions because they do not contain the required version of PyGObject: 77 | 78 | - Ubuntu 18.04 79 | - Linux Mint 19.3 80 | - openSUSE 15.1 81 | 82 | Other Linux distributions may work as well. Minigalaxy requires the following dependencies: 83 | 84 | - GTK+ 85 | - Python 3 86 | - PyGObject 3.29.1+ 87 | - Webkit2gtk with API version 4.0 support 88 | - Python Requests 89 | - gettext 90 | 91 | ## Installation 92 | 93 | 94 | Packaging status 95 | 96 | 97 |
Debian/Ubuntu 98 | 99 | Available in the official repositories since Debian 11 and Ubuntu 21.04. You can install it with: 100 |
101 | sudo apt install minigalaxy
102 | 
103 | 104 | You can also download the latest .deb package from the releases page and install it that way. 105 |
106 | 107 |
Arch/Manjaro 108 | 109 | Available the AUR. You can use an AUR helper or use the following set of commands to install Minigalaxy on Arch: 110 |
111 | git clone https://aur.archlinux.org/minigalaxy.git
112 | cd minigalaxy
113 | makepkg -si
114 | 
115 |
116 | 117 |
Fedora 118 | 119 | Available in the official repositories since Fedora 31. You can install it with: 120 |
121 | sudo dnf install minigalaxy
122 | 
123 |
124 | 125 |
openSUSE 126 | 127 | Available in the official repositories for openSUSE Tumbleweed and also Leap since 15.2. You can install it with: 128 |
129 | sudo zypper in minigalaxy
130 | 
131 | 132 | Alternatively, you can use the following set of commands to install Minigalaxy on openSUSE from the devel project on OBS: 133 |
134 | sudo zypper ar -f obs://games:tools gamestools
135 | sudo zypper ref
136 | sudo zypper in minigalaxy
137 | 
138 |
139 | 140 |
MX Linux 141 | 142 | Available in the official repository. Please use MX Package Installer or Synaptic instead of manually installing the .deb from the repo. 143 |
144 | 145 |
Solus 146 | 147 | Available in the official repositories. You can install it with: 148 |
149 | sudo eopkg it minigalaxy
150 | 
151 |
152 | 153 |
Void Linux 154 | 155 | Available in the official repositories. You can install it with: 156 |
157 | sudo xbps-install -S minigalaxy
158 | 
159 |
160 | 161 |
Flatpak 162 | 163 | Available on Flathub. You can install it with: 164 |
165 | flatpak install flathub io.github.sharkwouter.Minigalaxy
166 | 
167 |
168 | 169 |
Other distributions 170 | 171 | On other distributions, Minigalaxy can be downloaded and started with the following commands: 172 |
173 | git clone https://github.com/sharkwouter/minigalaxy.git
174 | cd minigalaxy
175 | scripts/compile-translations.sh
176 | bin/minigalaxy
177 | 
178 | 179 | This will be the development version. Alternatively, a tarball of a specific release can be downloaded from the releases page. 180 |
181 | 182 | ## Support 183 | 184 | If you need any help using Minigalaxy, feel free to join the [Minigalaxy Discord server](https://discord.gg/RC4cXVD). 185 | Bugs reports and feature requests can also be made [here](https://github.com/sharkwouter/minigalaxy/issues). 186 | 187 | ## Contribute 188 | 189 | Currently, help is needed with the following: 190 | 191 | - Reporting bugs in the [issue tracker](https://github.com/sharkwouter/minigalaxy/issues). 192 | - Translating to different languages on [Weblate](https://hosted.weblate.org/projects/minigalaxy/minigalaxy/). 193 | - Testing issues with the ["needs testing"](https://github.com/sharkwouter/minigalaxy/issues?q=is%3Aissue+is%3Aopen+label%3A%22needs+testing%22) tag. 194 | - Working on or giving input on issues with the ["help wanted"](https://github.com/sharkwouter/minigalaxy/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) or ["good first issue"](https://github.com/sharkwouter/minigalaxy/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Also check out the [the wiki](https://github.com/sharkwouter/minigalaxy/wiki/Developer-information) for developer information. 195 | 196 | Feel free to join the [Minigalaxy Discord](https://discord.gg/RC4cXVD) if you would like to help out. 197 | 198 | ## Other GOG tools 199 | 200 | - [LGOGDownloader](https://sites.google.com/site/gogdownloader/), a GOG client for the command line 201 | 202 | ## Special thanks 203 | 204 | Special thanks goes out to all contributors: 205 | 206 | - makson96 for multiple code contributions 207 | - Odelpasso for multiple code contributions 208 | - TotalCaesar659 for multiple code contributions 209 | - SvdB-nonp for multiple code contributions 210 | - tim77 for packaging Minigalaxy for Fedora, Flathub and multiple code contributions 211 | - larslindq for multiple code contributions 212 | - graag for multiple code contributions 213 | - lmeunier for multiple code contributions 214 | - BlindJerobine for translating to German and adding the support option 215 | - zweif contributions to code and the German translation 216 | - JoshuaFern for packaging Minigalaxy for NixOS and for contributing code 217 | - stephanlachnit for upstreaming to Debian and multiple code contributions 218 | - sgn for fixing a bug 219 | - otaconix for fixing a bug 220 | - phlash for fixing a bug 221 | - mareksapota for fixing a bug 222 | - zocker-160 for code cleanup 223 | - waltercool for contributing code 224 | - jgerrish for improving the download code 225 | - LexofLeviafan for fixing a bug 226 | - orende for contributing code 227 | - Unrud for contributing code 228 | - slowsage for contributing code 229 | - viacheslavka for contributing code 230 | - GB609 for contributing code 231 | - s8321414 for translating to Taiwanese Mandarin 232 | - fuzunspm for translating to Turkish 233 | - thomansb22 for translating to French 234 | - ArturWroblewski for translating to Polish 235 | - kimmalmo for translating to Norwegian Bokmål 236 | - EsdrasTarsis for translating to Brazilian Portuguese 237 | - protheory8 for translating to Russian 238 | - LordPilum for translating to Norwegian Nynorsk 239 | - dummyx for translating to simplified Chinese 240 | - juanborda for translating to Spanish 241 | - advy99i for translating to Spanish 242 | - LocalPinkRobin for translating to Spanish 243 | - Newbytee for translating to Swedish 244 | - Pyrofanis for translating to Greek 245 | - mbarrio for translating to Spanish 246 | - manurtinez for translating to Spanish 247 | - GLSWV for translating to Portuguese 248 | - jubalh for packaging Minigalaxy for openSUSE 249 | - gasinvein for packaging Minigalaxy for flathub 250 | - metafarion for packaging Minigalaxy for Gentoo early on 251 | - SwampRabbit and Steven Pusser for packaging Minigalaxy for MX Linux 252 | - karaushu for translating to Ukrainian 253 | - koraynilay for translating to Italian 254 | - heidiwenger and jonnelafin for translating to Finnish 255 | - jakbuz23 for translating to Czech 256 | -------------------------------------------------------------------------------- /THIRD-PARTY-LICENSES.md: -------------------------------------------------------------------------------- 1 | ## Logo image (data/minigalaxy.png) 2 | 3 | **[Copyright 2014 Epic Runes](https://opengameart.org/users/epic-runes)** 4 | 5 | You are free to: Share :copy and redistribute the material in any medium or format Adapt :remix, transform, and build upon the material for any purpose, even commercially. The licensor cannot revoke these freedoms as long as you follow the license terms. Under the following terms: Attribution :You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. 6 | 7 | Full license text: https://creativecommons.org/licenses/by/3.0/ 8 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-primer 2 | title: Minigalaxy 3 | description: A simple GOG client for Linux that lets you download and play your GOG Linux games 4 | -------------------------------------------------------------------------------- /bin/minigalaxy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import platform 3 | import sys 4 | import os 5 | import argparse 6 | import shutil 7 | from os.path import realpath, dirname, normpath 8 | 9 | import requests 10 | 11 | APPLICATION_NAME = "Minigalaxy" 12 | 13 | LAUNCH_PATH = dirname(realpath(__file__)) 14 | if os.path.isdir(os.path.join(LAUNCH_PATH, "../minigalaxy")): 15 | SOURCE_PATH = normpath(os.path.join(LAUNCH_PATH, '..')) 16 | sys.path.insert(0, SOURCE_PATH) 17 | os.chdir(SOURCE_PATH) 18 | 19 | from minigalaxy.version import VERSION 20 | from minigalaxy.paths import CONFIG_DIR, CACHE_DIR 21 | 22 | 23 | def conf_reset(): 24 | shutil.rmtree(CONFIG_DIR, ignore_errors=True) 25 | shutil.rmtree(CACHE_DIR, ignore_errors=True) 26 | 27 | 28 | def cli_params(): 29 | parser = argparse.ArgumentParser(description="A simple GOG Linux client") 30 | 31 | parser.add_argument("--reset", 32 | dest="reset", action="store_true", 33 | help="reset the configuration of Minigalaxy") 34 | parser.add_argument("-v", "--version", 35 | action="version", version=VERSION) 36 | 37 | return parser.parse_args() 38 | 39 | 40 | def main(): 41 | cli_args = cli_params() 42 | 43 | if cli_args.reset: conf_reset() 44 | 45 | # Import the gi module after parsing arguments 46 | from minigalaxy.ui.gtk import Gtk 47 | from minigalaxy.ui import Window 48 | from minigalaxy.config import Config 49 | from minigalaxy.api import Api 50 | from minigalaxy.download_manager import DownloadManager 51 | from minigalaxy.css import load_css 52 | 53 | # Start the application 54 | load_css() 55 | config = Config() 56 | session = requests.Session() 57 | session.headers.update({'User-Agent': 'Minigalaxy/{} (Linux {})'.format(VERSION, platform.machine())}) 58 | api = Api(config, session) 59 | download_manager = DownloadManager(session, config) 60 | window = Window(config, api, download_manager, APPLICATION_NAME) 61 | window.connect("destroy", Gtk.main_quit) 62 | Gtk.main() 63 | 64 | 65 | if __name__ == "__main__": 66 | main() 67 | -------------------------------------------------------------------------------- /data/icons/128x128/io.github.sharkwouter.Minigalaxy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharkwouter/minigalaxy/d2d5c349094a88fc821ff1ed4ec59f359bc2f63c/data/icons/128x128/io.github.sharkwouter.Minigalaxy.png -------------------------------------------------------------------------------- /data/icons/192x192/io.github.sharkwouter.Minigalaxy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharkwouter/minigalaxy/d2d5c349094a88fc821ff1ed4ec59f359bc2f63c/data/icons/192x192/io.github.sharkwouter.Minigalaxy.png -------------------------------------------------------------------------------- /data/images/winehq_logo_glass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharkwouter/minigalaxy/d2d5c349094a88fc821ff1ed4ec59f359bc2f63c/data/images/winehq_logo_glass.png -------------------------------------------------------------------------------- /data/io.github.sharkwouter.Minigalaxy.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Minigalaxy 3 | GenericName=GOG client 4 | Comment=A simple GOG Linux client 5 | GenericName[ru]=Клиент GOG 6 | Comment[ru]=Простой клиент GOG для Linux 7 | Exec=minigalaxy 8 | Icon=io.github.sharkwouter.Minigalaxy 9 | Type=Application 10 | Keywords=galaxy;gaming;games;game;old;good;gog; 11 | Categories=Game; 12 | StartupWMClass=minigalaxy 13 | -------------------------------------------------------------------------------- /data/style.css: -------------------------------------------------------------------------------- 1 | .test { 2 | border: 1px solid green; 3 | color: red; 4 | background-color: purple; 5 | } 6 | 7 | #gametile .button-left { 8 | border-top-left-radius: 0px; 9 | border-top-right-radius: 0px; 10 | border-bottom-right-radius: 0px; 11 | border-right-width: 0px; 12 | margin-right: 0px; 13 | } 14 | #gametile .button-right { 15 | margin-left: 0px; 16 | border-top-left-radius: 0px; 17 | border-top-right-radius: 0px; 18 | border-bottom-left-radius: 0px; 19 | } 20 | #gametile .progress-bar * { 21 | border-radius: 0px; 22 | } -------------------------------------------------------------------------------- /data/ui/about.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 93 | 94 | -------------------------------------------------------------------------------- /data/ui/application.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | False 7 | 8 | 9 | 10 | 11 | 12 | False 13 | 14 | 15 | True 16 | False 17 | vertical 18 | 19 | 20 | True 21 | True 22 | True 23 | Logout 24 | True 25 | 26 | 27 | 28 | False 29 | True 30 | 0 31 | 32 | 33 | 34 | 35 | True 36 | True 37 | True 38 | Preferences 39 | True 40 | 41 | 42 | 43 | False 44 | True 45 | 1 46 | 47 | 48 | 49 | 50 | True 51 | False 52 | 53 | 54 | False 55 | True 56 | 2 57 | 58 | 59 | 60 | 61 | True 62 | True 63 | True 64 | About 65 | True 66 | 67 | 68 | 69 | False 70 | True 71 | 3 72 | 73 | 74 | 75 | 76 | 77 | 223 | 224 | -------------------------------------------------------------------------------- /data/ui/categoryfilters.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 86 | 87 | -------------------------------------------------------------------------------- /data/ui/download_action_buttons.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 64 | 65 | -------------------------------------------------------------------------------- /data/ui/download_list_entry.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 105 | 106 | -------------------------------------------------------------------------------- /data/ui/filterswitch.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 32 | -------------------------------------------------------------------------------- /data/ui/information.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 194 | 195 | -------------------------------------------------------------------------------- /data/ui/library.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 23 | 24 | -------------------------------------------------------------------------------- /data/ui/login.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 42 | 43 | -------------------------------------------------------------------------------- /data/wine_resources/disable_menubuilder.reg: -------------------------------------------------------------------------------- 1 | REGEDIT4 2 | 3 | [HKEY_CURRENT_USER\Software\Wine\DllOverrides] 4 | "winemenubuilder.exe"="d" 5 | 6 | -------------------------------------------------------------------------------- /debian/clean: -------------------------------------------------------------------------------- 1 | minigalaxy.egg-info/ 2 | minigalaxy/__pycache__/ 3 | data/mo/ 4 | debian/minigalaxy.6 5 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: minigalaxy 2 | Section: games 3 | Priority: optional 4 | Maintainer: Wouter Wijsman 5 | Build-Depends: debhelper-compat (= 12), 6 | dh-sequence-python3, 7 | python3-all, 8 | python3-setuptools, 9 | python3-requests, 10 | help2man, 11 | gettext, 12 | Standards-Version: 4.5.0 13 | Rules-Requires-Root: no 14 | Vcs-Git: https://github.com/sharkwouter/minigalaxy.git 15 | Vcs-Browser: https://github.com/sharkwouter/minigalaxy 16 | Homepage: https://sharkwouter.github.io/minigalaxy/ 17 | 18 | Package: minigalaxy 19 | Architecture: all 20 | Depends: ${misc:Depends}, 21 | ${python3:Depends}, 22 | python3-requests, 23 | python3-gi (>= 3.29.1), 24 | python3-gi-cairo, 25 | gir1.2-gtk-3.0, 26 | gir1.2-glib-2.0, 27 | gir1.2-gdkpixbuf-2.0, 28 | gir1.2-notify-0.7, 29 | gir1.2-webkit2-4.1, 30 | unzip, 31 | xdg-utils, 32 | unrar-free (>= 1:0.1.0) | unrar, 33 | Suggests: dosbox, 34 | scummvm, 35 | wine32 | wine32-development | wine-stable-i386 | wine-devel-i386 | wine-staging-i386, 36 | innoextract, 37 | Description: Simple GOG Linux client 38 | Allows you to download and play Linux games from the gog.com game store. 39 | . 40 | Besides installing games, it offers the following feature: 41 | - Update your games 42 | - Install and update DLC 43 | - Select in which language you'd prefer to download your games 44 | - Change where games are installed 45 | - Search your GOG Linux library 46 | - Show all games or just the ones you've installed 47 | - View the error message if a game fails to launch 48 | . 49 | A GOG account is required to use this software. 50 | -------------------------------------------------------------------------------- /debian/minigalaxy.docs: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /debian/minigalaxy.manpages: -------------------------------------------------------------------------------- 1 | debian/minigalaxy.6 -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | #export DH_VERBOSE = 1 3 | export PYBUILD_DISABLE=test 4 | export PYBUILD_NAME=minigalaxy 5 | export PYBUILD_INSTALL_ARGS_python3=--install-scripts=/usr/games/ 6 | 7 | %: 8 | dh $@ --buildsystem=pybuild 9 | 10 | override_dh_auto_build: 11 | help2man -N -s 6 -n "a simple GTK based GOG Linux client" bin/minigalaxy > debian/minigalaxy.6 12 | dh_auto_build 13 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /minigalaxy/__init__.py: -------------------------------------------------------------------------------- 1 | """ MiniGalaxy packages """ 2 | -------------------------------------------------------------------------------- /minigalaxy/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from typing import List 4 | 5 | from minigalaxy.logger import logger 6 | from minigalaxy.paths import CONFIG_FILE_PATH, DEFAULT_INSTALL_DIR 7 | 8 | # Moved from constants.py to here because of circular import between translations, config and constants 9 | # UI download threads are for UI assets like thumbnails or icons 10 | UI_DOWNLOAD_THREADS = 4 11 | # Game download threads are for long-running downloads like games, DLC or updates 12 | DEFAULT_DOWNLOAD_THREAD_COUNT = 4 13 | 14 | 15 | class Config: 16 | 17 | __config_file: str 18 | __config: dict 19 | 20 | def __init__(self, config_file: str = CONFIG_FILE_PATH): 21 | self.__config_file = config_file 22 | self.__config = {} 23 | self.__load() 24 | 25 | def __load(self) -> None: 26 | if os.path.isfile(self.__config_file): 27 | with open(self.__config_file, "r") as file: 28 | try: 29 | self.__config = json.loads(file.read()) 30 | except (json.decoder.JSONDecodeError, UnicodeDecodeError): 31 | logger.warning("Reading config.json failed, creating new config file.") 32 | os.remove(self.__config_file) 33 | 34 | def __write(self) -> None: 35 | if not os.path.isfile(self.__config_file): 36 | config_dir = os.path.dirname(self.__config_file) 37 | os.makedirs(config_dir, mode=0o700, exist_ok=True) 38 | temp_file = f"{self.__config_file}.tmp" 39 | with open(temp_file, "w") as file: 40 | file.write(json.dumps(self.__config, ensure_ascii=False)) 41 | os.rename(temp_file, self.__config_file) 42 | 43 | @property 44 | def locale(self) -> str: 45 | return self.__config.get("locale", "") 46 | 47 | @locale.setter 48 | def locale(self, new_value: str) -> None: 49 | self.__config["locale"] = new_value 50 | self.__write() 51 | 52 | @property 53 | def lang(self) -> str: 54 | return self.__config.get("lang", "en") 55 | 56 | @lang.setter 57 | def lang(self, new_value: str) -> None: 58 | self.__config["lang"] = new_value 59 | self.__write() 60 | 61 | @property 62 | def view(self) -> str: 63 | return self.__config.get("view", "grid") 64 | 65 | @view.setter 66 | def view(self, new_value: str) -> None: 67 | self.__config["view"] = new_value 68 | self.__write() 69 | 70 | @property 71 | def install_dir(self) -> str: 72 | return self.__config.get("install_dir", DEFAULT_INSTALL_DIR) 73 | 74 | @install_dir.setter 75 | def install_dir(self, new_value: str) -> None: 76 | self.__config["install_dir"] = new_value 77 | self.__write() 78 | 79 | @property 80 | def username(self) -> str: 81 | return self.__config.get("username", "") 82 | 83 | @username.setter 84 | def username(self, new_value: str) -> None: 85 | self.__config["username"] = new_value 86 | self.__write() 87 | 88 | @property 89 | def refresh_token(self) -> str: 90 | return self.__config.get("refresh_token", "") 91 | 92 | @refresh_token.setter 93 | def refresh_token(self, new_value: str) -> None: 94 | self.__config["refresh_token"] = new_value 95 | self.__write() 96 | 97 | @property 98 | def keep_installers(self) -> bool: 99 | return self.__config.get("keep_installers", False) 100 | 101 | @keep_installers.setter 102 | def keep_installers(self, new_value: bool) -> None: 103 | self.__config["keep_installers"] = new_value 104 | self.__write() 105 | 106 | @property 107 | def stay_logged_in(self) -> bool: 108 | return self.__config.get("stay_logged_in", True) 109 | 110 | @stay_logged_in.setter 111 | def stay_logged_in(self, new_value: bool) -> None: 112 | self.__config["stay_logged_in"] = new_value 113 | self.__write() 114 | 115 | @property 116 | def use_dark_theme(self) -> bool: 117 | return self.__config.get("use_dark_theme", False) 118 | 119 | @use_dark_theme.setter 120 | def use_dark_theme(self, new_value: bool) -> None: 121 | self.__config["use_dark_theme"] = new_value 122 | self.__write() 123 | 124 | @property 125 | def show_hidden_games(self) -> bool: 126 | return self.__config.get("show_hidden_games", False) 127 | 128 | @show_hidden_games.setter 129 | def show_hidden_games(self, new_value: bool) -> None: 130 | self.__config["show_hidden_games"] = new_value 131 | self.__write() 132 | 133 | @property 134 | def show_windows_games(self) -> bool: 135 | return self.__config.get("show_windows_games", False) 136 | 137 | @show_windows_games.setter 138 | def show_windows_games(self, new_value: bool) -> None: 139 | self.__config["show_windows_games"] = new_value 140 | self.__write() 141 | 142 | @property 143 | def keep_window_maximized(self) -> bool: 144 | return self.__config.get("keep_window_maximized", False) 145 | 146 | @keep_window_maximized.setter 147 | def keep_window_maximized(self, new_value: bool) -> None: 148 | self.__config["keep_window_maximized"] = new_value 149 | self.__write() 150 | 151 | @property 152 | def installed_filter(self) -> bool: 153 | return self.__config.get("installed_filter", False) 154 | 155 | @installed_filter.setter 156 | def installed_filter(self, new_value: bool) -> None: 157 | self.__config["installed_filter"] = new_value 158 | self.__write() 159 | 160 | @property 161 | def create_applications_file(self) -> bool: 162 | return self.__config.get("create_applications_file", False) 163 | 164 | @create_applications_file.setter 165 | def create_applications_file(self, new_value: bool) -> None: 166 | self.__config["create_applications_file"] = new_value 167 | self.__write() 168 | 169 | @property 170 | def max_parallel_game_downloads(self) -> int: 171 | return self.__config.get("max_download_workers", DEFAULT_DOWNLOAD_THREAD_COUNT) 172 | 173 | @max_parallel_game_downloads.setter 174 | def max_parallel_game_downloads(self, new_value: int) -> None: 175 | self.__config["max_download_workers"] = new_value 176 | self.__write() 177 | 178 | @property 179 | def current_downloads(self) -> List[int]: 180 | return self.__config.get("current_downloads", []) 181 | 182 | @current_downloads.setter 183 | def current_downloads(self, new_value: List[int]) -> None: 184 | self.__config["current_downloads"] = new_value 185 | self.__write() 186 | 187 | def add_ongoing_download(self, download_id): 188 | '''Adds the given id to the list of active downloads if not contained already. Does nothing otherwise.''' 189 | current = self.current_downloads 190 | if download_id not in current: 191 | current.append(download_id) 192 | self.current_downloads = current 193 | 194 | def remove_ongoing_download(self, download_id): 195 | '''Removes the given id from the list of active downloads, if contained. Does nothing otherwise.''' 196 | current = self.current_downloads 197 | if download_id in current: 198 | current.remove(download_id) 199 | self.current_downloads = current 200 | 201 | @property 202 | def paused_downloads(self) -> List[int]: 203 | return self.__config.get("paused_downloads", {}) 204 | 205 | @paused_downloads.setter 206 | def paused_downloads(self, new_value: {}) -> None: 207 | self.__config["paused_downloads"] = new_value 208 | self.__write() 209 | 210 | def add_paused_download(self, save_location, current_progress): 211 | paused = self.paused_downloads 212 | paused[save_location] = current_progress 213 | self.paused_downloads = paused 214 | self.__write() 215 | 216 | def remove_paused_download(self, save_location): 217 | paused = self.paused_downloads 218 | if save_location in paused: 219 | del paused[save_location] 220 | self.paused_downloads = paused 221 | self.__write() 222 | -------------------------------------------------------------------------------- /minigalaxy/constants.py: -------------------------------------------------------------------------------- 1 | from minigalaxy.translation import _ 2 | 3 | SUPPORTED_DOWNLOAD_LANGUAGES = [ 4 | ["br", _("Brazilian Portuguese")], 5 | ["cn", _("Chinese")], 6 | ["da", _("Danish")], 7 | ["nl", _("Dutch")], 8 | ["en", _("English")], 9 | ["fi", _("Finnish")], 10 | ["fr", _("French")], 11 | ["de", _("German")], 12 | ["hu", _("Hungarian")], 13 | ["it", _("Italian")], 14 | ["jp", _("Japanese")], 15 | ["ko", _("Korean")], 16 | ["no", _("Norwegian")], 17 | ["pl", _("Polish")], 18 | ["pt", _("Portuguese")], 19 | ["ru", _("Russian")], 20 | ["es", _("Spanish")], 21 | ["sv", _("Swedish")], 22 | ["tr", _("Turkish")], 23 | ["ro", _("Romanian")], 24 | ] 25 | 26 | # match locale ids to special language names used by some installers 27 | # mapping supports 1:n so we can add more than one per language if needed later 28 | GAME_LANGUAGE_IDS = { 29 | "br": ["brazilian"], 30 | "cn": ["chinese"], 31 | "da": ["danish"], 32 | "nl": ["dutch"], 33 | "en": ["english"], 34 | "fi": ["finnish"], 35 | "fr": ["french"], 36 | "de": ["german"], 37 | "hu": ["hungarian"], 38 | "it": ["italian"], 39 | "jp": ["japanese"], 40 | "ko": ["korean"], 41 | "no": ["norwegian"], 42 | "pl": ["polish"], 43 | "pt": ["portuguese"], 44 | "ru": ["russian"], 45 | "es": ["spanish"], 46 | "sv": ["swedish"], 47 | "tr": ["turkish"], 48 | "ro": ["romanian"] 49 | } 50 | 51 | SUPPORTED_LOCALES = [ 52 | ["", _("System default")], 53 | ["pt_BR", _("Brazilian Portuguese")], 54 | ["cs_CZ", _("Czech")], 55 | ["nl", _("Dutch")], 56 | ["en_US", _("English")], 57 | ["fi", _("Finnish")], 58 | ["fr", _("French")], 59 | ["de", _("German")], 60 | ["it_IT", _("Italian")], 61 | ["nb_NO", _("Norwegian Bokmål")], 62 | ["nn_NO", _("Norwegian Nynorsk")], 63 | ["pl", _("Polish")], 64 | ["pt_PT", _("Portuguese")], 65 | ["ru_RU", _("Russian")], 66 | ["zh_CN", _("Simplified Chinese")], 67 | ["es", _("Spanish")], 68 | ["es_ES", _("Spanish (Spain)")], 69 | ["sv_SE", _("Swedish")], 70 | ["zh_TW", _("Traditional Chinese")], 71 | ["tr", _("Turkish")], 72 | ["uk", _("Ukrainian")], 73 | ["el", _("Greek")], 74 | ["ro", _("Romanian")], 75 | ] 76 | 77 | VIEWS = [ 78 | ["grid", _("Grid")], 79 | ["list", _("List")], 80 | ] 81 | 82 | # Game IDs to ignore when received by the API 83 | IGNORE_GAME_IDS = [ 84 | 1424856371, # Hotline Miami 2: Wrong Number - Digital Comics 85 | 1980301910, # The Witcher Goodies Collection 86 | 2005648906, # Spring Sale Goodies Collection #1 87 | 1486144755, # Cyberpunk 2077 Goodies Collection 88 | 1581684020, # A Plague Tale Digital Goodies Pack 89 | 1185685769, # CDPR Goodie Pack Content 90 | ] 91 | 92 | DOWNLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB 93 | 94 | # This is the file size needed for the download manager to consider resuming worthwhile 95 | MINIMUM_RESUME_SIZE = 20 * 1024**2 # 20 MB 96 | 97 | # Windows executables to not consider when launching 98 | BINARY_NAMES_TO_IGNORE = [ 99 | # Standard uninstaller 100 | "unins000.exe", 101 | # Common extra binaries 102 | "UnityCrashHandler64.exe", 103 | "nglide_config.exe", 104 | # Diablo 2 specific 105 | "ipxconfig.exe", 106 | "BNUpdate.exe", 107 | "VidSize.exe", 108 | # FreeSpace 2 specific 109 | "FRED2.exe", 110 | "FS2.exe", 111 | ] 112 | -------------------------------------------------------------------------------- /minigalaxy/css.py: -------------------------------------------------------------------------------- 1 | from minigalaxy.logger import logger 2 | from minigalaxy.ui.gtk import Gtk, Gdk 3 | from minigalaxy.paths import CSS_PATH 4 | 5 | CSS_PROVIDER = Gtk.CssProvider() 6 | 7 | 8 | def load_css(): 9 | try: 10 | with open(CSS_PATH) as style: 11 | CSS_PROVIDER.load_from_data(style.read().encode('utf-8')) 12 | except Exception: 13 | logger.error("The CSS in %s could not be loaded", CSS_PATH, exc_info=1) 14 | Gtk.StyleContext().add_provider_for_screen(Gdk.Screen.get_default(), CSS_PROVIDER, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) 15 | -------------------------------------------------------------------------------- /minigalaxy/download.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from enum import Enum 4 | from zipfile import BadZipFile 5 | 6 | 7 | # Enums were added in Python 3.4 8 | class DownloadType(Enum): 9 | ICON = 1 10 | THUMBNAIL = 2 11 | GAME = 3 12 | GAME_UPDATE = 4 13 | GAME_DLC = 5 14 | 15 | 16 | class Download: 17 | """ 18 | A class to easily download from URLs and save the file. 19 | 20 | Usage: 21 | >>> import os 22 | >>> from minigalaxy.download import Download, DownloadType 23 | >>> from minigalaxy.download_manager import DownloadManager 24 | >>> def your_function(): 25 | >>> image_url = "https://www.gog.com/bundles/gogwebsitestaticpages/images/icon_section1-header.png" 26 | >>> thumbnail = os.path.join(".", "{}.jpg".format("test-icon")) 27 | >>> download = Download(image_url, thumbnail, DownloadType.THUMBNAIL, finish_func=lambda x: print("Done downloading {}!".format(x))) # noqa: E501 28 | >>> your_function() # doctest: +SKIP 29 | """ 30 | 31 | def __init__(self, url, save_location, download_type=None, 32 | finish_func=None, progress_func=None, cancel_func=None, 33 | expected_size=None, number=1, out_of_amount=1, game=None, 34 | download_icon=None): 35 | self.url = url 36 | self.save_location = save_location 37 | self.__finish_func = finish_func 38 | self.__progress_func = progress_func 39 | self.__cancel_func = cancel_func 40 | self.cancel_reason = None 41 | self.number = number 42 | self.out_of_amount = out_of_amount 43 | self.game = game 44 | # Type of object, e.g. icon, thumbnail, game, dlc, 45 | self.download_type = download_type 46 | self.current_progress = 0 47 | self.expected_size = expected_size 48 | self.download_icon = download_icon 49 | 50 | def filename(self): 51 | return os.path.basename(self.save_location) 52 | 53 | def set_progress(self, percentage: int) -> None: 54 | "Set the download progress of the Download" 55 | self.current_progress = percentage 56 | if self.__progress_func: 57 | if self.out_of_amount > 1: 58 | # Change the percentage based on which number we are 59 | progress_start = 100 / self.out_of_amount * (self.number - 1) 60 | percentage = progress_start + percentage / self.out_of_amount 61 | percentage = int(percentage) 62 | self.__progress_func(percentage) 63 | 64 | def finish(self): 65 | """ 66 | finish is called when the download has completed 67 | If a finish_func was specified when the Download was created, call the function 68 | """ 69 | self.cancel_reason = None # make sure cancel_reason is reset 70 | if self.__finish_func: 71 | try: 72 | self.__finish_func(self.save_location) 73 | except (FileNotFoundError, BadZipFile): 74 | self.cancel() 75 | 76 | def cancel(self): 77 | "Cancel the download, calling a cancel_func if one was specified" 78 | if self.__cancel_func: 79 | self.__cancel_func() 80 | 81 | def on_finish(self, callback): 82 | if not self.__finish_func and callable(callback): 83 | self.__finish_func = callback 84 | 85 | def on_cancel(self, callback): 86 | if not self.__cancel_func and callable(callback): 87 | self.__cancel_func = callback 88 | 89 | def __str__(self): 90 | return self.filename() 91 | 92 | def __repr__(self): 93 | return self.filename() 94 | -------------------------------------------------------------------------------- /minigalaxy/entity/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharkwouter/minigalaxy/d2d5c349094a88fc821ff1ed4ec59f359bc2f63c/minigalaxy/entity/__init__.py -------------------------------------------------------------------------------- /minigalaxy/entity/state.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | class State(Enum): 5 | DOWNLOADABLE = auto() 6 | INSTALLABLE = auto() 7 | UPDATABLE = auto() 8 | QUEUED = auto() 9 | DOWNLOADING = auto() 10 | INSTALLING = auto() 11 | INSTALLED = auto() 12 | NOTLAUNCHABLE = auto() 13 | UNINSTALLING = auto() 14 | UPDATING = auto() 15 | UPDATE_INSTALLABLE = auto() 16 | -------------------------------------------------------------------------------- /minigalaxy/file_info.py: -------------------------------------------------------------------------------- 1 | class FileInfo: 2 | """ 3 | Just a container for the md5 checksum and file size of a downloadable file 4 | """ 5 | def __init__(self, md5=None, size=0): 6 | self.md5 = md5 7 | self.size = size 8 | 9 | def as_dict(self): 10 | return { 11 | "md5": self.md5, 12 | "size": self.size 13 | } 14 | -------------------------------------------------------------------------------- /minigalaxy/game.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import json 4 | 5 | from minigalaxy.paths import CONFIG_GAMES_DIR, ICON_DIR, THUMBNAIL_DIR 6 | 7 | 8 | class Game: 9 | 10 | def __init__(self, name: str, url: str = "", md5sum=None, game_id: int = 0, install_dir: str = "", 11 | image_url="", platform="linux", dlcs=None, category=""): 12 | self.name = name 13 | self.url = url 14 | self.md5sum = {} if md5sum is None else md5sum 15 | self.id = game_id 16 | self.install_dir = install_dir 17 | self.image_url = image_url 18 | self.platform = platform 19 | self.dlcs = [] if dlcs is None else dlcs 20 | self.category = category 21 | self.status_file_path = self.get_status_file_path() 22 | 23 | def get_stripped_name(self, to_path=False): 24 | return Game.strip_string(self.name, to_path=to_path) 25 | 26 | def get_install_directory_name(self): 27 | return Game.strip_string(self.name, to_path=True) 28 | 29 | def get_cached_icon_path(self, dlc_id=None): 30 | if dlc_id: 31 | return os.path.join(ICON_DIR, f"{dlc_id}.jpg") 32 | else: 33 | return os.path.join(ICON_DIR, f'{self.id}.png') 34 | 35 | def get_thumbnail_path(self, use_fallback=True): 36 | """ 37 | Returns path to thumbnail file. Has 2 ways to use: 38 | 1. As a file name/path factory - files don't have to exist 39 | 2. To find the actually existing file 40 | Looks in 2 locations: 41 | - game.install_dir: When game is installed and file exists. 42 | use_fallback=False enforces returning this path even when the file 43 | does not exist. But the game must still be installed. 44 | - global thumbnail dir as denoted by minigalaxy.paths.THUMBNAIL_DIR 45 | """ 46 | 47 | if self.is_installed(): 48 | thumbnail_file = os.path.join(self.install_dir, "thumbnail.jpg") 49 | if os.path.isfile(thumbnail_file) or not use_fallback: 50 | return thumbnail_file 51 | 52 | thumbnail_file = os.path.join(THUMBNAIL_DIR, f"{self.id}.jpg") 53 | if os.path.isfile(thumbnail_file) or use_fallback: 54 | return thumbnail_file 55 | 56 | return "" 57 | 58 | def get_status_file_path(self): 59 | if self.install_dir: 60 | last_install_dir = os.path.basename(os.path.normpath(self.install_dir)) 61 | else: 62 | last_install_dir = self.get_install_directory_name() 63 | status_file_path = os.path.join(CONFIG_GAMES_DIR, "{}.json".format(last_install_dir)) 64 | return status_file_path 65 | 66 | def load_minigalaxy_info_json(self): 67 | json_dict = {} 68 | if os.path.isfile(self.status_file_path): 69 | with open(self.status_file_path, 'r') as status_file: 70 | json_dict = json.load(status_file) 71 | return json_dict 72 | 73 | def save_minigalaxy_info_json(self, json_dict): 74 | if not os.path.exists(CONFIG_GAMES_DIR): 75 | os.makedirs(CONFIG_GAMES_DIR, mode=0o755) 76 | with open(self.status_file_path, 'w') as status_file: 77 | json.dump(json_dict, status_file) 78 | 79 | @staticmethod 80 | def strip_string(string, to_path=False): 81 | cleaned_string = re.sub('[^A-Za-z0-9]+', '', string) if not to_path else re.sub('[^A-Za-z0-9 ]+', '', string) 82 | return cleaned_string.strip() # make sure the directory does not start or end with any whitespace 83 | 84 | def is_installed(self, dlc_title="") -> bool: 85 | installed = False 86 | if dlc_title: 87 | dlc_version = self.get_dlc_info("version", dlc_title) 88 | installed = bool(dlc_version) 89 | else: 90 | if self.install_dir and os.path.exists(self.install_dir): 91 | installed = True 92 | return installed 93 | 94 | def is_update_available(self, version_from_api, dlc_title="") -> bool: 95 | update_available = False 96 | if dlc_title: 97 | installed_version = self.get_dlc_info("version", dlc_title) 98 | else: 99 | installed_version = self.get_info("version") 100 | if not installed_version: 101 | installed_version = self.fallback_read_installed_version() 102 | self.set_info("version", installed_version) 103 | if installed_version and version_from_api and version_from_api != installed_version: 104 | update_available = True 105 | 106 | return update_available 107 | 108 | def fallback_read_installed_version(self): 109 | gameinfo = os.path.join(self.install_dir, "gameinfo") 110 | gameinfo_list = [] 111 | if os.path.isfile(gameinfo): 112 | with open(gameinfo, 'r') as file: 113 | gameinfo_list = file.readlines() 114 | if len(gameinfo_list) > 1: 115 | version = gameinfo_list[1].strip() 116 | else: 117 | version = "0" 118 | return version 119 | 120 | def set_info(self, key, value): 121 | json_dict = self.load_minigalaxy_info_json() 122 | json_dict[key] = value 123 | self.save_minigalaxy_info_json(json_dict) 124 | 125 | def set_dlc_info(self, key, value, dlc_title): 126 | json_dict = self.load_minigalaxy_info_json() 127 | if "dlcs" not in json_dict: 128 | json_dict["dlcs"] = {} 129 | if dlc_title not in json_dict["dlcs"]: 130 | json_dict["dlcs"][dlc_title] = {} 131 | json_dict["dlcs"][dlc_title][key] = value 132 | self.save_minigalaxy_info_json(json_dict) 133 | 134 | def get_info(self, key, default_value=""): 135 | value = "" 136 | json_dict = self.load_minigalaxy_info_json() 137 | if key in json_dict: 138 | value = json_dict[key] 139 | # Start: Code for compatibility with minigalaxy 1.0.1 and 1.0.2 140 | elif os.path.isfile(os.path.join(self.install_dir, "minigalaxy-info.json")): 141 | with open(os.path.join(self.install_dir, "minigalaxy-info.json"), 'r') as status_file: 142 | json_dict = json.load(status_file) 143 | if key in json_dict: 144 | value = json_dict[key] 145 | # Lets move this value to new config 146 | self.set_info(key, value) 147 | # End: Code for compatibility with minigalaxy 1.0.1 and 1.0.2 148 | return default_value if value == "" else value 149 | 150 | def get_dlc_info(self, key, dlc_title): 151 | value = "" 152 | json_dict = self.load_minigalaxy_info_json() 153 | if "dlcs" in json_dict: 154 | if dlc_title in json_dict["dlcs"]: 155 | if key in json_dict["dlcs"][dlc_title]: 156 | value = json_dict["dlcs"][dlc_title][key] 157 | # Start: Code for compatibility with minigalaxy 1.0.1 and 1.0.2 158 | if os.path.isfile(os.path.join(self.install_dir, "minigalaxy-info.json")) and not value: 159 | with open(os.path.join(self.install_dir, "minigalaxy-info.json"), 'r') as status_file: 160 | json_dict = json.load(status_file) 161 | if "dlcs" in json_dict: 162 | if dlc_title in json_dict["dlcs"]: 163 | if key in json_dict["dlcs"][dlc_title]: 164 | value = json_dict["dlcs"][dlc_title][key] 165 | # Lets move this value to new config 166 | self.set_dlc_info(key, value, dlc_title) 167 | # End: Code for compatibility with minigalaxy 1.0.1 and 1.0.2 168 | return value 169 | 170 | def set_install_dir(self, install_dir) -> None: 171 | """ 172 | Set the install directory based on the given install dir and the game name 173 | :param install_dir: the global install directory from the config 174 | """ 175 | if not self.install_dir: 176 | self.install_dir = os.path.join(install_dir, self.get_install_directory_name()) 177 | 178 | def __str__(self): 179 | return self.name 180 | 181 | def __eq__(self, other): 182 | if self.id > 0 and other.id > 0: 183 | return self.id == other.id 184 | if self.name == other.name: 185 | return True 186 | # Compare names with special characters and capital letters removed 187 | if self.get_stripped_name().lower() == other.get_stripped_name().lower(): 188 | return True 189 | if self.install_dir and \ 190 | other.get_install_directory_name() == os.path.basename(os.path.normpath(self.install_dir)): 191 | return True 192 | if other.install_dir and \ 193 | self.get_install_directory_name() == os.path.basename(os.path.normpath(other.install_dir)): 194 | return True 195 | return False 196 | 197 | def __lt__(self, other): 198 | # Sort installed games before not installed ones 199 | if self.is_installed() != other.is_installed(): 200 | return self.is_installed() 201 | return str(self) < str(other) 202 | -------------------------------------------------------------------------------- /minigalaxy/launcher.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import shutil 4 | import re 5 | import json 6 | import shlex 7 | import threading 8 | from typing import List 9 | 10 | from minigalaxy.logger import logger 11 | from minigalaxy.translation import _ 12 | from minigalaxy.constants import BINARY_NAMES_TO_IGNORE 13 | 14 | 15 | def get_wine_path(game): 16 | binary_name = "wine" 17 | custom_wine_path = game.get_info("custom_wine") 18 | if custom_wine_path and custom_wine_path != shutil.which(binary_name): 19 | binary_name = custom_wine_path 20 | return binary_name 21 | 22 | 23 | # should go into a separate file or into installer, but not possible ATM because 24 | # it's a circular import otherwise 25 | def wine_restore_game_link(game): 26 | game_dir = os.path.join(game.install_dir, 'prefix', 'dosdevices', 'c:', 'game') 27 | if not os.path.exists(game_dir): 28 | # 'game' directory itself does not count 29 | canonical_prefix = os.path.realpath(os.path.join(game_dir, '..')) 30 | relative = os.path.relpath(game.install_dir, canonical_prefix) 31 | os.symlink(relative, game_dir) 32 | 33 | 34 | def config_game(game): 35 | prefix = os.path.join(game.install_dir, "prefix") 36 | subprocess.Popen(['env', f'WINEPREFIX={prefix}', get_wine_path(game), 'winecfg']) 37 | 38 | 39 | def regedit_game(game): 40 | prefix = os.path.join(game.install_dir, "prefix") 41 | subprocess.Popen(['env', f'WINEPREFIX={prefix}', get_wine_path(game), 'regedit']) 42 | 43 | 44 | def winetricks_game(game): 45 | prefix = os.path.join(game.install_dir, "prefix") 46 | subprocess.Popen(['env', f'WINEPREFIX={prefix}', 'winetricks']) 47 | 48 | 49 | def start_game(game): 50 | error_message = "" 51 | process = None 52 | if not error_message: 53 | error_message = set_fps_display(game) 54 | if not error_message: 55 | error_message, process = run_game_subprocess(game) 56 | if not error_message: 57 | error_message = check_if_game_started_correctly(process, game) 58 | if not error_message: 59 | send_game_output_to_stdout(process) 60 | if error_message: 61 | logger.error(_("Failed to start {}:").format(game.name), exc_info=1) 62 | logger.error("Cause of error: %s", error_message) 63 | return error_message 64 | 65 | 66 | def get_execute_command(game) -> list: 67 | files = os.listdir(game.install_dir) 68 | launcher_type = determine_launcher_type(files) 69 | if launcher_type in ["start_script", "wine"]: 70 | exe_cmd = get_start_script_exe_cmd(game) 71 | elif launcher_type == "windows": 72 | exe_cmd = get_windows_exe_cmd(game, files) 73 | elif launcher_type == "dosbox": 74 | exe_cmd = get_dosbox_exe_cmd(game, files) 75 | elif launcher_type == "scummvm": 76 | exe_cmd = get_scummvm_exe_cmd(game, files) 77 | elif launcher_type == "final_resort": 78 | exe_cmd = get_final_resort_exe_cmd(game, files) 79 | else: 80 | # If no executable was found at all, raise an error 81 | raise FileNotFoundError() 82 | if game.get_info("use_gamemode") is True: 83 | exe_cmd.insert(0, "gamemoderun") 84 | if game.get_info("use_mangohud") is True: 85 | exe_cmd.insert(0, "mangohud") 86 | exe_cmd.insert(1, "--dlsym") 87 | exe_cmd = get_exe_cmd_with_var_command(game, exe_cmd) 88 | logger.info("Launch command for %s: %s", game.name, " ".join(exe_cmd)) 89 | return exe_cmd 90 | 91 | 92 | def determine_launcher_type(files): 93 | launcher_type = "unknown" 94 | if "unins000.exe" in files: 95 | launcher_type = "windows" 96 | elif "dosbox" in files and shutil.which("dosbox"): 97 | launcher_type = "dosbox" 98 | elif "scummvm" in files and shutil.which("scummvm"): 99 | launcher_type = "scummvm" 100 | elif "start.sh" in files: 101 | launcher_type = "start_script" 102 | elif "prefix" in files and shutil.which("wine"): 103 | launcher_type = "wine" 104 | elif "game" in files: 105 | launcher_type = "final_resort" 106 | return launcher_type 107 | 108 | 109 | def get_exe_cmd_with_var_command(game, exe_cmd): 110 | var_list = shlex.split(game.get_info("variable")) 111 | command_list = shlex.split(game.get_info("command")) 112 | 113 | if var_list: 114 | if var_list[0] not in ["env"]: 115 | var_list.insert(0, "env") 116 | if 'env' == exe_cmd[0]: 117 | exe_cmd = exe_cmd[1:] 118 | 119 | exe_cmd = var_list + exe_cmd + command_list 120 | return exe_cmd 121 | 122 | 123 | def get_windows_exe_cmd_from_goggame_info(game, file: str) -> List[str]: 124 | exe_cmd = [] 125 | os.chdir(game.install_dir) 126 | with open(file, 'r') as info_file: 127 | info = json.loads(info_file.read()) 128 | # if we have the workingDir property, start the executable at that directory 129 | for task in info.get("playTasks", []): 130 | if not task.get('isPrimary', False): 131 | continue 132 | 133 | if "path" in task: 134 | working_dir = task.get("workingDir", ".") 135 | path = task["path"] 136 | exe_cmd = [get_wine_path(game), "start", "/b", "/wait", 137 | "/d", f'c:\\game\\{working_dir}', 138 | f'c:\\game\\{path}'] 139 | if "arguments" in task: 140 | exe_cmd += shlex.split(task["arguments"]) 141 | break 142 | 143 | logger.debug("%s contains execute command [%s]", file, ' '.join(exe_cmd)) 144 | return exe_cmd 145 | 146 | 147 | def get_windows_exe_cmd(game, files): 148 | '''Find game executable file''' 149 | 150 | exe_cmd = [] 151 | prefix = os.path.join(game.install_dir, "prefix") 152 | 153 | # Get the execute command from the goggame info file 154 | goggame_file = os.path.join(game.install_dir, f'goggame-{game.id}.info') 155 | if os.path.exists(goggame_file): 156 | exe_cmd = get_windows_exe_cmd_from_goggame_info(game, goggame_file) 157 | 158 | if not exe_cmd and (launch_file_list := [file for file in files if re.match(r"^Launch .*\.lnk$", file)]): 159 | # Set Launch Game.lnk as executable 160 | exe_cmd = [get_wine_path(game), os.path.join(game.install_dir, launch_file_list[0])] 161 | logger.debug("using link file [%s] as execute command", launch_file_list[0]) 162 | 163 | if not exe_cmd: 164 | # Find the first executable file that is not blacklisted 165 | for file in files: 166 | if os.path.splitext(file.upper())[-1] not in [".EXE", ".LNK"]: 167 | continue 168 | if file in BINARY_NAMES_TO_IGNORE: 169 | continue 170 | executable = file 171 | break 172 | exe_cmd = [get_wine_path(game), os.path.join(game.install_dir, executable)] 173 | 174 | # Backwards compatibility with windows games installed before installer fixes. 175 | # Will not fix games requiring registry keys, since the paths will already 176 | # be borked through the old installer. 177 | wine_restore_game_link(game) 178 | 179 | return ['env', f'WINEPREFIX={prefix}'] + exe_cmd 180 | 181 | 182 | def get_dosbox_exe_cmd(game, files): 183 | dosbox_config = "" 184 | dosbox_config_single = "" 185 | for file in files: 186 | if re.match(r'^dosbox_?([a-z]|[A-Z]|\d)+\.conf$', file): 187 | dosbox_config = file 188 | if re.match(r'^dosbox_?([a-z]|[A-Z]|\d)+_single\.conf$', file): 189 | dosbox_config_single = file 190 | logger.info("Using system's dosbox to launch %s", game.name) 191 | return ["dosbox", "-conf", dosbox_config, "-conf", dosbox_config_single, "-no-console", "-c", "exit"] 192 | 193 | 194 | def get_scummvm_exe_cmd(game, files): 195 | scummvm_config = "" 196 | for file in files: 197 | if re.match(r'^.*\.ini$', file): 198 | scummvm_config = file 199 | break 200 | logger.info("Using system's scrummvm to launch %s", game.name) 201 | return ["scummvm", "-c", scummvm_config] 202 | 203 | 204 | def get_start_script_exe_cmd(game): 205 | return [os.path.join(game.install_dir, "start.sh")] 206 | 207 | 208 | def get_final_resort_exe_cmd(game, files): 209 | # This is the final resort, applies to FTL 210 | exe_cmd = [""] 211 | game_dir = "game" 212 | game_files = os.listdir(os.path.join(game.install_dir, game_dir)) if game_dir in files else [] 213 | for file in game_files: 214 | if re.match(r'^goggame-[0-9]*\.info$', file): 215 | os.chdir(os.path.join(game.install_dir, game_dir)) 216 | with open(file, 'r') as info_file: 217 | info = json.loads(info_file.read()) 218 | exe_cmd = ["./{}".format(info["playTasks"][0]["path"])] 219 | return exe_cmd 220 | 221 | 222 | def set_fps_display(game): 223 | error_message = "" 224 | # Enable FPS Counter for Nvidia or AMD (Mesa) users 225 | if game.get_info("show_fps"): 226 | os.environ["__GL_SHOW_GRAPHICS_OSD"] = "1" # For Nvidia users + OpenGL/Vulkan games 227 | os.environ["GALLIUM_HUD"] = "simple,fps" # For AMDGPU users + OpenGL games 228 | os.environ["VK_INSTANCE_LAYERS"] = "VK_LAYER_MESA_overlay" # For AMDGPU users + Vulkan games 229 | else: 230 | os.environ["__GL_SHOW_GRAPHICS_OSD"] = "0" 231 | os.environ["GALLIUM_HUD"] = "" 232 | os.environ["VK_INSTANCE_LAYERS"] = "" 233 | return error_message 234 | 235 | 236 | def run_game_subprocess(game): 237 | try: 238 | process = subprocess.Popen( 239 | get_execute_command(game), 240 | stdout=subprocess.PIPE, 241 | stderr=subprocess.STDOUT, 242 | bufsize=0, 243 | cwd=game.install_dir 244 | ) 245 | error_message = "" 246 | except FileNotFoundError: 247 | process = None 248 | error_message = _("No executable was found in {}").format(game.install_dir) 249 | 250 | return error_message, process 251 | 252 | 253 | def check_if_game_started_correctly(process, game): 254 | error_message = "" 255 | # Check if the application has started and see if it is still runnning after a short timeout 256 | try: 257 | process.wait(timeout=float(3)) 258 | error_message = "Game start process has finished prematurely" 259 | except subprocess.TimeoutExpired: 260 | pass 261 | 262 | if error_message in ["Game start process has finished prematurely"]: 263 | error_message = check_if_game_start_process_spawned_final_process(error_message, game) 264 | 265 | # Set the error message to what's been received in stdout if not yet set 266 | if error_message: 267 | stdout, _ = process.communicate() 268 | error_message = stdout.decode("utf-8") 269 | return error_message 270 | 271 | 272 | def check_if_game_start_process_spawned_final_process(error_message, game): 273 | ps_ef = subprocess.check_output(["ps", "-ef"]).decode("utf-8") 274 | ps_list = ps_ef.split("\n") 275 | for ps in ps_list: 276 | ps_split = ps.split() 277 | if len(ps_split) < 2: 278 | continue 279 | if not ps_split[1].isdigit(): 280 | continue 281 | if int(ps_split[1]) > os.getpid() and game.get_install_directory_name() in ps: 282 | error_message = "" 283 | break 284 | return error_message 285 | 286 | 287 | def send_game_output_to_stdout(process): 288 | 289 | def _internal_call(process): 290 | for line in iter(process.stdout.readline, b''): 291 | print(line.decode('utf-8'), end='') # TODO Is this intentionally a print statement? 292 | process.stdout.close() 293 | process.wait() 294 | 295 | t = threading.Thread(target=_internal_call, args=(process,)) 296 | t.start() 297 | -------------------------------------------------------------------------------- /minigalaxy/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | import os 4 | import sys 5 | 6 | 7 | class MinigalaxyLogFormatter(logging.Formatter): 8 | converter = datetime.date.fromtimestamp 9 | 10 | def formatTime(self, record, datefmt=None): 11 | ct = self.converter(record.created) 12 | if datefmt: 13 | logger.debug("Have date format") 14 | return ct.strftime(datefmt) 15 | else: 16 | time_string = ct.strftime("%Y-%m-%d %H:%M:%S") 17 | time_string_with_ms = "%s,%03d" % (time_string, record.msecs) 18 | return time_string_with_ms 19 | 20 | 21 | # create logger for the minigalaxy application 22 | logger = logging.getLogger('minigalaxy') 23 | logger.setLevel(logging.DEBUG) 24 | 25 | # The console should log DEBUG messages and up 26 | ch = logging.StreamHandler(stream=sys.stdout) 27 | debug = os.environ.get("MG_DEBUG") 28 | if debug: 29 | ch.setLevel(logging.DEBUG) 30 | else: 31 | ch.setLevel(logging.ERROR) 32 | 33 | # create formatter and add it to the handlers 34 | # This doesn't use the MinigalaxyLogFormatter yet, it uses the default logging Formatter 35 | formatter = logging.Formatter(fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s") 36 | ch.setFormatter(formatter) 37 | 38 | # add the handlers to the logger 39 | logger.addHandler(ch) 40 | -------------------------------------------------------------------------------- /minigalaxy/paths.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | LAUNCH_DIR = os.path.abspath(os.path.dirname(sys.argv[0])) 5 | if LAUNCH_DIR == "/bin" or LAUNCH_DIR == "/sbin": 6 | LAUNCH_DIR = "/usr" + LAUNCH_DIR 7 | 8 | CONFIG_DIR = os.path.join(os.getenv('XDG_CONFIG_HOME', os.path.expanduser('~/.config')), "minigalaxy") 9 | CONFIG_GAMES_DIR = os.path.join(CONFIG_DIR, "games") 10 | CONFIG_FILE_PATH = os.path.join(CONFIG_DIR, "config.json") 11 | CACHE_DIR = os.path.join(os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache')), "minigalaxy") 12 | DOWNLOAD_DIR = os.path.join(os.getenv('MINIGALAXY_DOWNLOAD_DIR', CACHE_DIR), "download") 13 | 14 | THUMBNAIL_DIR = os.path.join(CACHE_DIR, "thumbnails") 15 | COVER_DIR = os.path.join(CACHE_DIR, "covers") 16 | ICON_DIR = os.path.join(CACHE_DIR, "icons") 17 | CATEGORIES_FILE_PATH = os.path.join(CACHE_DIR, "categories.json") 18 | APPLICATIONS_DIR = os.path.expanduser("~/.local/share/applications") 19 | DEFAULT_INSTALL_DIR = os.path.expanduser("~/GOG Games") 20 | 21 | UI_DIR = os.path.abspath(os.path.join(LAUNCH_DIR, "../data/ui")) 22 | if not os.path.exists(UI_DIR): 23 | UI_DIR = os.path.abspath(os.path.join(LAUNCH_DIR, "../share/minigalaxy/ui")) 24 | 25 | LOGO_IMAGE_PATH = os.path.abspath(os.path.join(LAUNCH_DIR, "../data/icons/192x192/io.github.sharkwouter.Minigalaxy.png")) 26 | if not os.path.exists(LOGO_IMAGE_PATH): 27 | LOGO_IMAGE_PATH = os.path.abspath( 28 | os.path.join(LAUNCH_DIR, "../share/icons/hicolor/192x192/apps/io.github.sharkwouter.Minigalaxy.png") 29 | ) 30 | 31 | WINE_RES_PATH = os.path.abspath(os.path.join(LAUNCH_DIR, "../data/wine_resources")) 32 | if not os.path.exists(WINE_RES_PATH): 33 | WINE_RES_PATH = os.path.abspath(os.path.join(LAUNCH_DIR, "../share/minigalaxy/wine_resources")) 34 | 35 | ICON_WINE_PATH = os.path.abspath(os.path.join(LAUNCH_DIR, "../data/images/winehq_logo_glass.png")) 36 | if not os.path.exists(ICON_WINE_PATH): 37 | ICON_WINE_PATH = os.path.abspath(os.path.join(LAUNCH_DIR, "../share/minigalaxy/images/winehq_logo_glass.png")) 38 | 39 | LOCALE_DIR = os.path.abspath(os.path.join(LAUNCH_DIR, "../data/mo")) 40 | if not os.path.exists(LOCALE_DIR): 41 | LOCALE_DIR = os.path.abspath(os.path.join(LAUNCH_DIR, "../share/minigalaxy/translations")) 42 | 43 | CSS_PATH = os.path.abspath(os.path.join(LAUNCH_DIR, "../data/style.css")) 44 | if not os.path.exists(CSS_PATH): 45 | CSS_PATH = os.path.abspath(os.path.join(LAUNCH_DIR, "../share/minigalaxy/style.css")) 46 | -------------------------------------------------------------------------------- /minigalaxy/translation.py: -------------------------------------------------------------------------------- 1 | import os 2 | import gettext 3 | import locale 4 | from minigalaxy.config import Config 5 | from minigalaxy.logger import logger 6 | from minigalaxy.paths import LOCALE_DIR 7 | 8 | TRANSLATION_DOMAIN = "minigalaxy" 9 | try: 10 | locale.setlocale(locale.LC_ALL, '') 11 | except locale.Error: 12 | logger.error("Unsupported locale detected, continuing without translation support", exc_info=1) 13 | 14 | try: 15 | locale.bindtextdomain(TRANSLATION_DOMAIN, LOCALE_DIR) 16 | except AttributeError: 17 | logger.error("Couldn't run locale.bindtextdomain. Translations might not work correctly.", exc_info=1) 18 | 19 | try: 20 | locale.textdomain(TRANSLATION_DOMAIN) 21 | except AttributeError: 22 | logger.error("Couldn't run locale.textdomain. Translations might not work correctly.", exc_info=1) 23 | 24 | gettext.bindtextdomain(TRANSLATION_DOMAIN, LOCALE_DIR) 25 | gettext.textdomain(TRANSLATION_DOMAIN) 26 | 27 | # Make sure LANGUAGE and LANG are not set, or translations will not be loaded 28 | os.unsetenv("LANGUAGE") 29 | os.unsetenv("LANG") 30 | 31 | current_locale = Config().locale 32 | default_locale = locale.getdefaultlocale()[0] 33 | if current_locale == '': 34 | if default_locale is None: 35 | lang = gettext.translation(TRANSLATION_DOMAIN, LOCALE_DIR, languages=['en'], fallback=True) 36 | else: 37 | lang = gettext.translation(TRANSLATION_DOMAIN, LOCALE_DIR, languages=[default_locale], fallback=True) 38 | else: 39 | lang = gettext.translation(TRANSLATION_DOMAIN, LOCALE_DIR, languages=[current_locale], fallback=True) 40 | _ = lang.gettext 41 | -------------------------------------------------------------------------------- /minigalaxy/ui/__init__.py: -------------------------------------------------------------------------------- 1 | """ MiniGalaxy gui windows """ 2 | # Flake8 thinks these imports are unused 3 | from minigalaxy.ui.window import Window # noqa: F401 4 | from minigalaxy.ui.preferences import Preferences # noqa: F401 5 | from minigalaxy.ui.gametile import GameTile # noqa: F401 6 | -------------------------------------------------------------------------------- /minigalaxy/ui/about.py: -------------------------------------------------------------------------------- 1 | import os 2 | from minigalaxy.version import VERSION 3 | from minigalaxy.translation import _ 4 | from minigalaxy.paths import LOGO_IMAGE_PATH, UI_DIR 5 | from minigalaxy.ui.gtk import Gtk, GdkPixbuf 6 | 7 | 8 | @Gtk.Template.from_file(os.path.join(UI_DIR, "about.ui")) 9 | class About(Gtk.AboutDialog): 10 | __gtype_name__ = "About" 11 | 12 | def __init__(self, parent): 13 | Gtk.AboutDialog.__init__(self, title=_("About"), parent=parent, modal=True) 14 | self.set_version(VERSION) 15 | new_image = GdkPixbuf.Pixbuf().new_from_file(LOGO_IMAGE_PATH) 16 | self.set_logo(new_image) 17 | -------------------------------------------------------------------------------- /minigalaxy/ui/categoryfilters.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from minigalaxy.logger import logger 4 | from minigalaxy.translation import _ 5 | from minigalaxy.paths import UI_DIR 6 | from minigalaxy.ui.filterswitch import FilterSwitch 7 | from minigalaxy.ui.gtk import Gtk 8 | 9 | 10 | @Gtk.Template.from_file(os.path.join(UI_DIR, "categoryfilters.ui")) 11 | class CategoryFilters(Gtk.Dialog): 12 | __gtype_name__ = "CategoryFilters" 13 | 14 | genre_filtering_grid = Gtk.Template.Child() 15 | filter_dict = {} 16 | categories = [ 17 | 'Action', 18 | 'Adventure', 19 | 'Racing', 20 | 'Role-playing', 21 | 'Shooter', 22 | 'Simulation', 23 | 'Sports', 24 | 'Strategy', 25 | ] 26 | 27 | def __init__(self, parent, library): 28 | Gtk.Dialog.__init__(self, title=_("Category Filters"), parent=parent, modal=True) 29 | self.parent = parent 30 | self.library = library 31 | 32 | for idx, category in enumerate(self.categories): 33 | initial_state = category in self.library.category_filters 34 | self.filter_dict[category] = initial_state 35 | filter_switch = FilterSwitch(self, 36 | category, 37 | lambda key, value: self.filter_dict.__setitem__(key, value), 38 | initial_state) 39 | self.genre_filtering_grid.attach( 40 | filter_switch, left=idx % 3, top=int(idx / 3), width=1, height=1) 41 | 42 | # Center filters window 43 | self.set_position(Gtk.WindowPosition.CENTER_ALWAYS) 44 | 45 | @Gtk.Template.Callback("on_button_category_filters_apply_clicked") 46 | def on_apply_clicked(self, button): 47 | logger.debug("Filtering library according to category filter dict: %s", self.filter_dict) 48 | self.library.filter_library(self) 49 | self.destroy() 50 | 51 | @Gtk.Template.Callback("on_button_category_filters_cancel_clicked") 52 | def on_cancel_clicked(self, button): 53 | self.destroy() 54 | 55 | @Gtk.Template.Callback("on_button_category_filters_reset_clicked") 56 | def on_reset_clicked(self, button): 57 | logger.debug("Resetting category filters") 58 | for child in self.genre_filtering_grid.get_children(): 59 | child.switch_category_filter.set_active(False) 60 | self.library.filter_library(self) 61 | -------------------------------------------------------------------------------- /minigalaxy/ui/filterswitch.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from minigalaxy.paths import UI_DIR 4 | from minigalaxy.ui.gtk import Gtk 5 | from minigalaxy.translation import _ 6 | 7 | 8 | @Gtk.Template.from_file(os.path.join(UI_DIR, "filterswitch.ui")) 9 | class FilterSwitch(Gtk.Box): 10 | __gtype_name__ = "FilterSwitch" 11 | 12 | label_category_filter = Gtk.Template.Child() 13 | switch_category_filter = Gtk.Template.Child() 14 | 15 | def __init__(self, parent, category_name, on_toggle_fn, initial_state=False): 16 | Gtk.Frame.__init__(self) 17 | self.parent = parent 18 | self.label_category_filter.set_label(_(category_name)) 19 | self.switch_category_filter.set_active(initial_state) 20 | 21 | def on_click(self): 22 | on_toggle_fn(category_name, self.get_active()) 23 | self.switch_category_filter.connect('toggled', on_click) 24 | -------------------------------------------------------------------------------- /minigalaxy/ui/gametile.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from minigalaxy.game import Game 4 | from minigalaxy.paths import UI_DIR 5 | from minigalaxy.ui.gtk import Gtk 6 | from minigalaxy.ui.library_entry import LibraryEntry 7 | 8 | 9 | @Gtk.Template.from_file(os.path.join(UI_DIR, "gametile.ui")) 10 | class GameTile(LibraryEntry, Gtk.Box): 11 | __gtype_name__ = "GameTile" 12 | 13 | image = Gtk.Template.Child() 14 | button = Gtk.Template.Child() 15 | button_cancel = Gtk.Template.Child() 16 | menu_button = Gtk.Template.Child() 17 | wine_icon = Gtk.Template.Child() 18 | update_icon = Gtk.Template.Child() 19 | menu_button_update = Gtk.Template.Child() 20 | menu_button_dlc = Gtk.Template.Child() 21 | menu_button_uninstall = Gtk.Template.Child() 22 | dlc_scroll_panel = Gtk.Template.Child() 23 | dlc_horizontal_box = Gtk.Template.Child() 24 | menu_button_information = Gtk.Template.Child() 25 | menu_button_properties = Gtk.Template.Child() 26 | progress_bar = Gtk.Template.Child() 27 | 28 | def __init__(self, parent_library, game: Game): 29 | super().__init__(parent_library, game) 30 | Gtk.Frame.__init__(self) 31 | 32 | self.init_ui_elements() 33 | 34 | def __str__(self): 35 | return self.game.name 36 | 37 | @Gtk.Template.Callback("on_button_clicked") 38 | def on_button_click(self, widget) -> None: 39 | super().run_primary_action(widget) 40 | 41 | @Gtk.Template.Callback("on_menu_button_information_clicked") 42 | def show_information(self, button): 43 | super().show_information(button) 44 | 45 | @Gtk.Template.Callback("on_menu_button_properties_clicked") 46 | def show_properties(self, button): 47 | super().show_properties(button) 48 | 49 | @Gtk.Template.Callback("on_button_cancel_clicked") 50 | def on_button_cancel(self, widget): 51 | super().confirm_and_cancel_download(widget) 52 | 53 | @Gtk.Template.Callback("on_menu_button_uninstall_clicked") 54 | def on_menu_button_uninstall(self, widget): 55 | super().confirm_and_uninstall(widget) 56 | 57 | @Gtk.Template.Callback("on_menu_button_update_clicked") 58 | def on_menu_button_update(self, widget): 59 | super().run_update(widget) 60 | 61 | @Gtk.Template.Callback("recalculate_dlc_list_size") 62 | def recalc_dlc_list_size(self, widget, *data): 63 | super().recalc_dlc_list_size(self.dlc_scroll_panel, self.dlc_horizontal_box) 64 | 65 | def state_installed(self): 66 | self.menu_button.get_style_context().add_class("suggested-action") 67 | super().state_installed() 68 | 69 | def state_uninstalling(self): 70 | self.menu_button.get_style_context().remove_class("suggested-action") 71 | super().state_uninstalling() 72 | -------------------------------------------------------------------------------- /minigalaxy/ui/gametilelist.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from minigalaxy.css import CSS_PROVIDER 4 | from minigalaxy.game import Game 5 | from minigalaxy.paths import UI_DIR 6 | from minigalaxy.ui.gtk import Gtk 7 | from minigalaxy.ui.library_entry import LibraryEntry 8 | 9 | 10 | @Gtk.Template.from_file(os.path.join(UI_DIR, "gametilelist.ui")) 11 | class GameTileList(LibraryEntry, Gtk.Box): 12 | __gtype_name__ = "GameTileList" 13 | 14 | image = Gtk.Template.Child() 15 | button = Gtk.Template.Child() 16 | button_cancel = Gtk.Template.Child() 17 | menu_button = Gtk.Template.Child() 18 | wine_icon = Gtk.Template.Child() 19 | update_icon = Gtk.Template.Child() 20 | menu_button_update = Gtk.Template.Child() 21 | menu_button_dlc = Gtk.Template.Child() 22 | menu_button_uninstall = Gtk.Template.Child() 23 | dlc_scroll_panel = Gtk.Template.Child() 24 | dlc_horizontal_box = Gtk.Template.Child() 25 | menu_button_information = Gtk.Template.Child() 26 | menu_button_properties = Gtk.Template.Child() 27 | game_label = Gtk.Template.Child() 28 | 29 | def __init__(self, parent_library, game: Game): 30 | super().__init__(parent_library, game) 31 | Gtk.Frame.__init__(self) 32 | 33 | self.init_ui_elements() 34 | 35 | def init_ui_elements(self): 36 | self.__create_progress_bar() 37 | self.game_label.set_label(self.game.name) 38 | Gtk.StyleContext.add_provider(self.button.get_style_context(), 39 | CSS_PROVIDER, 40 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) 41 | 42 | super().init_ui_elements() 43 | 44 | def __create_progress_bar(self) -> None: 45 | self.progress_bar = Gtk.ProgressBar() 46 | self.progress_bar.set_halign(Gtk.Align.START) 47 | self.progress_bar.set_size_request(196, -1) 48 | self.progress_bar.set_hexpand(False) 49 | self.progress_bar.set_vexpand(False) 50 | self.progress_bar.set_fraction(0.0) 51 | self.set_center_widget(self.progress_bar) 52 | self.progress_bar.hide() 53 | 54 | def __str__(self): 55 | return self.game.name 56 | 57 | @Gtk.Template.Callback("on_button_clicked") 58 | def on_button_click(self, widget) -> None: 59 | super().run_primary_action(widget) 60 | 61 | @Gtk.Template.Callback("on_menu_button_information_clicked") 62 | def show_information(self, button): 63 | super().show_information(button) 64 | 65 | @Gtk.Template.Callback("on_menu_button_properties_clicked") 66 | def show_properties(self, button): 67 | super().show_properties(button) 68 | 69 | @Gtk.Template.Callback("on_button_cancel_clicked") 70 | def on_button_cancel(self, widget): 71 | super().confirm_and_cancel_download(widget) 72 | 73 | @Gtk.Template.Callback("on_menu_button_uninstall_clicked") 74 | def on_menu_button_uninstall(self, widget): 75 | super().confirm_and_uninstall(widget) 76 | 77 | @Gtk.Template.Callback("on_menu_button_update_clicked") 78 | def on_menu_button_update(self, widget): 79 | super().run_update(widget) 80 | 81 | @Gtk.Template.Callback("recalculate_dlc_list_size") 82 | def recalc_dlc_list_size(self, widget, *data): 83 | super().recalc_dlc_list_size(self.dlc_scroll_panel, self.dlc_horizontal_box) 84 | -------------------------------------------------------------------------------- /minigalaxy/ui/gtk.py: -------------------------------------------------------------------------------- 1 | import gi 2 | 3 | gi.require_version('Gtk', '3.0') 4 | from gi.repository import Gtk, Gdk, Gio, GLib, GdkPixbuf # noqa: E402,F401 5 | gi.require_version('Notify', '0.7') 6 | from gi.repository import Notify # noqa: E402,F401 7 | -------------------------------------------------------------------------------- /minigalaxy/ui/information.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib 3 | import webbrowser 4 | 5 | from minigalaxy.api import Api 6 | from minigalaxy.config import Config 7 | from minigalaxy.download import Download 8 | from minigalaxy.download_manager import DownloadManager 9 | from minigalaxy.paths import UI_DIR, THUMBNAIL_DIR, COVER_DIR 10 | from minigalaxy.translation import _ 11 | from minigalaxy.ui.gtk import Gtk, GLib, Gio, GdkPixbuf 12 | 13 | 14 | @Gtk.Template.from_file(os.path.join(UI_DIR, "information.ui")) 15 | class Information(Gtk.Dialog): 16 | __gtype_name__ = "Information" 17 | gogBaseUrl = "https://www.gog.com" 18 | 19 | image = Gtk.Template.Child() 20 | button_information_ok = Gtk.Template.Child() 21 | button_information_support = Gtk.Template.Child() 22 | button_information_store = Gtk.Template.Child() 23 | button_information_forum = Gtk.Template.Child() 24 | button_information_gog_database = Gtk.Template.Child() 25 | button_information_pcgamingwiki = Gtk.Template.Child() 26 | label_game_description = Gtk.Template.Child() 27 | 28 | def __init__(self, parent_window, game, config: Config, api: Api, download_manager: DownloadManager): 29 | Gtk.Dialog.__init__(self, title=_("Information about {}").format(game.name), parent=parent_window, 30 | modal=True) 31 | self.parent_window = parent_window 32 | self.game = game 33 | self.config = config 34 | self.api = api 35 | self.download_manager = download_manager 36 | self.gamesdb_info = self.api.get_gamesdb_info(self.game) 37 | 38 | # Show the image 39 | self.load_thumbnail() 40 | self.load_description() 41 | 42 | # Center information window 43 | self.set_position(Gtk.WindowPosition.CENTER_ALWAYS) 44 | 45 | @Gtk.Template.Callback("on_button_information_ok_clicked") 46 | def ok_pressed(self, button): 47 | self.destroy() 48 | 49 | @Gtk.Template.Callback("on_button_information_support_clicked") 50 | def on_menu_button_support(self, widget): 51 | try: 52 | webbrowser.open(self.api.get_info(self.game)['links']['support'], new=2) 53 | except webbrowser.Error: 54 | self.parent_window.show_error( 55 | _("Couldn't open support page"), 56 | _("Please check your internet connection") 57 | ) 58 | 59 | @Gtk.Template.Callback("on_button_information_store_clicked") 60 | def on_menu_button_store(self, widget): 61 | try: 62 | webbrowser.open(self.gogBaseUrl + self.game.url) 63 | except webbrowser.Error: 64 | self.parent_window.show_error( 65 | _("Couldn't open store page"), 66 | _("Please check your internet connection") 67 | ) 68 | 69 | @Gtk.Template.Callback("on_button_information_forum_clicked") 70 | def on_menu_button_forum(self, widget): 71 | try: 72 | webbrowser.open(self.api.get_info(self.game)['links']['forum'], new=2) 73 | except webbrowser.Error: 74 | self.parent_window.show_error( 75 | _("Couldn't open forum page"), 76 | _("Please check your internet connection") 77 | ) 78 | 79 | @Gtk.Template.Callback("on_button_information_gog_database_clicked") 80 | def on_menu_button_gog_database(self, widget): 81 | try: 82 | webbrowser.open("https://www.gogdb.org/product/{}".format(self.game.id)) 83 | except webbrowser.Error: 84 | self.parent_window.show_error( 85 | _("Couldn't open GOG Database page"), 86 | _("Please check your internet connection") 87 | ) 88 | 89 | @Gtk.Template.Callback("on_button_information_pcgamingwiki_clicked") 90 | def on_menu_button_pcgamingwiki(self, widget): 91 | try: 92 | webbrowser.open("https://pcgamingwiki.com/api/gog.php?page={}".format(self.game.id)) 93 | except webbrowser.Error: 94 | self.parent_window.show_error( 95 | _("Couldn't open PCGamingWiki page"), 96 | _("Please check your internet connection") 97 | ) 98 | 99 | def load_thumbnail(self): 100 | if self.gamesdb_info["cover"]: 101 | cover_path = os.path.join(COVER_DIR, "{}.jpg".format(self.game.id)) 102 | if os.path.isfile(cover_path): 103 | pixbuf = GdkPixbuf.Pixbuf.new_from_file(cover_path) 104 | pixbuf = pixbuf.scale_simple(340, 480, GdkPixbuf.InterpType.BILINEAR) 105 | GLib.idle_add(self.image.set_from_pixbuf, pixbuf) 106 | else: 107 | url = "{}".format(self.gamesdb_info["cover"]) 108 | download = Download(url, cover_path) 109 | self.download_manager.download_now(download) 110 | response = urllib.request.urlopen(url) 111 | input_stream = Gio.MemoryInputStream.new_from_data(response.read(), None) 112 | pixbuf = GdkPixbuf.Pixbuf.new_from_stream(input_stream, None) 113 | pixbuf = pixbuf.scale_simple(340, 480, GdkPixbuf.InterpType.BILINEAR) 114 | GLib.idle_add(self.image.set_from_pixbuf, pixbuf) 115 | else: 116 | thumbnail_path = os.path.join(THUMBNAIL_DIR, "{}.jpg".format(self.game.id)) 117 | if not os.path.isfile(thumbnail_path) and self.game.is_installed: 118 | thumbnail_path = os.path.join(self.game.install_dir, "thumbnail.jpg") 119 | GLib.idle_add(self.image.set_from_file, thumbnail_path) 120 | 121 | def load_description(self): 122 | description = "" 123 | lang = self.config.lang 124 | if self.gamesdb_info["summary"]: 125 | desc_lang = "*" 126 | for summary_key in self.gamesdb_info["summary"].keys(): 127 | if lang in summary_key: 128 | desc_lang = summary_key 129 | description_len = 470 130 | if len(self.gamesdb_info["summary"][desc_lang]) > description_len: 131 | description = "{}...".format(self.gamesdb_info["summary"][desc_lang][:description_len]) 132 | else: 133 | description = self.gamesdb_info["summary"][desc_lang] 134 | if "*" in self.gamesdb_info["genre"]: 135 | genre = self.gamesdb_info["genre"]["*"] 136 | else: 137 | genre = _("unknown") 138 | for genre_key, genre_value in self.gamesdb_info["genre"].items(): 139 | if lang in genre_key: 140 | genre = genre_value 141 | description = "{}: {}\n{}".format(_("Genre"), genre, description) 142 | if self.game.is_installed(): 143 | description = "{}: {}\n{}".format(_("Version"), self.game.get_info("version"), description) 144 | GLib.idle_add(self.label_game_description.set_text, description) 145 | -------------------------------------------------------------------------------- /minigalaxy/ui/library.py: -------------------------------------------------------------------------------- 1 | import json 2 | import locale 3 | import os 4 | import re 5 | import threading 6 | 7 | from minigalaxy.api import Api 8 | from minigalaxy.config import Config 9 | from minigalaxy.download_manager import DownloadManager 10 | from minigalaxy.entity.state import State 11 | from minigalaxy.game import Game 12 | from minigalaxy.logger import logger 13 | from minigalaxy.paths import UI_DIR, CATEGORIES_FILE_PATH 14 | from minigalaxy.translation import _ 15 | from minigalaxy.ui.categoryfilters import CategoryFilters 16 | from minigalaxy.ui.gametile import GameTile 17 | from minigalaxy.ui.gametilelist import GameTileList 18 | from minigalaxy.ui.gtk import Gtk, GLib 19 | 20 | from typing import List 21 | 22 | 23 | @Gtk.Template.from_file(os.path.join(UI_DIR, "library.ui")) 24 | class Library(Gtk.Viewport): 25 | __gtype_name__ = "Library" 26 | 27 | flowbox = Gtk.Template.Child() 28 | 29 | def __init__(self, parent_window, config: Config, api: Api, download_manager: DownloadManager): 30 | Gtk.Viewport.__init__(self) 31 | 32 | self.parent_window = parent_window 33 | self.config = config 34 | 35 | current_locale = self.config.locale 36 | default_locale = locale.getdefaultlocale()[0] 37 | if current_locale == '': 38 | locale.setlocale(locale.LC_ALL, (default_locale, 'UTF-8')) 39 | else: 40 | try: 41 | locale.setlocale(locale.LC_ALL, (current_locale, 'UTF-8')) 42 | except NameError: 43 | locale.setlocale(locale.LC_ALL, (default_locale, 'UTF-8')) 44 | 45 | self.api = api 46 | self.download_manager = download_manager 47 | self.show_installed_only = self.config.installed_filter 48 | self.search_string = "" 49 | self.offline = False 50 | self.games = [] 51 | self.owned_products_ids = [] 52 | self._queue = [] 53 | self.category_filters = [] 54 | self.configure_library_sort() 55 | 56 | def _debounce(self, thunk): 57 | if thunk not in self._queue: 58 | self._queue.append(thunk) 59 | GLib.idle_add(self._run_queue) 60 | 61 | def _run_queue(self): 62 | queue, self._queue = self._queue, [] 63 | for thunk in queue: 64 | GLib.idle_add(thunk) 65 | 66 | def reset(self): 67 | self.games = [] 68 | for child in self.flowbox.get_children(): 69 | self.flowbox.remove(child) 70 | self.flowbox.show_all() 71 | self.update_library() 72 | 73 | def update_library(self) -> None: 74 | library_update_thread = threading.Thread(target=self.__update_library) 75 | library_update_thread.daemon = True 76 | library_update_thread.start() 77 | 78 | def __update_library(self): 79 | GLib.idle_add(self.__load_tile_states) 80 | self.owned_products_ids = self.api.get_owned_products_ids() 81 | # Get already installed games first 82 | self.games = self.__get_installed_games() 83 | GLib.idle_add(self.__create_gametiles) 84 | 85 | # Get games from the API 86 | self.__add_games_from_api() 87 | GLib.idle_add(self.__create_gametiles) 88 | GLib.idle_add(self.filter_library) 89 | 90 | def __load_tile_states(self): 91 | for child in self.flowbox.get_children(): 92 | tile = child.get_children()[0] 93 | tile.reload_state() 94 | 95 | def filter_library(self, widget: Gtk.Widget = None): 96 | if isinstance(widget, Gtk.Switch): 97 | self.show_installed_only = widget.get_active() 98 | elif isinstance(widget, Gtk.SearchEntry): 99 | self.search_string = widget.get_text() 100 | elif isinstance(widget, Gtk.Dialog) and isinstance(widget, CategoryFilters): 101 | # filter all true category-bool pairs and then extract category names 102 | self.category_filters = [j[0] for j in filter(lambda i: i[1], widget.filter_dict.items())] 103 | self.flowbox.set_filter_func(self.__filter_library_func) 104 | 105 | def __filter_library_func(self, child): 106 | tile = child.get_children()[0] 107 | if self.search_string.lower() not in str(tile).lower(): 108 | return False 109 | 110 | if self.show_installed_only: 111 | if tile.current_state in [State.DOWNLOADABLE, State.INSTALLABLE]: 112 | return False 113 | 114 | if not self.config.show_hidden_games and tile.game.get_info("hide_game"): 115 | return False 116 | 117 | if len(self.category_filters) > 0: 118 | if tile.game.category not in self.category_filters: 119 | return False 120 | 121 | return True 122 | 123 | def configure_library_sort(self): 124 | self.flowbox.set_sort_func(self.__sort_library_func) 125 | 126 | def __sort_library_func(self, child1, child2): 127 | tile1 = child1.get_children()[0].game 128 | tile2 = child2.get_children()[0].game 129 | return tile2 < tile1 130 | 131 | def __create_gametiles(self) -> None: 132 | """Gets called twice: Once for installed, once for not installed games.""" 133 | games_with_tiles = [] 134 | for child in self.flowbox.get_children(): 135 | tile = child.get_children()[0] 136 | if tile.game in self.games: 137 | games_with_tiles.append(tile.game) 138 | """Games which already have a tile during the second invocation are installed games. 139 | These did NOT have api information about the thumbnail url in their Game instance in the first pass. 140 | Thus, they weren't able to load the thumbnail if it wasn't cached before. Try again now. 141 | This mostly applies when the user empties the cache. Otherwise THUMBNAIL dir should contain a file from 142 | when the game still wasn't installed 143 | """ 144 | tile.load_thumbnail() 145 | 146 | for game in self.games: 147 | if game not in games_with_tiles: 148 | self.__add_gametile(game) 149 | 150 | def __add_gametile(self, game): 151 | view = self.config.view 152 | if view == "grid": 153 | game_tile = GameTile(self, game) 154 | elif view == "list": 155 | game_tile = GameTileList(self, game) 156 | 157 | # Start download if Minigalaxy was closed while downloading this game 158 | game_tile.resume_download_if_expected() 159 | self.flowbox.add(game_tile) 160 | ''' 161 | using flowbox.show_all at this point would overrule any state-based 162 | hide() statements in game_tile (progress_bar in GameTileList) 163 | ''' 164 | game_tile.show() 165 | 166 | def __get_installed_games(self) -> List[Game]: 167 | # Make sure the install directory exists 168 | library_dir = self.config.install_dir 169 | if not os.path.exists(library_dir): 170 | os.makedirs(library_dir, mode=0o755) 171 | directories = os.listdir(library_dir) 172 | games = [] 173 | game_categories_dict = read_game_categories_file(CATEGORIES_FILE_PATH) 174 | for directory in directories: 175 | full_path = os.path.join(self.config.install_dir, directory) 176 | # Only scan directories 177 | if not os.path.isdir(full_path): 178 | continue 179 | # Make sure the gameinfo file exists 180 | gameinfo = os.path.join(full_path, "gameinfo") 181 | if os.path.isfile(gameinfo): 182 | with open(gameinfo, 'r') as file: 183 | name = file.readline().strip() 184 | version = file.readline().strip() # noqa: F841 185 | version_dev = file.readline().strip() # noqa: F841 186 | language = file.readline().strip() # noqa: F841 187 | game_id = file.readline().strip() 188 | if not game_id: 189 | game_id = 0 190 | else: 191 | game_id = int(game_id) 192 | category = game_categories_dict.get(name, "") 193 | games.append(Game(name=name, game_id=game_id, install_dir=full_path, category=category)) 194 | else: 195 | games.extend(get_installed_windows_games(full_path, game_categories_dict)) 196 | return games 197 | 198 | def __add_games_from_api(self): 199 | retrieved_games, err_msg = self.api.get_library() 200 | if not err_msg: 201 | self.offline = False 202 | else: 203 | self.offline = True 204 | logger.info("Client is offline, showing installed games only") 205 | GLib.idle_add(self.parent_window.show_error, _("Failed to retrieve library"), _(err_msg)) 206 | game_category_dict = {} 207 | for game in retrieved_games: 208 | if game not in self.games: 209 | self.games.append(game) 210 | 211 | local_game = self.games[self.games.index(game)] 212 | if local_game.id == 0 or local_game.name != game.name: 213 | local_game.id = game.id 214 | local_game.name = game.name 215 | 216 | local_game.image_url = game.image_url 217 | local_game.url = game.url 218 | local_game.category = game.category 219 | if len(game.category) > 0: # exclude games without set category 220 | game_category_dict[game.name] = game.category 221 | update_game_categories_file(game_category_dict, CATEGORIES_FILE_PATH) 222 | 223 | 224 | def get_installed_windows_games(full_path, game_categories_dict=None): 225 | games = [] 226 | game_files = os.listdir(full_path) 227 | for file in game_files: 228 | if re.match(r'^goggame-[0-9]*\.info$', file): 229 | with open(os.path.join(full_path, file), 'rb') as info_file: 230 | info = json.loads(info_file.read().decode('utf-8-sig')) 231 | if not info.get('playTasks', []): 232 | continue 233 | 234 | game = Game( 235 | name=info["name"], 236 | game_id=int(info["gameId"]), 237 | install_dir=full_path, 238 | platform="windows", 239 | category=(game_categories_dict or {}).get(info["name"], "") 240 | ) 241 | games.append(game) 242 | return games 243 | 244 | 245 | def update_game_categories_file(game_category_dict, categories_file_path): 246 | if len(game_category_dict) == 0: 247 | return 248 | if not os.path.exists(categories_file_path): # if file does not exist, create it and write dict 249 | with open(categories_file_path, 'wt') as fd: 250 | json.dump(game_category_dict, fd) 251 | else: 252 | with open(categories_file_path, 'r+t') as fd: # if file exists, write dict only if not equal to file data 253 | cached_game_category_dict = json.load(fd) 254 | if game_category_dict != cached_game_category_dict: 255 | fd.seek(os.SEEK_SET) 256 | fd.truncate(0) 257 | json.dump(game_category_dict, fd) 258 | 259 | 260 | def read_game_categories_file(categories_file_path): 261 | cached_game_category_dict = {} 262 | if os.path.exists(categories_file_path): 263 | with open(categories_file_path, 'rt') as fd: 264 | cached_game_category_dict = json.load(fd) 265 | return cached_game_category_dict 266 | -------------------------------------------------------------------------------- /minigalaxy/ui/login.py: -------------------------------------------------------------------------------- 1 | import os 2 | from urllib.parse import urlparse, parse_qsl 3 | from minigalaxy.translation import _ 4 | from minigalaxy.paths import UI_DIR 5 | from minigalaxy.ui.gtk import Gtk 6 | from minigalaxy.ui.webkit import WebKit2 7 | 8 | 9 | @Gtk.Template.from_file(os.path.join(UI_DIR, "login.ui")) 10 | class Login(Gtk.Dialog): 11 | __gtype_name__ = "Login" 12 | 13 | box = Gtk.Template.Child() 14 | 15 | redirect_url = None 16 | 17 | result = None 18 | 19 | def __init__(self, login_url=None, redirect_url=None, parent=None): 20 | Gtk.Dialog.__init__(self, title=_("Login"), parent=parent, flags=0, buttons=()) 21 | 22 | self.redirect_url = redirect_url 23 | 24 | # https://stackoverflow.com/questions/9147875/webview-dont-display-javascript-windows-open 25 | settings = WebKit2.Settings.new() 26 | settings.props.javascript_can_open_windows_automatically = True 27 | webview = WebKit2.WebView.new_with_settings(settings) 28 | webview.load_uri(login_url) 29 | webview.connect('load-changed', self.on_navigation) 30 | webview.connect('create', self.on_create) 31 | 32 | self.box.pack_start(webview, True, True, 0) 33 | self.show_all() 34 | 35 | # Check if the login has completed when the page is changed. Set the result to the code value found within the url 36 | def on_navigation(self, widget, load_event): 37 | if load_event == WebKit2.LoadEvent.FINISHED: 38 | uri = widget.get_uri() 39 | if uri.startswith(self.redirect_url): 40 | self.result = self.__get_code_from_url(uri) 41 | self.hide() 42 | 43 | # Create any pop-up windows during authentication 44 | def on_create(self, widget, action): 45 | popup = Gtk.Dialog(title=_("Login"), parent=self, flags=0, buttons=()) 46 | webview = WebKit2.WebView.new_with_related_view(widget) 47 | webview.connect('notify::title', self.on_title_change) 48 | webview.load_uri(action.get_request().get_uri()) 49 | webview.__dict__['popup'] = popup 50 | webview.connect('close', self.on_close_popup) 51 | popup.get_content_area().pack_start(webview, True, True, 0) 52 | popup.set_size_request(400, 600) 53 | popup.set_modal(True) 54 | popup.show_all() 55 | return webview 56 | 57 | def on_title_change(self, widget, property): 58 | if 'popup' in widget.__dict__: 59 | widget.__dict__['popup'].set_title(widget.get_title()) 60 | 61 | # When a pop up is closed (by Javascript), close the Gtk window too 62 | def on_close_popup(self, widget): 63 | if 'popup' in widget.__dict__: 64 | widget.__dict__['popup'].hide() 65 | 66 | # Return the code when can be used by the API to authenticate 67 | def get_result(self): 68 | return self.result 69 | 70 | # Get the code from the url returned by GOG when logging in has succeeded 71 | def __get_code_from_url(self, url: str): 72 | parsed_url = urlparse(url) 73 | input_params = dict(parse_qsl(parsed_url.query)) 74 | return input_params.get('code') 75 | -------------------------------------------------------------------------------- /minigalaxy/ui/preferences.py: -------------------------------------------------------------------------------- 1 | import os 2 | import locale 3 | import shutil 4 | from minigalaxy.translation import _ 5 | from minigalaxy.paths import UI_DIR 6 | from minigalaxy.constants import SUPPORTED_DOWNLOAD_LANGUAGES, SUPPORTED_LOCALES, VIEWS 7 | from minigalaxy.download_manager import DownloadManager 8 | from minigalaxy.ui.gtk import Gtk 9 | from minigalaxy.config import Config 10 | 11 | 12 | @Gtk.Template.from_file(os.path.join(UI_DIR, "preferences.ui")) 13 | class Preferences(Gtk.Dialog): 14 | __gtype_name__ = "Preferences" 15 | 16 | combobox_program_language = Gtk.Template.Child() 17 | combobox_language = Gtk.Template.Child() 18 | combobox_view = Gtk.Template.Child() 19 | button_file_chooser = Gtk.Template.Child() 20 | label_keep_installers = Gtk.Template.Child() 21 | switch_keep_installers = Gtk.Template.Child() 22 | switch_stay_logged_in = Gtk.Template.Child() 23 | switch_show_hidden_games = Gtk.Template.Child() 24 | switch_show_windows_games = Gtk.Template.Child() 25 | switch_create_applications_file = Gtk.Template.Child() 26 | switch_use_dark_theme = Gtk.Template.Child() 27 | button_cancel = Gtk.Template.Child() 28 | button_save = Gtk.Template.Child() 29 | 30 | def __init__(self, parent, config: Config, download_manager: DownloadManager): 31 | Gtk.Dialog.__init__(self, title=_("Preferences"), parent=parent, modal=True) 32 | self.parent = parent 33 | self.config = config 34 | self.download_manager = download_manager 35 | 36 | self.__set_locale_list() 37 | self.__set_language_list() 38 | self.__set_view_list() 39 | self.button_file_chooser.set_filename(self.config.install_dir) 40 | self.switch_keep_installers.set_active(self.config.keep_installers) 41 | self.switch_stay_logged_in.set_active(self.config.stay_logged_in) 42 | self.switch_use_dark_theme.set_active(self.config.use_dark_theme) 43 | self.switch_show_hidden_games.set_active(self.config.show_hidden_games) 44 | self.switch_show_windows_games.set_active(self.config.show_windows_games) 45 | self.switch_create_applications_file.set_active(self.config.create_applications_file) 46 | 47 | # Set tooltip for keep installers label 48 | installer_dir = os.path.join(self.button_file_chooser.get_filename(), "installer") 49 | self.label_keep_installers.set_tooltip_text( 50 | _("Keep installers after downloading a game.\nInstallers are stored in: {}").format(installer_dir) 51 | ) 52 | 53 | def __set_locale_list(self) -> None: 54 | locales = Gtk.ListStore(str, str) 55 | for local in SUPPORTED_LOCALES: 56 | locales.append(local) 57 | 58 | self.combobox_program_language.set_model(locales) 59 | self.combobox_program_language.set_entry_text_column(1) 60 | self.renderer_text = Gtk.CellRendererText() 61 | self.combobox_program_language.pack_start(self.renderer_text, False) 62 | self.combobox_program_language.add_attribute(self.renderer_text, "text", 1) 63 | 64 | # Set the active option 65 | current_locale = self.config.locale 66 | default_locale = locale.getdefaultlocale() 67 | if current_locale is None: 68 | locale.setlocale(locale.LC_ALL, default_locale) 69 | for key in range(len(locales)): 70 | if locales[key][:1][0] == current_locale: 71 | self.combobox_program_language.set_active(key) 72 | break 73 | 74 | def __set_language_list(self) -> None: 75 | languages = Gtk.ListStore(str, str) 76 | for lang in SUPPORTED_DOWNLOAD_LANGUAGES: 77 | languages.append(lang) 78 | 79 | self.combobox_language.set_model(languages) 80 | self.combobox_language.set_entry_text_column(1) 81 | self.renderer_text = Gtk.CellRendererText() 82 | self.combobox_language.pack_start(self.renderer_text, False) 83 | self.combobox_language.add_attribute(self.renderer_text, "text", 1) 84 | 85 | # Set the active option 86 | current_lang = self.config.lang 87 | for key in range(len(languages)): 88 | if languages[key][:1][0] == current_lang: 89 | self.combobox_language.set_active(key) 90 | break 91 | 92 | def __set_view_list(self) -> None: 93 | views = Gtk.ListStore(str, str) 94 | for view in VIEWS: 95 | views.append(view) 96 | 97 | self.combobox_view.set_model(views) 98 | self.combobox_view.set_entry_text_column(1) 99 | self.renderer_text = Gtk.CellRendererText() 100 | self.combobox_view.pack_start(self.renderer_text, False) 101 | self.combobox_view.add_attribute(self.renderer_text, "text", 1) 102 | 103 | # Set the active option 104 | current_view = self.config.view 105 | for key in range(len(views)): 106 | if views[key][:1][0] == current_view: 107 | self.combobox_view.set_active(key) 108 | break 109 | 110 | def __save_locale_choice(self) -> None: 111 | new_locale = self.combobox_program_language.get_active_iter() 112 | if new_locale is not None: 113 | model = self.combobox_program_language.get_model() 114 | locale_choice = model[new_locale][-2] 115 | if locale_choice == '': 116 | default_locale = locale.getdefaultlocale()[0] 117 | locale.setlocale(locale.LC_ALL, (default_locale, 'UTF-8')) 118 | self.config.locale = locale_choice 119 | else: 120 | try: 121 | locale.setlocale(locale.LC_ALL, (locale_choice, 'UTF-8')) 122 | self.config.locale = locale_choice 123 | except locale.Error: 124 | self.parent.show_error(_("Failed to change program language. Make sure locale is generated on " 125 | "your system.")) 126 | 127 | def __save_language_choice(self) -> None: 128 | lang_choice = self.combobox_language.get_active_iter() 129 | if lang_choice is not None: 130 | model = self.combobox_language.get_model() 131 | lang, _ = model[lang_choice][:2] 132 | self.config.lang = lang 133 | 134 | def __save_view_choice(self) -> None: 135 | view_choice = self.combobox_view.get_active_iter() 136 | if view_choice is not None: 137 | model = self.combobox_view.get_model() 138 | view, _ = model[view_choice][:2] 139 | if view != self.config.view: 140 | self.parent.reset_library() 141 | self.config.view = view 142 | 143 | def __save_theme_choice(self) -> None: 144 | settings = Gtk.Settings.get_default() 145 | self.config.use_dark_theme = self.switch_use_dark_theme.get_active() 146 | if self.config.use_dark_theme is True: 147 | settings.set_property("gtk-application-prefer-dark-theme", True) 148 | else: 149 | settings.set_property("gtk-application-prefer-dark-theme", False) 150 | 151 | def __save_install_dir_choice(self) -> bool: 152 | choice = self.button_file_chooser.get_filename() 153 | old_dir = self.config.install_dir 154 | if choice == old_dir: 155 | return True 156 | 157 | if not os.path.exists(choice): 158 | try: 159 | os.makedirs(choice, mode=0o755) 160 | except Exception: 161 | return False 162 | else: 163 | write_test_file = os.path.join(choice, "write_test.txt") 164 | try: 165 | with open(write_test_file, "w") as file: 166 | file.write("test") 167 | file.close() 168 | os.remove(write_test_file) 169 | except Exception: 170 | return False 171 | # Remove the old directory if it is empty 172 | try: 173 | os.rmdir(old_dir) 174 | except OSError: 175 | pass 176 | 177 | self.config.install_dir = choice 178 | return True 179 | 180 | @Gtk.Template.Callback("on_button_save_clicked") 181 | def save_pressed(self, button): 182 | self.__save_locale_choice() 183 | self.__save_language_choice() 184 | self.__save_view_choice() 185 | self.__save_theme_choice() 186 | self.config.keep_installers = self.switch_keep_installers.get_active() 187 | self.config.stay_logged_in = self.switch_stay_logged_in.get_active() 188 | self.config.show_hidden_games = self.switch_show_hidden_games.get_active() 189 | self.config.create_applications_file = self.switch_create_applications_file.get_active() 190 | self.parent.library.filter_library() 191 | 192 | if self.switch_show_windows_games.get_active() != self.config.show_windows_games: 193 | if self.switch_show_windows_games.get_active() and not shutil.which("wine"): 194 | self.parent.show_error(_("Wine wasn't found. Showing Windows games cannot be enabled.")) 195 | self.config.show_windows_games = False 196 | else: 197 | self.config.show_windows_games = self.switch_show_windows_games.get_active() 198 | self.parent.reset_library() 199 | 200 | # Only change the install_dir is it was actually changed 201 | if self.button_file_chooser.get_filename() != self.config.install_dir: 202 | if self.__save_install_dir_choice(): 203 | self.download_manager.cancel_all_downloads() 204 | self.parent.reset_library() 205 | else: 206 | self.parent.show_error(_("{} isn't a usable path").format(self.button_file_chooser.get_filename())) 207 | self.destroy() 208 | 209 | @Gtk.Template.Callback("on_button_cancel_clicked") 210 | def cancel_pressed(self, button): 211 | self.response(Gtk.ResponseType.CANCEL) 212 | self.destroy() 213 | -------------------------------------------------------------------------------- /minigalaxy/ui/properties.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | 5 | from minigalaxy.config import Config 6 | from minigalaxy.installer import create_applications_file 7 | from minigalaxy.paths import UI_DIR 8 | from minigalaxy.translation import _ 9 | from minigalaxy.launcher import config_game, regedit_game, winetricks_game 10 | from minigalaxy.ui.gtk import Gtk 11 | 12 | 13 | @Gtk.Template.from_file(os.path.join(UI_DIR, "properties.ui")) 14 | class Properties(Gtk.Dialog): 15 | __gtype_name__ = "Properties" 16 | gogBaseUrl = "https://www.gog.com" 17 | 18 | button_properties_regedit = Gtk.Template.Child() 19 | button_properties_winecfg = Gtk.Template.Child() 20 | button_properties_winetricks = Gtk.Template.Child() 21 | button_properties_open_files = Gtk.Template.Child() 22 | switch_properties_check_for_updates = Gtk.Template.Child() 23 | button_properties_wine = Gtk.Template.Child() 24 | button_properties_reset = Gtk.Template.Child() 25 | switch_properties_show_fps = Gtk.Template.Child() 26 | switch_properties_hide_game = Gtk.Template.Child() 27 | switch_properties_use_gamemode = Gtk.Template.Child() 28 | switch_properties_use_mangohud = Gtk.Template.Child() 29 | entry_properties_variable = Gtk.Template.Child() 30 | entry_properties_command = Gtk.Template.Child() 31 | button_properties_cancel = Gtk.Template.Child() 32 | button_properties_ok = Gtk.Template.Child() 33 | label_wine_custom = Gtk.Template.Child() 34 | 35 | def __init__(self, parent_library, game, config: Config, api): 36 | Gtk.Dialog.__init__(self, title=_("Properties of {}").format(game.name), parent=parent_library.parent_window, 37 | modal=True) 38 | self.parent_library = parent_library 39 | self.parent_window = parent_library.parent_window 40 | self.game = game 41 | self.config = config 42 | self.api = api 43 | self.gamesdb_info = self.api.get_gamesdb_info(self.game) 44 | 45 | # Disable/Enable buttons 46 | self.button_sensitive(game) 47 | 48 | # Keep switch check for updates disabled/enabled 49 | self.switch_properties_check_for_updates.set_active(self.game.get_info("check_for_updates")) 50 | 51 | # Retrieve custom wine path each time Properties is open 52 | if self.game.get_info("custom_wine"): 53 | self.button_properties_wine.set_filename(self.game.get_info("custom_wine")) 54 | elif shutil.which("wine"): 55 | self.button_properties_wine.set_filename(shutil.which("wine")) 56 | 57 | # Keep switch FPS disabled/enabled 58 | self.switch_properties_show_fps.set_active(self.game.get_info("show_fps")) 59 | 60 | # Keep switch game shown/hidden 61 | self.switch_properties_hide_game.set_active(self.game.get_info("hide_game")) 62 | 63 | # Keep switch use GameMode disabled/enabled 64 | self.switch_properties_use_gamemode.set_active(self.game.get_info("use_gamemode")) 65 | 66 | # Keep switch use MangoHud disabled/enabled 67 | self.switch_properties_use_mangohud.set_active(self.game.get_info("use_mangohud")) 68 | 69 | # Retrieve variable & command each time properties is open 70 | self.entry_properties_variable.set_text(self.game.get_info("variable")) 71 | self.entry_properties_command.set_text(self.game.get_info("command")) 72 | 73 | # Center properties window 74 | self.set_position(Gtk.WindowPosition.CENTER_ALWAYS) 75 | 76 | @Gtk.Template.Callback("on_button_properties_cancel_clicked") 77 | def cancel_pressed(self, button): 78 | self.destroy() 79 | 80 | @Gtk.Template.Callback("on_button_properties_ok_clicked") 81 | def ok_pressed(self, button): 82 | game_installed = self.game.is_installed() 83 | if game_installed: 84 | self.game.set_info("check_for_updates", self.switch_properties_check_for_updates.get_active()) 85 | self.game.set_info("show_fps", self.switch_properties_show_fps.get_active()) 86 | if self.switch_properties_use_gamemode.get_active() and not shutil.which("gamemoderun"): 87 | self.parent_window.show_error(_("GameMode wasn't found. Using GameMode cannot be enabled.")) 88 | self.game.set_info("use_gamemode", False) 89 | else: 90 | self.game.set_info("use_gamemode", self.switch_properties_use_gamemode.get_active()) 91 | if self.switch_properties_use_mangohud.get_active() and not shutil.which("mangohud"): 92 | self.parent_window.show_error(_("MangoHud wasn't found. Using MangoHud cannot be enabled.")) 93 | self.game.set_info("use_mangohud", False) 94 | else: 95 | self.game.set_info("use_mangohud", self.switch_properties_use_mangohud.get_active()) 96 | self.game.set_info("variable", str(self.entry_properties_variable.get_text())) 97 | self.game.set_info("command", str(self.entry_properties_command.get_text())) 98 | self.game.set_info("hide_game", self.switch_properties_hide_game.get_active()) 99 | self.game.set_info("custom_wine", str(self.button_properties_wine.get_filename())) 100 | self.parent_library.filter_library() 101 | 102 | if game_installed and self.config.create_applications_file: 103 | create_applications_file(game=self.game, override=True) 104 | 105 | self.destroy() 106 | 107 | @Gtk.Template.Callback("on_button_properties_regedit_clicked") 108 | def on_menu_button_regedit(self, widget): 109 | regedit_game(self.game) 110 | 111 | @Gtk.Template.Callback("on_button_properties_reset_clicked") 112 | def on_menu_button_reset(self, widget): 113 | self.button_properties_wine.select_filename(shutil.which("wine")) 114 | 115 | @Gtk.Template.Callback("on_button_properties_winecfg_clicked") 116 | def on_menu_button_winecfg(self, widget): 117 | config_game(self.game) 118 | 119 | @Gtk.Template.Callback("on_button_properties_winetricks_clicked") 120 | def on_menu_button_winetricks(self, widget): 121 | if not shutil.which("winetricks"): 122 | self.parent_window.show_error(_("Winetricks wasn't found and cannot be used.")) 123 | else: 124 | winetricks_game(self.game) 125 | 126 | @Gtk.Template.Callback("on_button_properties_open_files_clicked") 127 | def on_menu_button_open_files(self, widget): 128 | subprocess.call(["xdg-open", self.game.install_dir]) 129 | 130 | def button_sensitive(self, game): 131 | if not game.is_installed(): 132 | self.button_properties_open_files.set_sensitive(False) 133 | self.button_properties_wine.set_sensitive(False) 134 | self.button_properties_reset.set_sensitive(False) 135 | self.button_properties_regedit.set_sensitive(False) 136 | self.button_properties_winecfg.set_sensitive(False) 137 | self.button_properties_winetricks.set_sensitive(False) 138 | self.button_properties_open_files.set_sensitive(False) 139 | self.switch_properties_check_for_updates.set_sensitive(False) 140 | self.switch_properties_show_fps.set_sensitive(False) 141 | self.switch_properties_use_gamemode.set_sensitive(False) 142 | self.switch_properties_use_mangohud.set_sensitive(False) 143 | self.entry_properties_variable.set_sensitive(False) 144 | self.entry_properties_command.set_sensitive(False) 145 | 146 | if game.platform == 'linux': 147 | self.button_properties_regedit.hide() 148 | self.button_properties_winecfg.hide() 149 | self.button_properties_winetricks.hide() 150 | self.button_properties_wine.hide() 151 | self.button_properties_reset.hide() 152 | self.label_wine_custom.hide() 153 | -------------------------------------------------------------------------------- /minigalaxy/ui/webkit.py: -------------------------------------------------------------------------------- 1 | import gi 2 | 3 | try: 4 | gi.require_version('WebKit2', '4.1') 5 | except ValueError: 6 | gi.require_version('WebKit2', '4.0') 7 | from gi.repository import WebKit2 # noqa: E402,F401 8 | -------------------------------------------------------------------------------- /minigalaxy/ui/window.py: -------------------------------------------------------------------------------- 1 | import os 2 | import locale 3 | 4 | from minigalaxy.download_manager import DownloadManager 5 | from minigalaxy.logger import logger 6 | from minigalaxy.ui.categoryfilters import CategoryFilters 7 | from minigalaxy.ui.login import Login 8 | from minigalaxy.ui.preferences import Preferences 9 | from minigalaxy.ui.about import About 10 | from minigalaxy.api import Api 11 | from minigalaxy.paths import UI_DIR, LOGO_IMAGE_PATH, THUMBNAIL_DIR, COVER_DIR, ICON_DIR 12 | from minigalaxy.translation import _ 13 | from minigalaxy.ui.library import Library 14 | from minigalaxy.ui.gtk import Gtk, Gdk, GdkPixbuf, Notify 15 | from minigalaxy.config import Config 16 | from minigalaxy.ui.download_list import DownloadManagerList 17 | 18 | 19 | @Gtk.Template.from_file(os.path.join(UI_DIR, "application.ui")) 20 | class Window(Gtk.ApplicationWindow): 21 | __gtype_name__ = "Window" 22 | 23 | HeaderBar = Gtk.Template.Child() 24 | header_sync = Gtk.Template.Child() 25 | header_installed = Gtk.Template.Child() 26 | header_search = Gtk.Template.Child() 27 | menu_about = Gtk.Template.Child() 28 | menu_preferences = Gtk.Template.Child() 29 | menu_logout = Gtk.Template.Child() 30 | window_library = Gtk.Template.Child() 31 | download_list_button = Gtk.Template.Child() 32 | download_list = Gtk.Template.Child() 33 | 34 | def __init__(self, config: Config, api: 'Api', download_manager: DownloadManager, name="Minigalaxy"): 35 | current_locale = config.locale 36 | default_locale = locale.getdefaultlocale()[0] 37 | if current_locale == '': 38 | locale.setlocale(locale.LC_ALL, (default_locale, 'UTF-8')) 39 | else: 40 | try: 41 | locale.setlocale(locale.LC_ALL, (current_locale, 'UTF-8')) 42 | except NameError: 43 | locale.setlocale(locale.LC_ALL, (default_locale, 'UTF-8')) 44 | Gtk.ApplicationWindow.__init__(self, title=name) 45 | 46 | self.api = api 47 | self.config = config 48 | self.download_manager = download_manager 49 | self.search_string = "" 50 | self.offline = False 51 | 52 | # Initialize notifications module 53 | Notify.init("minigalaxy") 54 | 55 | # Set library 56 | self.library = Library(self, config, api, download_manager) 57 | 58 | self.window_library.add(self.library) 59 | self.header_installed.set_active(self.config.installed_filter) 60 | self.download_list.add(DownloadManagerList(self.download_manager, self, self.config)) 61 | 62 | # Set the icon 63 | icon = GdkPixbuf.Pixbuf.new_from_file(LOGO_IMAGE_PATH) 64 | self.set_default_icon_list([icon]) 65 | 66 | # Set theme 67 | settings = Gtk.Settings.get_default() 68 | if self.config.use_dark_theme is True: 69 | settings.set_property("gtk-application-prefer-dark-theme", True) 70 | else: 71 | settings.set_property("gtk-application-prefer-dark-theme", False) 72 | 73 | # Show the window 74 | if self.config.keep_window_maximized: 75 | self.maximize() 76 | self.show_all() 77 | 78 | self.make_directories() 79 | 80 | # Interact with the API 81 | logger.debug("Checking API connectivity...") 82 | self.offline = not self.api.can_connect() 83 | logger.debug("Done checking API connectivity, status: %s", "offline" if self.offline else "online") 84 | if not self.offline: 85 | try: 86 | logger.debug("Authenticating...") 87 | self.__authenticate() 88 | logger.debug("Authenticated as: %s", self.api.get_user_info()) 89 | self.HeaderBar.set_subtitle(self.api.get_user_info()) 90 | except Exception: 91 | logger.warning("Starting in offline mode after receiving exception", exc_info=1) 92 | self.offline = True 93 | self.sync_library() 94 | 95 | @Gtk.Template.Callback("filter_library") 96 | def filter_library(self, switch, _=""): 97 | self.library.filter_library(switch) 98 | if switch == self.header_installed: 99 | self.config.installed_filter = switch.get_active() 100 | 101 | @Gtk.Template.Callback("on_menu_preferences_clicked") 102 | def show_preferences(self, button): 103 | preferences_window = Preferences(parent=self, config=self.config, download_manager=self.download_manager) 104 | preferences_window.run() 105 | preferences_window.destroy() 106 | 107 | @Gtk.Template.Callback("on_menu_about_clicked") 108 | def show_about(self, button): 109 | about_window = About(self) 110 | about_window.run() 111 | about_window.destroy() 112 | 113 | @Gtk.Template.Callback("on_menu_category_filter_clicked") 114 | def show_categories(self, button): 115 | category_filters_window = CategoryFilters(self, self.library) 116 | category_filters_window.run() 117 | category_filters_window.destroy() 118 | 119 | @Gtk.Template.Callback("on_menu_logout_clicked") 120 | def logout(self, button): 121 | question = _("Are you sure you want to log out of GOG?") 122 | if self.show_question(question): 123 | # Unset everything which is specific to this user 124 | self.HeaderBar.set_subtitle("") 125 | self.config.username = "" 126 | self.config.refresh_token = "" 127 | self.hide() 128 | # Show the login screen 129 | self.__authenticate() 130 | self.HeaderBar.set_subtitle(self.api.get_user_info()) 131 | self.sync_library() 132 | self.show_all() 133 | 134 | @Gtk.Template.Callback("on_window_state_event") 135 | def on_window_state_event(self, widget, event): 136 | if event.new_window_state & Gdk.WindowState.MAXIMIZED: 137 | self.config.keep_window_maximized = True 138 | else: 139 | self.config.keep_window_maximized = False 140 | 141 | @Gtk.Template.Callback("on_header_sync_clicked") 142 | def sync_library(self, _=""): 143 | if self.library.offline: 144 | self.__authenticate() 145 | self.library.update_library() 146 | 147 | def make_directories(self): 148 | # Create the thumbnails directory 149 | if not os.path.exists(THUMBNAIL_DIR): 150 | os.makedirs(THUMBNAIL_DIR, mode=0o755) 151 | # Create the covers directory 152 | if not os.path.exists(COVER_DIR): 153 | os.makedirs(COVER_DIR, mode=0o755) 154 | # Create the icons directory 155 | if not os.path.exists(ICON_DIR): 156 | os.makedirs(ICON_DIR, mode=0o755) 157 | 158 | def reset_library(self): 159 | self.library.reset() 160 | 161 | def update_library(self): 162 | self.library.update_library() 163 | 164 | def show_error(self, text, secondary_text=""): 165 | dialog = Gtk.MessageDialog( 166 | parent=self, 167 | modal=True, 168 | destroy_with_parent=True, 169 | message_type=Gtk.MessageType.ERROR, 170 | buttons=Gtk.ButtonsType.OK, 171 | text=text 172 | ) 173 | if secondary_text: 174 | dialog.format_secondary_text(secondary_text) 175 | dialog.set_position(Gtk.WindowPosition.CENTER_ALWAYS) 176 | dialog.run() 177 | dialog.destroy() 178 | 179 | def show_question(self, text, secondary_text=""): 180 | dialog = Gtk.MessageDialog( 181 | parent=self, 182 | flags=Gtk.DialogFlags.MODAL, 183 | message_type=Gtk.MessageType.WARNING, 184 | buttons=Gtk.ButtonsType.OK_CANCEL, 185 | message_format=text 186 | ) 187 | if secondary_text: 188 | dialog.format_secondary_text(secondary_text) 189 | response = dialog.run() 190 | dialog.destroy() 191 | return response == Gtk.ResponseType.OK 192 | 193 | """ 194 | The API remembers the authentication token and uses it 195 | The token is not valid for a long time 196 | """ 197 | 198 | def __authenticate(self): 199 | url = None 200 | if self.config.stay_logged_in: 201 | token = self.config.refresh_token 202 | else: 203 | self.config.username = "" 204 | self.config.refresh_token = "" 205 | token = None 206 | 207 | # Make sure there is an internet connection 208 | if not self.api.can_connect(): 209 | return 210 | 211 | authenticated = self.api.authenticate(refresh_token=token, login_code=url) 212 | 213 | while not authenticated: 214 | login_url = self.api.get_login_url() 215 | redirect_url = self.api.get_redirect_url() 216 | login = Login(login_url=login_url, redirect_url=redirect_url, parent=self) 217 | response = login.run() 218 | login.hide() 219 | if response == Gtk.ResponseType.DELETE_EVENT: 220 | Gtk.main_quit() 221 | exit(0) 222 | if response == Gtk.ResponseType.NONE: 223 | result = login.get_result() 224 | authenticated = self.api.authenticate(login_code=result) 225 | 226 | self.config.refresh_token = authenticated 227 | -------------------------------------------------------------------------------- /minigalaxy/version.py: -------------------------------------------------------------------------------- 1 | VERSION = "1.3.2" 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "minigalaxy" 3 | description = "A simple GOG Linux client" 4 | version = "1.3.2" 5 | authors = [ 6 | { name = "Wouter Wijsman", email = "wwijsman@live.nl" } 7 | ] 8 | dependencies = [ 9 | "PyGObject", 10 | "requests" 11 | ] 12 | dynamic= [ 13 | "keywords", 14 | "classifiers", 15 | "urls", 16 | ] 17 | 18 | [build-system] 19 | requires = ["setuptools", "wheel"] 20 | build-backend = "setuptools.build_meta:__legacy__" 21 | -------------------------------------------------------------------------------- /requirements-testing.txt: -------------------------------------------------------------------------------- 1 | requests 2 | flake8 3 | simplejson 4 | coverage 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | PyGObject -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharkwouter/minigalaxy/d2d5c349094a88fc821ff1ed4ec59f359bc2f63c/screenshot.jpg -------------------------------------------------------------------------------- /scripts/add-language.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")"/../data/po 3 | 4 | if [ -z "${1}" ]; then 5 | echo "Please select a language like: ${0} en_US" 6 | fi 7 | 8 | msginit --locale="${1}" --input=minigalaxy.pot 9 | 10 | LANGFILE="$(echo ${1}|cut -f1 -d'_').po" 11 | xdg-open "${LANGFILE}" 12 | -------------------------------------------------------------------------------- /scripts/check-changelog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")"/.. 3 | 4 | n=0 5 | while read line; do 6 | n=$((n+1)) 7 | if [ $n -eq 1 ];then 8 | if [[ ! "${line}" =~ ^\*\*[0-9]*\.[0-9]*\.[0-9]*\*\*$ ]]; then 9 | echo "First line in CHANGELOG.md doesn't match **1.0.0** format" 10 | exit 1 11 | fi 12 | version="$(echo ${line}|tr -d "*")" 13 | echo "version: ${version}" 14 | continue 15 | fi 16 | 17 | if [ -z "${line}" ]; then 18 | continue 19 | fi 20 | 21 | if [[ "${line}" =~ ^\*\*[0-9]*\.[0-9]*\.[0-9]*\*\*$ ]]; then 22 | break 23 | fi 24 | 25 | echo "${line}" 26 | if [[ ! "${line}" =~ ^\ *-\ .* ]]; then 27 | echo "Error on line ${n} in CHANGELOG.md does not start with \"- \"" 28 | exit 2 29 | fi 30 | done < CHANGELOG.md 31 | -------------------------------------------------------------------------------- /scripts/compile-translations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")"/../data/po 3 | 4 | OUTPUTFILE="minigalaxy.mo" 5 | 6 | for langfile in *.po; do 7 | 8 | OUTPUTDIR="../mo/$(echo ${langfile}|cut -f1 -d '.')/LC_MESSAGES" 9 | mkdir -p "${OUTPUTDIR}" 10 | msgfmt -o "${OUTPUTDIR}/${OUTPUTFILE}" "${langfile}" 11 | done 12 | -------------------------------------------------------------------------------- /scripts/create-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Set variables 5 | cd "$(dirname "$0")/.." 6 | 7 | WORK_DIR="${PWD}" 8 | SCRIPT_DIR="${WORK_DIR}/scripts" 9 | CHANGELOG_FILE="${WORK_DIR}/CHANGELOG.md" 10 | METADATA_FILE="${WORK_DIR}/data/io.github.sharkwouter.Minigalaxy.metainfo.xml" 11 | RELEASE_FILE="${WORK_DIR}/release.md" 12 | VERSION_FILE="${WORK_DIR}/minigalaxy/version.py" 13 | TOML_FILE="${WORK_DIR}/pyproject.toml" 14 | VERSION="$(head -1 "${CHANGELOG_FILE}"|tr -d "*")" 15 | 16 | check_changelog() { 17 | "${SCRIPT_DIR}/check-changelog.sh" > /dev/null 18 | } 19 | 20 | init_release_file() { 21 | echo "Minigalaxy version ${VERSION} is now available. For new users, Minigalaxy is a simple GOG client for Linux. The download and a breakdown of the changes can be found below." > "${RELEASE_FILE}" 22 | echo "" >> "${RELEASE_FILE}" 23 | echo "![screenshot](https://raw.githubusercontent.com/sharkwouter/minigalaxy/${VERSION}/screenshot.jpg)" >> "${RELEASE_FILE}" 24 | echo "" >> "${RELEASE_FILE}" 25 | echo "## Changes" >> "${RELEASE_FILE}" 26 | echo "" >> "${RELEASE_FILE}" 27 | } 28 | 29 | add_release_file_entry() { 30 | echo " - $@" >> "${RELEASE_FILE}" 31 | } 32 | 33 | finish_release_file() { 34 | echo "" >> "${RELEASE_FILE}" 35 | echo "As usual, a deb file for installing this release on Debian and Ubuntu can be found below. Packages most distributions will most likely become available soon. See the [website](https://sharkwouter.github.io/minigalaxy/) for installation instructions.">> "${RELEASE_FILE}" 36 | } 37 | 38 | init_metadata() { 39 | xmlstarlet ed -L \ 40 | -s /component/releases \ 41 | -t elem -n "release version=\"${VERSION}\" date=\"$(date -Idate)\"" \ 42 | "${METADATA_FILE}" 43 | 44 | xmlstarlet ed -L \ 45 | -s /component/releases/release[@version="'$VERSION'"] \ 46 | -t elem -n "description" \ 47 | -s /component/releases/release[@version="'$VERSION'"]/description \ 48 | -t elem -n "p" -v "Implements the following changes:" \ 49 | -s /component/releases/release[@version="'$VERSION'"]/description \ 50 | -t elem -n "ul" \ 51 | "${METADATA_FILE}" 52 | } 53 | 54 | add_metadata_entry() { 55 | xmlstarlet ed -L \ 56 | -s /component/releases/release[@version="'$VERSION'"]/description/ul \ 57 | -t elem -n li -v "$(echo $@|sed 's/^- //'|sed 's/&/\&/g; s//\>/g; s/"/\"/g; s/'"'"'/\'/g')" \ 58 | "${METADATA_FILE}" 59 | } 60 | 61 | sort_metadata() { 62 | xmlstarlet tr --xinclude "${SCRIPT_DIR}/sort-releases.xls" "${METADATA_FILE}" > "${METADATA_FILE}.tmp" 63 | mv "${METADATA_FILE}.tmp" "${METADATA_FILE}" 64 | sed -i '1s/^/\n/' "${METADATA_FILE}" 65 | } 66 | 67 | add_debian_changelog_entry() { 68 | dch -v "${VERSION}" -M "$(echo $@|sed 's/^- //')" 69 | } 70 | 71 | set_debian_changelog_release() { 72 | dch -r -D "$(lsb_release -cs)" "" 73 | } 74 | 75 | set_version() { 76 | echo "VERSION = \"${VERSION}\"" > "${VERSION_FILE}" 77 | sed -i "s/version = .*/version = \"${VERSION}\"/" "${TOML_FILE}" 78 | } 79 | 80 | return_version_info() { 81 | echo "::set-output name=VERSION::${VERSION}" 82 | } 83 | 84 | ############### 85 | # Actual code # 86 | ############### 87 | 88 | check_changelog 89 | 90 | set_version 91 | init_metadata 92 | init_release_file 93 | 94 | n=0 95 | while read line; do 96 | n=$((n+1)) 97 | if [ $n -eq 1 ] || [ -z "${line}" ]; then 98 | continue 99 | fi 100 | 101 | # End the loop if we find the next version 102 | if [[ "${line}" =~ ^\*\*[0-9]*\.[0-9]*\.[0-9]*\*\*$ ]]; then 103 | break 104 | fi 105 | 106 | line="$(echo ${line}|sed 's/^- //')" 107 | add_metadata_entry "${line}" 108 | add_release_file_entry "${line}" 109 | add_debian_changelog_entry "${line}" 110 | 111 | done < "${CHANGELOG_FILE}" 112 | 113 | set_debian_changelog_release 114 | finish_release_file 115 | sort_metadata 116 | return_version_info 117 | -------------------------------------------------------------------------------- /scripts/missing-translations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")"/.. 3 | 4 | # Update each po file 5 | for langfile in data/po/*.po; do 6 | echo "file: ${langfile}" 7 | msgattrib --untranslated "${langfile}" 8 | echo "" 9 | done 10 | -------------------------------------------------------------------------------- /scripts/sort-releases.xls: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /scripts/take-screenshot.sh: -------------------------------------------------------------------------------- 1 | # Variables 2 | cd "$(dirname "$0")"/.. 3 | 4 | IMAGE="screenshot.jpg" 5 | 6 | # Delete the old screenshot 7 | rm -f ${IMAGE} 8 | 9 | # Start Minigalaxy 10 | bin/minigalaxy & 11 | 12 | # Wait for Minigalaxy 13 | sleep 15s 14 | 15 | # Get the window id 16 | WID="$(xwininfo -tree -root|grep Minigalaxy|tail -1|awk '{print $1}')" 17 | 18 | # Make the screenshot 19 | import -window "${WID}" -strip -trim "${PWD}/${IMAGE}" && kill %1 20 | -------------------------------------------------------------------------------- /scripts/update-translation-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")"/.. 3 | 4 | POTFILE="data/po/minigalaxy.pot" 5 | 6 | # Generate the pot file 7 | xgettext --from-code=UTF-8 --keyword=_ --sort-output --add-comments --language=Python minigalaxy/*.py minigalaxy/ui/*.py bin/minigalaxy -o "${POTFILE}" 8 | xgettext --join-existing --from-code=UTF-8 --keyword=translatable --sort-output --language=Glade data/ui/*.ui -o "${POTFILE}" 9 | 10 | # Update each po file 11 | for langfile in data/po/*.po; do 12 | msgmerge -U "${langfile}" "${POTFILE}" -N 13 | done 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from glob import glob 3 | import subprocess 4 | import os 5 | from minigalaxy.version import VERSION 6 | 7 | # Generate the translations 8 | subprocess.run(['bash', 'scripts/compile-translations.sh']) 9 | 10 | translations = [] 11 | for language_file in glob("data/mo/*/*/*.mo"): 12 | install_path = os.path.join("share/minigalaxy/translations", os.path.relpath(os.path.dirname(language_file), "data/mo")) 13 | translations.append((install_path, [language_file])) 14 | 15 | setup( 16 | name="minigalaxy", 17 | version=VERSION, 18 | packages=find_packages(exclude=['tests']), 19 | scripts=['bin/minigalaxy'], 20 | 21 | data_files=[ 22 | ('share/applications', ['data/io.github.sharkwouter.Minigalaxy.desktop']), 23 | ('share/icons/hicolor/128x128/apps', ['data/icons/128x128/io.github.sharkwouter.Minigalaxy.png']), 24 | ('share/icons/hicolor/192x192/apps', ['data/icons/192x192/io.github.sharkwouter.Minigalaxy.png']), 25 | ('share/minigalaxy/ui', glob('data/ui/*.ui')), 26 | ('share/minigalaxy/images', glob('data/images/*')), 27 | ('share/minigalaxy/wine_resources', glob('data/wine_resources/*')), 28 | ('share/minigalaxy/', ['data/style.css']), 29 | ('share/metainfo', ['data/io.github.sharkwouter.Minigalaxy.metainfo.xml']), 30 | ] + translations, 31 | 32 | # Project uses reStructuredText, so ensure that the docutils get 33 | # installed or upgraded on the target machine 34 | install_requires=[ 35 | 'PyGObject>=3.30', 36 | 'requests', 37 | ], 38 | 39 | # metadata to display on PyPI 40 | author="Wouter Wijsman", 41 | author_email="wwijsman@live.nl", 42 | description="A simple GOG Linux client", 43 | keywords="GOG gog client gaming gtk Gtk", 44 | url="https://github.com/sharkwouter/minigalaxy", # project home page, if any 45 | project_urls={ 46 | "Bug Tracker": "https://github.com/sharkwouter/minigalaxy/issues", 47 | "Documentation": "https://github.com/sharkwouter/minigalaxy/blob/master/README.md", 48 | "Source Code": "https://github.com/sharkwouter/minigalaxy", 49 | }, 50 | classifiers=[ 51 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 52 | ] 53 | ) 54 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharkwouter/minigalaxy/d2d5c349094a88fc821ff1ed4ec59f359bc2f63c/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, patch, mock_open 3 | 4 | from minigalaxy.config import Config 5 | from minigalaxy.paths import DEFAULT_INSTALL_DIR 6 | 7 | 8 | class TestConfig(TestCase): 9 | 10 | @patch('os.path.isfile') 11 | def test_read_config_file(self, mock_isfile: MagicMock): 12 | mock_isfile.return_value = True 13 | config_data = \ 14 | """ 15 | { 16 | "locale": "locale", 17 | "lang": "lang", 18 | "view": "view", 19 | "install_dir": "install_dir", 20 | "username": "username", 21 | "refresh_token": "refresh_token", 22 | "keep_installers": true, 23 | "stay_logged_in": false, 24 | "use_dark_theme": true, 25 | "show_hidden_games": true, 26 | "show_windows_games": true, 27 | "keep_window_maximized": true, 28 | "installed_filter": true, 29 | "create_applications_file": true, 30 | "current_downloads": [1, 2, 3] 31 | } 32 | """ 33 | with patch("builtins.open", mock_open(read_data=config_data)): 34 | config = Config() 35 | 36 | self.assertIsNotNone(config) 37 | self.assertEqual("locale", config.locale) 38 | self.assertEqual("lang", config.lang) 39 | self.assertEqual("view", config.view) 40 | self.assertEqual("install_dir", config.install_dir) 41 | self.assertEqual("username", config.username) 42 | self.assertEqual("refresh_token", config.refresh_token) 43 | self.assertEqual(True, config.keep_installers) 44 | self.assertEqual(False, config.stay_logged_in) 45 | self.assertEqual(True, config.use_dark_theme) 46 | self.assertEqual(True, config.show_hidden_games) 47 | self.assertEqual(True, config.show_windows_games) 48 | self.assertEqual(True, config.keep_window_maximized) 49 | self.assertEqual(True, config.installed_filter) 50 | self.assertEqual(True, config.create_applications_file) 51 | self.assertEqual([1, 2, 3], config.current_downloads) 52 | 53 | @patch('os.path.isfile') 54 | def test_defaults_if_file_does_not_exist(self, mock_isfile: MagicMock): 55 | mock_isfile.return_value = False 56 | config = Config() 57 | self.assertEqual({}, config._Config__config) 58 | 59 | self.assertEqual("", config.locale) 60 | self.assertEqual("en", config.lang) 61 | self.assertEqual("grid", config.view) 62 | self.assertEqual("", config.username) 63 | self.assertEqual(DEFAULT_INSTALL_DIR, config.install_dir) 64 | self.assertEqual("", config.refresh_token) 65 | self.assertEqual(False, config.keep_installers) 66 | self.assertEqual(True, config.stay_logged_in) 67 | self.assertEqual(False, config.use_dark_theme) 68 | self.assertEqual(False, config.show_hidden_games) 69 | self.assertEqual(False, config.show_windows_games) 70 | self.assertEqual(False, config.keep_window_maximized) 71 | self.assertEqual(False, config.installed_filter) 72 | self.assertEqual(False, config.create_applications_file) 73 | self.assertEqual([], config.current_downloads) 74 | 75 | @patch("os.remove") 76 | @patch("os.path.isfile") 77 | def test_invalid_config_file_is_deleted(self, mock_isfile: MagicMock, mock_remove: MagicMock): 78 | mock_isfile.return_value = True 79 | filename = "/this/is/a/test" 80 | config_data = \ 81 | """ 82 | { 83 | "locale": "locale", 84 | """ 85 | with patch("builtins.open", mock_open(read_data=config_data)): 86 | config = Config(filename) 87 | 88 | mock_remove.assert_called_once_with(filename) 89 | 90 | self.assertEqual("", config.locale) 91 | self.assertEqual("en", config.lang) 92 | self.assertEqual("grid", config.view) 93 | self.assertEqual("", config.username) 94 | self.assertEqual(DEFAULT_INSTALL_DIR, config.install_dir) 95 | self.assertEqual("", config.refresh_token) 96 | self.assertEqual(False, config.keep_installers) 97 | self.assertEqual(True, config.stay_logged_in) 98 | self.assertEqual(False, config.use_dark_theme) 99 | self.assertEqual(False, config.show_hidden_games) 100 | self.assertEqual(False, config.show_windows_games) 101 | self.assertEqual(False, config.keep_window_maximized) 102 | self.assertEqual(False, config.installed_filter) 103 | self.assertEqual(False, config.create_applications_file) 104 | self.assertEqual([], config.current_downloads) 105 | 106 | @patch("os.path.isfile") 107 | def test_config_property_setters(self, mock_isfile: MagicMock): 108 | mock_isfile.return_value = True 109 | filename = "/this/is/a/test" 110 | config_data = "{}" 111 | with patch("builtins.open", mock_open(read_data=config_data)): 112 | config = Config(filename) 113 | config._Config__write = MagicMock() 114 | 115 | self.assertEqual("", config.locale) 116 | config.locale = "en_US.UTF-8" 117 | self.assertEqual("en_US.UTF-8", config.locale) 118 | 119 | config._Config__write.assert_called_once() 120 | 121 | self.assertEqual("en", config.lang) 122 | config.lang = "pl" 123 | self.assertEqual("pl", config.lang) 124 | 125 | self.assertEqual("grid", config.view) 126 | config.view = "list" 127 | self.assertEqual("list", config.view) 128 | 129 | self.assertEqual("", config.username) 130 | config.username = "username" 131 | self.assertEqual("username", config.username) 132 | 133 | self.assertEqual(DEFAULT_INSTALL_DIR, config.install_dir) 134 | config.install_dir = "/install/dir" 135 | self.assertEqual("/install/dir", config.install_dir) 136 | 137 | self.assertEqual("", config.refresh_token) 138 | config.refresh_token = "refresh_token" 139 | self.assertEqual("refresh_token", config.refresh_token) 140 | 141 | self.assertEqual(False, config.keep_installers) 142 | config.keep_installers = True 143 | self.assertEqual(True, config.keep_installers) 144 | 145 | self.assertEqual(True, config.stay_logged_in) 146 | config.stay_logged_in = False 147 | self.assertEqual(False, config.stay_logged_in) 148 | 149 | self.assertEqual(False, config.use_dark_theme) 150 | config.use_dark_theme = True 151 | self.assertEqual(True, config.use_dark_theme) 152 | 153 | self.assertEqual(False, config.show_hidden_games) 154 | config.show_hidden_games = True 155 | self.assertEqual(True, config.show_hidden_games) 156 | 157 | self.assertEqual(False, config.show_windows_games) 158 | config.show_windows_games = True 159 | self.assertEqual(True, config.show_windows_games) 160 | 161 | self.assertEqual(False, config.keep_window_maximized) 162 | config.keep_window_maximized = True 163 | self.assertEqual(True, config.keep_window_maximized) 164 | 165 | self.assertEqual(False, config.installed_filter) 166 | config.installed_filter = True 167 | self.assertEqual(True, config.installed_filter) 168 | 169 | self.assertEqual(False, config.create_applications_file) 170 | config.create_applications_file = True 171 | self.assertEqual(True, config.create_applications_file) 172 | 173 | self.assertEqual([], config.current_downloads) 174 | config.current_downloads = [1, 2, 3] 175 | self.assertEqual([1, 2, 3], config.current_downloads) 176 | 177 | @patch("os.rename") 178 | @patch('os.path.isfile') 179 | @patch('os.makedirs') 180 | def test_create_config(self, mock_makedirs: MagicMock, mock_isfile: MagicMock, mock_rename: MagicMock): 181 | mock_isfile.return_value = False 182 | config = Config("/path/config.json") 183 | with patch("builtins.open", mock_open()): 184 | config.lang = "lang" 185 | self.assertEqual("lang", config.lang) 186 | mock_makedirs.assert_called_once_with("/path", mode=0o700, exist_ok=True) 187 | mock_rename.assert_called_once_with("/path/config.json.tmp", "/path/config.json") 188 | 189 | @patch('os.path.isfile') 190 | def test_add_ongoing_download(self, mock_isfile: MagicMock): 191 | mock_isfile.return_value = True 192 | config_data = "{}" 193 | with patch("builtins.open", mock_open(read_data=config_data)): 194 | config = Config("/pseudo/test/name") 195 | config._Config__write = MagicMock() 196 | 197 | self.assertEqual(0, len(config.current_downloads)) 198 | 199 | config.add_ongoing_download("TESTID") 200 | self.assertEqual(["TESTID"], config.current_downloads) 201 | 202 | # not added a second time 203 | config.add_ongoing_download("TESTID") 204 | self.assertEqual(1, len(config.current_downloads)) 205 | 206 | @patch('os.path.isfile') 207 | def test_remove_ongoing_download(self, mock_isfile: MagicMock): 208 | mock_isfile.return_value = True 209 | config_data = "{}" 210 | with patch("builtins.open", mock_open(read_data=config_data)): 211 | config = Config("/pseudo/test/name") 212 | config._Config__write = MagicMock() 213 | 214 | self.assertEqual(0, len(config.current_downloads)) 215 | 216 | # removing something that is not contained should just be silently ignored 217 | config.remove_ongoing_download("UNKNOWN-ID") 218 | 219 | config.add_ongoing_download("TESTID") 220 | config.remove_ongoing_download("TESTID") 221 | self.assertEqual(0, len(config.current_downloads)) 222 | -------------------------------------------------------------------------------- /tests/test_download.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, Mock 3 | 4 | from minigalaxy.download import Download 5 | 6 | 7 | class TestDownload(TestCase): 8 | def test1_set_progress(self): 9 | mock_progress_function = MagicMock() 10 | download = Download("test_url", "test_save_location", progress_func=mock_progress_function) 11 | download.set_progress(50) 12 | kall = mock_progress_function.mock_calls[-1] 13 | name, args, kwargs = kall 14 | exp = 50 15 | obs = args[0] 16 | self.assertEqual(exp, obs) 17 | 18 | def test2_set_progress(self): 19 | mock_progress_function = MagicMock() 20 | download = Download("test_url", "test_save_location", progress_func=mock_progress_function, out_of_amount=2) 21 | download.set_progress(32) 22 | kall = mock_progress_function.mock_calls[-1] 23 | name, args, kwargs = kall 24 | exp = 16 25 | obs = args[0] 26 | self.assertEqual(exp, obs) 27 | 28 | def test1_finish(self): 29 | mock_finish_function = MagicMock() 30 | download = Download("test_url", "test_save_location", finish_func=mock_finish_function) 31 | download.finish() 32 | exp = 2 33 | obs = len(mock_finish_function.mock_calls) 34 | self.assertEqual(exp, obs) 35 | 36 | def test2_finish(self): 37 | mock_finish_function = MagicMock() 38 | mock_finish_function.side_effect = FileNotFoundError(Mock(status="Connection Error")) 39 | mock_cancel_function = MagicMock() 40 | download = Download("test_url", "test_save_location", finish_func=mock_finish_function, 41 | cancel_func=mock_cancel_function) 42 | download.finish() 43 | exp = 2 44 | obs = len(mock_cancel_function.mock_calls) 45 | self.assertEqual(exp, obs) 46 | 47 | def test_cancel(self): 48 | mock_cancel_function = MagicMock() 49 | download = Download("test_url", "test_save_location", cancel_func=mock_cancel_function) 50 | download.cancel() 51 | exp = 2 52 | obs = len(mock_cancel_function.mock_calls) 53 | self.assertEqual(exp, obs) 54 | -------------------------------------------------------------------------------- /tests/test_download_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import tempfile 4 | import time 5 | from string import ascii_uppercase 6 | from unittest import TestCase 7 | from unittest.mock import MagicMock 8 | 9 | from minigalaxy.constants import DOWNLOAD_CHUNK_SIZE 10 | from minigalaxy.download import Download, DownloadType 11 | from minigalaxy.download_manager import DownloadManager 12 | 13 | 14 | class TestDownloadManager(TestCase): 15 | 16 | def setUp(self): 17 | self.session = MagicMock() 18 | self.config_mock = MagicMock() 19 | self.config_mock.paused_downloads = {} 20 | self.download_request = MagicMock() 21 | self.download_request.__enter__.return_value = self.download_request 22 | 23 | self.session.get.return_value = self.download_request 24 | 25 | self.chunks = [ 26 | bytes(random.choices(ascii_uppercase.encode('utf-8'), k=DOWNLOAD_CHUNK_SIZE)), 27 | bytes(random.choices(ascii_uppercase.encode('utf-8'), k=DOWNLOAD_CHUNK_SIZE)), 28 | bytes(random.choices(ascii_uppercase.encode('utf-8'), k=DOWNLOAD_CHUNK_SIZE)) 29 | ] 30 | 31 | self.download_request.iter_content.return_value = [*self.chunks] 32 | self.download_request.headers.get.return_value = len(self.chunks) * DOWNLOAD_CHUNK_SIZE 33 | self.download_manager = DownloadManager(self.session, self.config_mock) 34 | self.download_manager.fork_listener = False 35 | 36 | def test_download_operation(self): 37 | progress_func = MagicMock() 38 | finish_func = MagicMock() 39 | cancel_func = MagicMock() 40 | 41 | temp_file = tempfile.mktemp() 42 | download = Download("example.com", temp_file, DownloadType.GAME, finish_func, progress_func, cancel_func) 43 | self.download_manager._DownloadManager__download_operation(download, 0, "wb") 44 | 45 | expected = b''.join(self.chunks) 46 | with open(temp_file) as content: 47 | actual = content.read().encode('utf-8') 48 | self.assertEqual(expected, actual) 49 | 50 | # Clean up temp_file 51 | os.remove(temp_file) 52 | self.assertFalse(os.path.isfile(temp_file)) 53 | 54 | self.download_request.headers.get.assert_called_once() 55 | self.download_request.iter_content.assert_called_once() 56 | 57 | # 0 at begin, 33, 66, 100 58 | self.assertEqual(3 + 1, progress_func.call_count) 59 | self.assertEqual(0, finish_func.call_count) 60 | self.assertEqual(0, cancel_func.call_count) 61 | 62 | def test_download_operation_still_downloads_without_content_length(self): 63 | self.download_request.headers.get.side_effect = TypeError 64 | 65 | progress_func = MagicMock() 66 | finish_func = MagicMock() 67 | cancel_func = MagicMock() 68 | 69 | temp_file = tempfile.mktemp() 70 | download = Download("example.com", temp_file, DownloadType.GAME, finish_func, progress_func, cancel_func) 71 | self.download_manager._DownloadManager__download_operation(download, 0, "wb") 72 | 73 | expected = b''.join(self.chunks) 74 | with open(temp_file) as content: 75 | actual = content.read().encode('utf-8') 76 | self.assertEqual(expected, actual) 77 | 78 | # Clean up temp_file 79 | os.remove(temp_file) 80 | self.assertFalse(os.path.isfile(temp_file)) 81 | 82 | self.download_request.headers.get.assert_called_once() 83 | self.download_request.iter_content.assert_called_once() 84 | 85 | # 0, 50 (hard-coded), 100 (done) 86 | self.assertEqual(3, progress_func.call_count) 87 | self.assertEqual(0, finish_func.call_count) 88 | self.assertEqual(0, cancel_func.call_count) 89 | 90 | def test_download_operation_cancel_download(self): 91 | self.download_request.headers.get.side_effect = TypeError 92 | 93 | progress_func = MagicMock() 94 | finish_func = MagicMock() 95 | cancel_func = MagicMock() 96 | 97 | temp_file = tempfile.mktemp() 98 | download = Download("example.com", temp_file, DownloadType.GAME, finish_func, progress_func, cancel_func) 99 | self.download_manager._add_to_active_downloads(download) 100 | self.download_manager.cancel_download(download) 101 | self.download_manager._DownloadManager__download_operation(download, 0, "wb") 102 | 103 | expected = self.chunks[0] 104 | with open(temp_file) as content: 105 | actual = content.read().encode('utf-8') 106 | self.assertEqual(expected, actual) 107 | 108 | # Clean up temp_file 109 | os.remove(temp_file) 110 | self.assertFalse(os.path.isfile(temp_file)) 111 | 112 | self.download_request.headers.get.assert_called_once() 113 | self.download_request.iter_content.assert_called_once() 114 | 115 | self.assertEqual(1, progress_func.call_count) 116 | self.assertEqual(0, finish_func.call_count) 117 | self.assertEqual(0, cancel_func.call_count) 118 | 119 | def test_cancel_download(self): 120 | progress_func = MagicMock() 121 | finish_func = MagicMock() 122 | finish_func.side_effect = lambda: print("download finished") 123 | cancel_func = MagicMock() 124 | cancel_func.side_effect = lambda: print(str(time.time()) + " download cancel received") 125 | 126 | temp_file = tempfile.mktemp() 127 | download = Download("example.com", temp_file, DownloadType.GAME, finish_func, progress_func, cancel_func) 128 | 129 | self.download_manager.download(download) 130 | 131 | self.download_manager.cancel_download(download) 132 | print(str(time.time()) + " assert") 133 | cancel_func.assert_called_once() 134 | self.assertFalse(download in self.download_manager.active_downloads) 135 | for d in self.download_manager.queued_downloads.values(): 136 | self.assertNotEqual(download, d) 137 | self.assertFalse(os.path.isfile(temp_file)) 138 | -------------------------------------------------------------------------------- /tests/test_installer_queue.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from unittest import TestCase, mock 4 | from unittest.mock import MagicMock 5 | from threading import RLock, Thread 6 | 7 | from minigalaxy.game import Game 8 | from minigalaxy import installer 9 | 10 | 11 | class Test(TestCase): 12 | 13 | def test_no_duplicates(self): 14 | '''[scenario: InstallerQueue.put() must ignore items which are equal to already placed items.]''' 15 | lock = RLock() # use inserted lock to prevent the installer thread from picking items 16 | test_queue = installer.InstallerQueue(lock) 17 | 18 | # use try-finally because of queue.clear() to stop worker 19 | try: 20 | items_to_put = [32, "something", 32, "another", "another"] 21 | lock.acquire() 22 | 23 | for i in items_to_put: 24 | test_queue.put(i) 25 | 26 | self.assertEqual(3, len(test_queue.queue), "There should be 3 items with no duplicates on the queue") 27 | self.assertEqual([32, "something", "another"], [*test_queue.queue]) 28 | finally: 29 | test_queue.queue.clear() 30 | lock.release() 31 | 32 | def test_install_thread_lifecycle(self): 33 | '''[scenario: The install worker thread of InstallerQueue must only run when there are items on the queue]''' 34 | lock = RLock() # use inserted lock to prevent the installer thread from picking items 35 | test_queue = installer.InstallerQueue(lock) 36 | self.assertIsNone(test_queue.worker, "Worker thread should not be spawned by default") 37 | 38 | # use try-finally because of queue.clear() to stop worker 39 | try: 40 | lock.acquire() 41 | item = MagicMock() 42 | test_queue.put(item) 43 | self.assertIsInstance(test_queue.worker, Thread) 44 | self.assertTrue(test_queue.worker.is_alive(), "worker thread should be assigned and alive after put") 45 | spawned_worker = test_queue.worker 46 | 47 | # let the worker thread do its work 48 | lock.release() 49 | time.sleep(0.5) 50 | 51 | # then take the lock again 52 | lock.acquire() 53 | 54 | item.execute.assert_called_once() 55 | 56 | # worker must be dead and gone 57 | self.assertTrue(test_queue.empty(), "Queue should be empty again") 58 | self.assertFalse(spawned_worker.is_alive(), "Worker thread should be dead when the queue is empty") 59 | self.assertIsNone(test_queue.worker, "Worker thread should be gone") 60 | finally: 61 | test_queue.queue.clear() 62 | lock.release() 63 | 64 | @mock.patch('minigalaxy.installer.InstallerQueue') 65 | def test_enqueue_game_install_lazy_init(self, mock_queue_class): 66 | '''[scenario: The very first invocation of installer.enqueue_game_install creates the global InstallerQueue]''' 67 | 68 | installer.INSTALL_QUEUE = None 69 | 70 | queue_instance = MagicMock() 71 | mock_queue_class.return_value = queue_instance 72 | 73 | self.assertIsNone(installer.INSTALL_QUEUE, "Global INSTALL_QUEUE must not exist yet") 74 | game = Game("Beneath A Steel Sky", install_dir="/home/makson/GOG Games/Beneath a Steel Sky") 75 | installer.enqueue_game_install("42", MagicMock(), game, "/path/to/installer") 76 | 77 | self.assertIs(queue_instance, installer.INSTALL_QUEUE) 78 | queue_instance.put.assert_called_once() 79 | 80 | @mock.patch('minigalaxy.installer.install_game') 81 | def test_enqueue_game(self, mock_install): 82 | """[scenario: Game gets queued and ultimately runs into install_game]""" 83 | 84 | installer.INSTALL_QUEUE = None 85 | result_callback = MagicMock() 86 | 87 | game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") 88 | installer.enqueue_game_install(12345, result_callback, 89 | game, installer="adrift.exe", language="", install_dir="", 90 | keep_installers=False, create_desktop_file=True) 91 | time.sleep(0.5) 92 | lock = installer.INSTALL_QUEUE.state_lock 93 | with lock: 94 | mock_install.assert_called_once() 95 | result_callback.assert_called_once_with(installer.InstallResult( 96 | 12345, installer.InstallResultType.SUCCESS, "/home/makson/GOG Games/Absolute Drift" 97 | )) 98 | 99 | @mock.patch('minigalaxy.installer.install_game') 100 | def test_enqueue_game_failure(self, mock_install): 101 | """[scenario: Game gets queued and ultimately runs into install_game]""" 102 | 103 | installer.INSTALL_QUEUE = None 104 | result_callback = MagicMock() 105 | mock_install.side_effect = installer.InstallException("error") 106 | 107 | game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") 108 | installer.enqueue_game_install(12345, result_callback, 109 | game, installer="adrift.exe", language="", install_dir="", 110 | keep_installers=False, create_desktop_file=True) 111 | time.sleep(0.5) 112 | lock = installer.INSTALL_QUEUE.state_lock 113 | with lock: 114 | mock_install.assert_called_once() 115 | result_callback.assert_called_once_with(installer.InstallResult( 116 | 12345, installer.InstallResultType.FAILURE, "error" 117 | )) 118 | 119 | def test_InstallTask_init_requires_callback(self): 120 | '''[scenario: InstallTask__init__ enforces result_callback to be a callable]''' 121 | 122 | game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") 123 | with self.assertRaises(ValueError) as cm: 124 | installer.InstallTask(815, "not-a-callable", game) 125 | # the search for a Game instance happens before the check of callback, so assert the message as well 126 | self.assertEqual("result_callback is required", str(cm.exception)) 127 | 128 | def test_InstallTask_init_requires_Game(self): 129 | '''[scenario: InstallTask__init__ must have received a Game instance as part of *args or **kwargs]''' 130 | 131 | def callback(): pass 132 | 133 | game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") 134 | 135 | with self.assertRaises(ValueError) as cm: 136 | installer.InstallTask(815, callback) 137 | self.assertEqual("No instance of Game in InstallTask constructor arguments", str(cm.exception)) 138 | 139 | # counter-test: pass game as part of kwargs, it should not raise an exception 140 | installer.InstallTask(815, callback, game=game) 141 | -------------------------------------------------------------------------------- /tests/test_ui_library.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import uuid 5 | from unittest import TestCase, mock 6 | from unittest.mock import MagicMock, patch, mock_open 7 | import tempfile 8 | 9 | m_gtk = MagicMock() 10 | m_gi = MagicMock() 11 | m_window = MagicMock() 12 | m_preferences = MagicMock() 13 | m_gametile = MagicMock() 14 | m_gametilelist = MagicMock() 15 | m_categoryfilters = MagicMock() 16 | 17 | 18 | class UnitTestGtkTemplate: 19 | 20 | def __init__(self): 21 | self.Child = m_gtk 22 | 23 | def from_file(self, lib_file): 24 | def passthrough(func): 25 | def passthrough2(*args, **kwargs): 26 | return func(*args, **kwargs) 27 | return passthrough2 28 | return passthrough 29 | 30 | 31 | class UnitTestGiRepository: 32 | 33 | class Gtk: 34 | 35 | Template = UnitTestGtkTemplate() 36 | Widget = m_gtk 37 | 38 | class Viewport: 39 | pass 40 | 41 | class Gdk: 42 | pass 43 | 44 | class GdkPixbuf: 45 | pass 46 | 47 | class Gio: 48 | pass 49 | 50 | class GLib: 51 | pass 52 | 53 | class Notify: 54 | pass 55 | 56 | 57 | u_gi_repository = UnitTestGiRepository() 58 | sys.modules['gi.repository'] = u_gi_repository 59 | sys.modules['gi'] = m_gi 60 | sys.modules['minigalaxy.ui.window'] = m_window 61 | sys.modules['minigalaxy.ui.preferences'] = m_preferences 62 | sys.modules['minigalaxy.ui.gametile'] = m_gametile 63 | sys.modules['minigalaxy.ui.gametilelist'] = m_gametilelist 64 | sys.modules['minigalaxy.ui.categoryfilters'] = m_categoryfilters 65 | from minigalaxy.game import Game # noqa: E402 66 | from minigalaxy.ui.library import Library, get_installed_windows_games, read_game_categories_file, \ 67 | update_game_categories_file # noqa: E402 68 | 69 | SELF_GAMES = {"Neverwinter Nights: Enhanced Edition": "1097893768", "Beneath A Steel Sky": "1207658695", 70 | "Stellaris (English)": "1508702879"} 71 | API_GAMES = {"Neverwinter Nights: Enhanced Edition": "1097893768", "Beneath a Steel Sky": "1207658695", 72 | "Dragonsphere": "1207658927", "Warsow": "1207659121", "Outlast": "1207660064", "Xenonauts": "1207664803", 73 | "Wasteland 2": "1207665783", "Baldur's Gate: Enhanced Edition": "1207666353", 74 | "Baldur's Gate II: Enhanced Edition": "1207666373", "Toonstruck": "1207666633", 75 | "Icewind Dale: Enhanced Edition": "1207666683", "Pillars of Eternity": "1207666813", 76 | "Grim Fandango Remastered": "1207667183", "Knights of Pen and Paper +1 Edition": "1320675280", 77 | "Sunless Sea": "1421064427", "Dungeons 2": "1436885138", "Wasteland 2 Director's Cut": "1444386007", 78 | "Stellaris": "1508702879", "Butcher": "1689871374", "Reigns: Game of Thrones": "2060365190"} 79 | 80 | 81 | class TestLibrary(TestCase): 82 | 83 | mock_config = MagicMock() 84 | mock_config.locale = "en" 85 | 86 | def test1_add_games_from_api(self): 87 | self_games = [] 88 | for game in SELF_GAMES: 89 | self_games.append(Game(name=game, game_id=int(SELF_GAMES[game]),)) 90 | api_games = [] 91 | for game in API_GAMES: 92 | api_games.append(Game(name=game, game_id=int(API_GAMES[game]),)) 93 | err_msg = "" 94 | api_mock = MagicMock() 95 | api_mock.get_library.return_value = api_games, err_msg 96 | test_library = Library(MagicMock(), self.mock_config, api_mock, MagicMock()) 97 | test_library.games = self_games 98 | test_library._Library__add_games_from_api() 99 | exp = len(API_GAMES) 100 | obs = len(test_library.games) 101 | self.assertEqual(exp, obs) 102 | 103 | def test2_add_games_from_api(self): 104 | self_games = [] 105 | for game in SELF_GAMES: 106 | self_games.append(Game(name=game, game_id=int(SELF_GAMES[game]),)) 107 | api_games = [] 108 | for game in API_GAMES: 109 | api_games.append(Game(name=game, game_id=int(API_GAMES[game]),)) 110 | err_msg = "" 111 | api_mock = MagicMock() 112 | api_mock.get_library.return_value = api_games, err_msg 113 | test_library = Library(MagicMock(), self.mock_config, api_mock, MagicMock()) 114 | test_library.games = self_games 115 | test_library._Library__add_games_from_api() 116 | exp = True 117 | obs = Game(name="Stellaris (English)", game_id=1508702879,) in test_library.games 118 | self.assertEqual(exp, obs) 119 | 120 | def test3_add_games_from_api(self): 121 | self_games = [] 122 | for game in SELF_GAMES: 123 | self_games.append(Game(name=game, game_id=int(SELF_GAMES[game]),)) 124 | self_games.append(Game(name="Game without ID", game_id=0)) 125 | api_games = [] 126 | for game in API_GAMES: 127 | api_games.append(Game(name=game, game_id=int(API_GAMES[game]),)) 128 | api_gmae_with_id = Game(name="Game without ID", game_id=1234567890) 129 | api_games.append(api_gmae_with_id) 130 | err_msg = "" 131 | api_mock = MagicMock() 132 | api_mock.get_library.return_value = api_games, err_msg 133 | test_library = Library(MagicMock(), self.mock_config, api_mock, MagicMock()) 134 | test_library.games = self_games 135 | test_library._Library__add_games_from_api() 136 | exp = True 137 | obs = api_gmae_with_id in test_library.games 138 | self.assertEqual(exp, obs) 139 | exp = len(api_games) 140 | obs = len(test_library.games) 141 | self.assertEqual(exp, obs) 142 | 143 | def test4_add_games_from_api(self): 144 | self_games = [] 145 | for game in SELF_GAMES: 146 | self_games.append(Game(name=game, game_id=int(SELF_GAMES[game]),)) 147 | api_games = [] 148 | url_nr = 1 149 | for game in API_GAMES: 150 | api_games.append(Game(name=game, game_id=int(API_GAMES[game]), url="http://test_url{}".format(str(url_nr)))) 151 | url_nr += 1 152 | err_msg = "" 153 | api_mock = MagicMock() 154 | api_mock.get_library.return_value = api_games, err_msg 155 | test_library = Library(MagicMock(), self.mock_config, api_mock, MagicMock()) 156 | test_library.games = self_games 157 | test_library._Library__add_games_from_api() 158 | exp = "http://test_url1" 159 | obs = test_library.games[0].url 160 | self.assertEqual(exp, obs) 161 | 162 | def test5_add_games_from_api(self): 163 | self_games = [] 164 | for game in SELF_GAMES: 165 | self_games.append(Game(name="{}_diff".format(game), game_id=int(SELF_GAMES[game]),)) 166 | api_games = [] 167 | for game in API_GAMES: 168 | api_games.append(Game(name=game, game_id=int(API_GAMES[game]))) 169 | err_msg = "" 170 | api_mock = MagicMock() 171 | api_mock.get_library.return_value = api_games, err_msg 172 | test_library = Library(MagicMock(), self.mock_config, api_mock, MagicMock()) 173 | test_library.games = self_games 174 | test_library._Library__add_games_from_api() 175 | exp = "Neverwinter Nights: Enhanced Edition" 176 | obs = test_library.games[0].name 177 | self.assertEqual(exp, obs) 178 | 179 | def test6_add_games_from_api(self): 180 | self_games = [Game(name="Torchlight 2", game_id=0, install_dir="/home/user/GoG Games/Torchlight II")] 181 | api_games = [Game(name="Torchlight II", game_id=1958228073)] 182 | err_msg = "" 183 | api_mock = MagicMock() 184 | api_mock.get_library.return_value = api_games, err_msg 185 | test_library = Library(MagicMock(), self.mock_config, api_mock, MagicMock()) 186 | test_library.games = self_games 187 | test_library._Library__add_games_from_api() 188 | exp = 1 189 | obs = len(test_library.games) 190 | self.assertEqual(exp, obs) 191 | 192 | @mock.patch('os.listdir') 193 | def test1_get_installed_windows_game(self, mock_listdir): 194 | mock_listdir.return_value = ["goggame-1207665883.info"] 195 | # none-empty list of playTasks needed so that library recognizes it as installed game 196 | game_json_data = '{ "gameId": "1207665883", "name": "Aliens vs Predator Classic 2000", "playTasks":[{}]}'.encode('utf-8') 197 | with patch("builtins.open", mock_open(read_data=game_json_data)): 198 | games = get_installed_windows_games("/example/path") 199 | exp = "Aliens vs Predator Classic 2000" 200 | obs = games[0].name 201 | self.assertEqual(exp, obs) 202 | 203 | @mock.patch('os.listdir') 204 | def test2_get_installed_windows_game(self, mock_listdir): 205 | mock_listdir.return_value = ["goggame-1207665883.info"] 206 | # none-empty list of playTasks needed so that library recognizes it as installed game 207 | game_json_data = '{ "gameId": "1207665883", "name": "Aliens vs Predator Classic 2000", "playTasks":[{}]}'.encode('utf-8-sig') 208 | with patch("builtins.open", mock_open(read_data=game_json_data)): 209 | games = get_installed_windows_games("/example/path") 210 | exp = "Aliens vs Predator Classic 2000" 211 | obs = games[0].name 212 | self.assertEqual(exp, obs) 213 | 214 | def test_read_game_categories_file_should_return_populated_dict(self): 215 | with tempfile.NamedTemporaryFile(mode='w+t', delete=False) as tmpfile: 216 | tmpfile.write('{"Test Game":"Adventure"}') 217 | tmpfile.flush() 218 | 219 | actual = read_game_categories_file(tmpfile.name) 220 | 221 | self.assertTrue(len(actual)) 222 | self.assertEqual(actual, {'Test Game': 'Adventure'}) 223 | 224 | @mock.patch('os.path.exists') 225 | def test_update_game_categories_file_should_skip_for_empty_dict(self, mock_path_exists: MagicMock): 226 | mock_path_exists.side_effect = Exception("Test error") 227 | 228 | update_game_categories_file({}, None) 229 | 230 | self.assertFalse(mock_path_exists.called) 231 | 232 | def test_update_game_categories_file_should_create_file_if_not_found(self): 233 | initially_non_existent_file = f'/tmp/{uuid.uuid4()}.json' 234 | self.assertFalse(os.path.exists(initially_non_existent_file)) 235 | expected = {'Test game': 'Adventure'} 236 | 237 | update_game_categories_file(expected, initially_non_existent_file) 238 | 239 | self.assertTrue(os.path.exists(initially_non_existent_file)) 240 | self.assertDictEqual(expected, read_game_categories_file(initially_non_existent_file)) 241 | 242 | def test_update_game_categories_file_should_skip_if_file_found_with_identical_contents(self): 243 | expected = {"Test Game": "Adventure"} 244 | with tempfile.NamedTemporaryFile(mode='r+t', delete=False) as tmpfile: 245 | json.dump(expected, tmpfile) 246 | tmpfile.flush() 247 | 248 | update_game_categories_file(expected, tmpfile.name) 249 | 250 | tmpfile.seek(os.SEEK_SET) 251 | actual = json.load(tmpfile) 252 | self.assertDictEqual(actual, expected) 253 | 254 | def test_update_game_categories_file_should_overwrite_file_if_contents_differ(self): 255 | with tempfile.NamedTemporaryFile(mode='w+t', delete=False) as tmpfile: 256 | tmpfile.write('{"Test Game":"Adventure"}') 257 | tmpfile.flush() 258 | expected = {"Test Game": "Adventure", "Another Game": "Strategy"} 259 | 260 | update_game_categories_file(expected, tmpfile.name) 261 | 262 | tmpfile.seek(os.SEEK_SET) 263 | actual = json.load(tmpfile) 264 | self.assertDictEqual(actual, expected) 265 | 266 | 267 | del sys.modules['gi'] 268 | del sys.modules['gi.repository'] 269 | del sys.modules['minigalaxy.ui.window'] 270 | del sys.modules['minigalaxy.ui.preferences'] 271 | del sys.modules['minigalaxy.ui.gametile'] 272 | del sys.modules['minigalaxy.ui.gametilelist'] 273 | del sys.modules['minigalaxy.ui.categoryfilters'] 274 | -------------------------------------------------------------------------------- /tests/test_ui_window.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import TestCase 3 | from unittest.mock import MagicMock, patch 4 | from simplejson.errors import JSONDecodeError 5 | 6 | m_gtk = MagicMock() 7 | m_gi = MagicMock() 8 | m_library = MagicMock() 9 | m_download_list = MagicMock() 10 | m_preferences = MagicMock() 11 | m_login = MagicMock() 12 | m_about = MagicMock() 13 | m_categoryfilters = MagicMock() 14 | 15 | 16 | class UnitTestGtkTemplate: 17 | 18 | def __init__(self): 19 | self.Child = m_gtk 20 | 21 | def from_file(self, lib_file): 22 | def passthrough(func): 23 | def passthrough2(*args, **kwargs): 24 | return func(*args, **kwargs) 25 | return passthrough2 26 | return passthrough 27 | 28 | Callback = MagicMock() 29 | 30 | 31 | class UnitTestGiRepository: 32 | 33 | class Gtk: 34 | Template = UnitTestGtkTemplate() 35 | Widget = MagicMock() 36 | Settings = MagicMock() 37 | ResponseType = MagicMock() 38 | 39 | class ApplicationWindow: 40 | def __init__(self, title): 41 | pass 42 | 43 | set_default_icon_list = MagicMock() 44 | show_all = MagicMock() 45 | 46 | Gdk = MagicMock() 47 | GdkPixbuf = MagicMock() 48 | Gio = MagicMock() 49 | GLib = MagicMock 50 | Notify = MagicMock() 51 | 52 | 53 | u_gi_repository = UnitTestGiRepository() 54 | sys.modules['gi.repository'] = u_gi_repository 55 | sys.modules['gi'] = m_gi 56 | sys.modules['minigalaxy.ui.download_list'] = m_download_list 57 | sys.modules['minigalaxy.ui.library'] = m_library 58 | sys.modules['minigalaxy.ui.preferences'] = m_preferences 59 | sys.modules['minigalaxy.ui.login'] = m_login 60 | sys.modules['minigalaxy.ui.about'] = m_about 61 | sys.modules['minigalaxy.ui.gtk'] = u_gi_repository 62 | sys.modules['minigalaxy.ui.categoryfilters'] = m_categoryfilters 63 | from minigalaxy.ui.window import Window # noqa: E402 64 | 65 | 66 | class TestWindow(TestCase): 67 | def test1_init(self): 68 | with patch('minigalaxy.ui.window.Api.can_connect', return_value=False): 69 | config = MagicMock() 70 | config.locale = "en_US.UTF-8" 71 | config.keep_window_maximized = False 72 | api = MagicMock() 73 | api.can_connect.return_value = False 74 | test_window = Window(api=api, config=config, download_manager=MagicMock()) 75 | exp = True 76 | obs = test_window.offline 77 | self.assertEqual(exp, obs) 78 | 79 | def test2_init(self): 80 | config = MagicMock() 81 | config.locale = "en_US.UTF-8" 82 | config.keep_window_maximized = False 83 | api = MagicMock() 84 | api.authenticate.return_value = True 85 | test_window = Window(api=api, config=config, download_manager=MagicMock()) 86 | exp = False 87 | obs = test_window.offline 88 | self.assertEqual(exp, obs) 89 | api.authenticate.assert_called_once() 90 | 91 | def test3_init(self): 92 | config = MagicMock() 93 | config.locale = "en_US.UTF-8" 94 | config.keep_window_maximized = False 95 | api = MagicMock() 96 | api.authenticate.side_effect = JSONDecodeError(msg='mock', doc='mock', pos=0) 97 | test_window = Window(api=api, config=config, download_manager=MagicMock()) 98 | exp = True 99 | obs = test_window.offline 100 | self.assertEqual(exp, obs) 101 | 102 | 103 | del sys.modules['gi'] 104 | del sys.modules['gi.repository'] 105 | del sys.modules['minigalaxy.ui.library'] 106 | del sys.modules['minigalaxy.ui.preferences'] 107 | del sys.modules['minigalaxy.ui.login'] 108 | del sys.modules['minigalaxy.ui.about'] 109 | del sys.modules['minigalaxy.ui.gtk'] 110 | --------------------------------------------------------------------------------