├── .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 | 
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 |
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 |
6 | False
7 | dialog
8 | Minigalaxy
9 | Copyright © 2019-2020 Wouter Wijsman
10 | A simple GOG client for Linux
11 | https://github.com/sharkwouter/minigalaxy
12 | GitHub page
13 | <a href="https://github.com/sharkwouter">Wouter Wijsman</a>
14 | <a href="https://github.com/makson96">Tomasz Makarewicz</a>
15 | <a href="https://github.com/Odelpasso">Maxime Lombard</a>
16 | <a href="https://github.com/SvdB-nonp">SvdB-nonp</a>
17 | <a href="https://github.com/larslindq">Lars Lindqvist</a>
18 | <a href="https://github.com/TotalCaesar659">TotalCaesar659</a>
19 | <a href="https://github.com/tim77">Artem Polishchuk</a>
20 | <a href="https://github.com/BlindJerobine">BlindJerobine</a>
21 | <a href="https://github.com/JoshuaFern">Joshua Fern</a>
22 | <a href="https://github.com/stephanlachnit">Stephan Lachnit</a>
23 | <a href="https://github.com/graag">Konrad Klimaszewski</a>
24 | <a href="https://github.com/lmeunier">Laurent Meunier</a>
25 | <a href="https://github.com/zweif">zweif</a>
26 | <a href="https://github.com/phlash">Phil Ashby</a>
27 | <a href="https://github.com/mareksapota">Marek Sapota</a>
28 | <a href="https://github.com/zocker-160">zocker-160</a>
29 | <a href="https://github.com/waltercool">WalterCool</a>
30 | <a href="https://github.com/jgerrish">Joshua Gerrish</a>
31 | <a href="https://github.com/LeXofLeviafan">LeXofLeviafan</a>
32 | <a href="https://github.com/orende">orende</a>
33 | <a href="https://github.com/viacheslavka">slavka</a>
34 | <a href="https://github.com/slowsage">slowsage</a>
35 | <a href="https://github.com/GB609">GB609</a>
36 | <a href="https://github.com/ArturWroblewski">Artur Wróblewski</a>
37 | <a href="https://github.com/Pyrofani">Athanasios Nektarios Karachalios Stagkas</a>
38 | <a href="https://github.com/BlindJerobine">BlindJerobine</a>
39 | <a href="https://github.com/EsdrasTarsis">Esdras Tarsis</a>
40 | <a href="https://github.com/fuzunspm">Hüseyin Fahri Uzun</a>
41 | <a href="https://github.com/LordPilum">Jan Kjetil Myklebust</a>
42 | <a href="https://github.com/s8321414">Jeff Huang</a>
43 | <a href="https://github.com/kimmalmo">kimmalmo</a>
44 | <a href="https://github.com/protheory8">ProTheory8</a>
45 | <a href="https://github.com/thomasb22">thomasb22</a>
46 | <a href="https://github.com/tim77">Artem Polishchuk</a>
47 | <a href="https://github.com/dummyx">dummyx</a>
48 | <a href="https://github.com/juanborda">JB</a>
49 | <a href="https://github.com/mbarrio">Miguel Barrio Orsikowsky</a>
50 | <a href="https://github.com/Newbytee">Newbytee</a>
51 | <a href="https://github.com/jakbuz23">jakbuz23</a>
52 | <a href="https://github.com/heidiwenger">heidiwenger</a>
53 | <a href="https://github.com/jonnelafin">Elias Eskelinen</a>
54 | <a href="https://github.com/koraynilay">koraynilay</a>
55 | <a href="https://github.com/karaushu">Andrew Karaushu</a>
56 | <a href="https://github.com/zweif">zweif</a>
57 | <a href="https://github.com/LocalPinkRobin">María Sánchez</a>
58 | <a href="https://github.com/advy99">Antonio David Villegas Yeguas</a>
59 | <a href="https://github.com/manurtinez">Manu Martinez</a>
60 | <a href="https://github.com/Unrud">Unrud</a>
61 | <a href="https://github.com/GLSWV">GLSWV</a>
62 | <a href="https://opengameart.org/users/epic-runes">Epic Runes</a>
63 | image-missing
64 | gpl-3-0
65 |
66 |
67 | False
68 | vertical
69 | 2
70 |
71 |
72 | False
73 | end
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | False
83 | False
84 | 0
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/data/ui/application.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | False
7 |
8 |
9 |
10 |
11 |
77 |
78 | False
79 | center
80 | 1400
81 | 800
82 |
83 |
84 |
85 | True
86 | True
87 | in
88 |
89 |
90 |
91 |
92 |
93 |
94 |
221 |
222 |
223 |
224 |
--------------------------------------------------------------------------------
/data/ui/categoryfilters.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | False
6 | 400
7 | dialog
8 |
9 |
10 |
11 |
12 |
13 | False
14 | 18
15 |
16 |
17 | True
18 | False
19 | 6
20 | 12
21 | True
22 | True
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | True
32 | False
33 | horizontal
34 | 5
35 | end
36 | start
37 | False
38 | False
39 |
40 |
41 | True
42 | True
43 | True
44 | Reset
45 |
46 |
47 |
48 | True
49 | False
50 | start
51 |
52 |
53 |
54 |
55 | True
56 | True
57 | True
58 | Cancel
59 |
60 |
61 |
62 | True
63 | False
64 | start
65 |
66 |
67 |
68 |
69 | True
70 | True
71 | True
72 | Apply
73 |
74 |
75 |
76 | True
77 | False
78 | start
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
--------------------------------------------------------------------------------
/data/ui/download_action_buttons.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | True
7 | False
8 | 3
9 | 3
10 | 3
11 | 3
12 | True
13 |
14 |
15 | True
16 | True
17 | True
18 | center
19 | center
20 | none
21 |
22 |
23 |
24 | True
25 | False
26 | center
27 | media-playback-pause
28 | 3
29 |
30 |
31 |
32 |
33 | False
34 | False
35 | 0
36 |
37 |
38 |
39 |
40 | True
41 | True
42 | True
43 | center
44 | center
45 | none
46 |
47 |
48 |
49 | True
50 | False
51 | center
52 | media-playback-stop
53 | 3
54 |
55 |
56 |
57 |
58 | False
59 | True
60 | 1
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/data/ui/download_list_entry.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 300
7 | True
8 | False
9 | start
10 | 1
11 | 1
12 | 1
13 | 1
14 |
15 |
16 | True
17 | False
18 | center
19 | 3
20 | 3
21 | 3
22 | 3
23 | application-x-executable
24 | 3
25 |
26 |
27 | False
28 | False
29 | 0
30 |
31 |
32 |
33 |
34 | True
35 | False
36 | center
37 | 3
38 | 3
39 | 3
40 | 3
41 | vertical
42 |
43 |
44 | True
45 | False
46 | Game title placeholder
47 | 0
48 |
49 |
50 | False
51 | True
52 | 0
53 |
54 |
55 |
56 |
57 | True
58 | False
59 |
60 |
61 | True
62 | False
63 | center
64 |
65 |
66 | True
67 | True
68 | 0
69 |
70 |
71 |
72 |
73 | False
74 | end
75 | start
76 | 5
77 | 5
78 | label_size
79 |
80 |
81 | False
82 | False
83 | end
84 | 1
85 |
86 |
87 |
88 |
89 | False
90 | True
91 | 1
92 |
93 |
94 |
95 |
96 | True
97 | True
98 | 1
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
--------------------------------------------------------------------------------
/data/ui/filterswitch.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | filterswitch
6 | True
7 | False
8 | start
9 | start
10 | False
11 | False
12 | horizontal
13 | 10
14 |
15 |
16 | True
17 | True
18 | end
19 | center
20 |
21 |
22 |
23 |
24 | True
25 | False
26 | start
27 | Label text here
28 | fill
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/data/ui/information.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | False
7 | 400
8 | 250
9 | dialog
10 |
11 |
12 |
13 |
14 |
15 | False
16 | horizontal
17 | 18
18 |
19 |
20 | False
21 |
22 |
23 | False
24 | False
25 | 0
26 |
27 |
28 |
29 |
30 | True
31 | False
32 | gtk-missing-image
33 |
34 |
35 | False
36 | True
37 | 0
38 |
39 |
40 |
41 |
42 | True
43 | False
44 | vertical
45 | 18
46 |
47 |
48 | True
49 | False
50 | 6
51 | True
52 |
53 |
54 | True
55 | False
56 | 18
57 | 6
58 | 12
59 | True
60 | True
61 |
62 |
63 | Support
64 | True
65 | True
66 | True
67 |
68 |
69 |
70 | 0
71 | 0
72 |
73 |
74 |
75 |
76 | Store
77 | True
78 | True
79 | True
80 |
81 |
82 |
83 | 1
84 | 0
85 |
86 |
87 |
88 |
89 | Forum
90 | True
91 | True
92 | True
93 |
94 |
95 |
96 | 0
97 | 1
98 |
99 |
100 |
101 |
102 | GOG Database
103 | True
104 | True
105 | True
106 |
107 |
108 |
109 | 1
110 | 1
111 |
112 |
113 |
114 |
115 | PCGamingWiki
116 | True
117 | True
118 | True
119 |
120 |
121 |
122 | 0
123 | 2
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 | False
132 | True
133 | 1
134 |
135 |
136 |
137 |
138 | False
139 | True
140 | 0
141 |
142 |
143 |
144 |
145 | True
146 | False
147 | True
148 | True
149 |
150 |
151 | False
152 | True
153 | 1
154 |
155 |
156 |
157 |
158 | True
159 | False
160 | 18
161 | 12
162 | end
163 |
164 |
165 | OK
166 | True
167 | True
168 | True
169 |
170 |
171 |
172 | True
173 | True
174 | 1
175 |
176 |
177 |
178 |
179 | False
180 | True
181 | 2
182 |
183 |
184 |
185 |
186 | False
187 | True
188 | 2
189 |
190 |
191 |
192 |
193 |
194 |
195 |
--------------------------------------------------------------------------------
/data/ui/library.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | True
7 | False
8 |
9 |
10 | True
11 | False
12 | start
13 | 15
14 | True
15 | 15
16 | 10
17 | 100
18 | none
19 | False
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/data/ui/login.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | False
7 | True
8 | 390
9 | 500
10 | dialog
11 |
12 |
13 | False
14 | vertical
15 | 2
16 |
17 |
18 | False
19 | end
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | False
29 | False
30 | 0
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
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 "" >> "${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; 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 |
--------------------------------------------------------------------------------