├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ └── publish-release.yml ├── .gitignore ├── .pylintrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build-aux ├── flatpak │ └── page.kramo.Cartridges.Devel.json ├── macos │ ├── cartridges.spec │ └── icon.icns └── windows │ ├── Cartridges.iss.in │ ├── icon.ico │ └── meson.build ├── cartridges.doap ├── cartridges ├── __builtins__.pyi ├── application_delegate.py ├── cartridges.in ├── details_dialog.py ├── errors │ ├── error_producer.py │ └── friendly_error.py ├── game.py ├── game_cover.py ├── importer │ ├── bottles_source.py │ ├── desktop_source.py │ ├── flatpak_source.py │ ├── heroic_source.py │ ├── importer.py │ ├── itch_source.py │ ├── legendary_source.py │ ├── location.py │ ├── lutris_source.py │ ├── retroarch_source.py │ ├── source.py │ └── steam_source.py ├── logging │ ├── color_log_formatter.py │ ├── session_file_handler.py │ └── setup.py ├── main.py ├── meson.build ├── preferences.py ├── shared.py.in ├── shared.pyi ├── store │ ├── managers │ │ ├── async_manager.py │ │ ├── cover_manager.py │ │ ├── display_manager.py │ │ ├── file_manager.py │ │ ├── manager.py │ │ ├── sgdb_manager.py │ │ └── steam_api_manager.py │ ├── pipeline.py │ └── store.py ├── utils │ ├── create_dialog.py │ ├── rate_limiter.py │ ├── relative_date.py │ ├── run_executable.py │ ├── save_cover.py │ ├── sqlite.py │ ├── steam.py │ └── steamgriddb.py └── window.py ├── data ├── cartridges.gresource.xml.in ├── gtk │ ├── details-dialog.blp │ ├── game.blp │ ├── help-overlay.blp │ ├── preferences.blp │ ├── style-dark.css │ ├── style.css │ └── window.blp ├── icons │ ├── hicolor │ │ ├── scalable │ │ │ └── apps │ │ │ │ ├── page.kramo.Cartridges.Devel.svg │ │ │ │ └── page.kramo.Cartridges.svg │ │ └── symbolic │ │ │ └── apps │ │ │ ├── page.kramo.Cartridges-symbolic.svg │ │ │ └── page.kramo.Cartridges.Devel-symbolic.svg │ ├── meson.build │ └── sources │ │ ├── bottles-source-symbolic.svg │ │ ├── flatpak-source-symbolic.svg │ │ ├── heroic-source-symbolic.svg │ │ ├── itch-source-symbolic.svg │ │ ├── legendary-source-symbolic.svg │ │ ├── lutris-source-symbolic.svg │ │ ├── retroarch-source-symbolic.svg │ │ └── steam-source-symbolic.svg ├── library_placeholder.svg ├── library_placeholder_small.svg ├── meson.build ├── page.kramo.Cartridges.desktop.in ├── page.kramo.Cartridges.gschema.xml.in ├── page.kramo.Cartridges.metainfo.xml.in └── screenshots │ ├── 1.png │ ├── 2.png │ ├── 3.png │ └── 4.png ├── docs └── game_id.json.md ├── meson.build ├── meson_options.txt ├── po ├── LINGUAS ├── POTFILES ├── ar.po ├── be.po ├── ca.po ├── cartridges.pot ├── cs.po ├── de.po ├── el.po ├── en_GB.po ├── es.po ├── fa.po ├── fi.po ├── fr.po ├── hi.po ├── hr.po ├── hu.po ├── ia.po ├── ie.po ├── it.po ├── ja.po ├── ko.po ├── meson.build ├── nb_NO.po ├── nl.po ├── pl.po ├── pt.po ├── pt_BR.po ├── ro.po ├── ru.po ├── sv.po ├── ta.po ├── te.po ├── tr.po ├── uk.po └── zh_Hans.po ├── pyrightconfig.json ├── search-provider ├── cartridges-search-provider.in ├── meson.build ├── page.kramo.Cartridges.SearchProvider.ini └── page.kramo.Cartridges.SearchProvider.service.in └── subprojects └── blueprint-compiler.wrap /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [kra-mo] 2 | liberapay: kramo 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Logs** 27 | If applicable, attatch your logs from `Main Menu > About Cartridges > Troubleshooting > Debugging Information` to the issue. 28 | 29 | **System (please complete the following information):** 30 | - OS: [e.g. Fedora Linux] 31 | - Installation method [e.g. Flatpak] 32 | - Cartridges version [e.g. 1.5.4] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | 7 | concurrency: 8 | group: release-${{ github.sha }} 9 | jobs: 10 | flatpak: 11 | name: Flatpak 12 | runs-on: ubuntu-latest 13 | container: 14 | image: bilelmoussaoui/flatpak-github-actions:gnome-47 15 | options: --privileged 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Flatpak Builder 21 | uses: flatpak/flatpak-github-actions/flatpak-builder@v6.5 22 | with: 23 | bundle: page.kramo.Cartridges.Devel.flatpak 24 | manifest-path: build-aux/flatpak/page.kramo.Cartridges.Devel.json 25 | 26 | windows: 27 | name: Windows 28 | runs-on: windows-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup MSYS2 34 | uses: msys2/setup-msys2@v2 35 | with: 36 | msystem: UCRT64 37 | update: true 38 | install: mingw-w64-ucrt-x86_64-gtk4 mingw-w64-ucrt-x86_64-libadwaita mingw-w64-ucrt-x86_64-python-gobject mingw-w64-ucrt-x86_64-python-yaml mingw-w64-ucrt-x86_64-python-requests mingw-w64-ucrt-x86_64-python-pillow mingw-w64-ucrt-x86_64-desktop-file-utils mingw-w64-ucrt-x86_64-ca-certificates mingw-w64-ucrt-x86_64-meson git 39 | 40 | - name: Compile 41 | shell: msys2 {0} 42 | run: | 43 | meson setup _build 44 | ninja -C _build install 45 | pacman --noconfirm -Rs mingw-w64-ucrt-x86_64-desktop-file-utils mingw-w64-ucrt-x86_64-meson git 46 | 47 | - name: Test 48 | shell: msys2 {0} 49 | run: | 50 | set +e 51 | timeout 2 cartridges; [ "$?" -eq "124" ] 52 | 53 | - name: Inno Setup 54 | run: iscc ".\_build\build-aux\windows\Cartridges.iss" 55 | 56 | - name: Upload Artifact 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: Windows Installer 60 | path: _build/build-aux/windows/Output/Cartridges Windows.exe 61 | 62 | macos: 63 | name: macOS 64 | runs-on: macos-latest 65 | steps: 66 | - name: Checkout 67 | uses: actions/checkout@v4 68 | 69 | - name: Set up Homebrew 70 | id: set-up-homebrew 71 | uses: Homebrew/actions/setup-homebrew@master 72 | 73 | - name: Install Dependencies 74 | run: | 75 | brew install meson pygobject3 libadwaita adwaita-icon-theme desktop-file-utils pyinstaller pillow 76 | pip3 install --break-system-packages requests PyYAML pyobjc 77 | 78 | - name: Meson Build 79 | run: | 80 | meson setup _build -Dtiff_compression=jpeg 81 | ninja install -C _build 82 | 83 | - name: PyInstaller 84 | env: 85 | PYTHONPATH: /opt/homebrew/opt/homebrew/lib/python3.12/site-packages 86 | run: | 87 | cd build-aux/macos 88 | pyinstaller ./cartridges.spec 89 | 90 | - name: Zip 91 | run: | 92 | cd build-aux/macos/dist 93 | zip -yr Cartridges\ macOS.zip Cartridges.app 94 | 95 | - name: Upload Artifact 96 | uses: actions/upload-artifact@v4 97 | with: 98 | path: build-aux/macos/dist/Cartridges macOS.zip 99 | name: macOS Application 100 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | on: 3 | push: 4 | tags: "*" 5 | 6 | concurrency: 7 | group: release-${{ github.sha }} 8 | 9 | jobs: 10 | publish-release: 11 | name: Publish Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Download workflow artifact 18 | uses: dawidd6/action-download-artifact@v9 19 | with: 20 | workflow: ci.yml 21 | commit: ${{ github.sha }} 22 | 23 | - name: Get release notes 24 | shell: python 25 | run: | 26 | import re, textwrap 27 | open_file = open("./data/page.kramo.Cartridges.metainfo.xml.in", "r", encoding="utf-8") 28 | string = open_file.read() 29 | open_file.close() 30 | string = re.findall("\s*\n([\s\S]*?)\s*\s*<\/release>", string)[0] 31 | string = textwrap.dedent(string) 32 | open_file = open("release_notes", "w", encoding="utf-8") 33 | open_file.write(string) 34 | open_file.close() 35 | 36 | - name: Get tag name 37 | id: get_tag_name 38 | run: echo tag_name=${GITHUB_REF#refs/tags/} >> $GITHUB_OUTPUT 39 | 40 | - name: Publish release 41 | uses: softprops/action-gh-release@v2.2.2 42 | with: 43 | files: | 44 | Windows Installer/Cartridges Windows.exe 45 | macOS Application/Cartridges macOS.zip 46 | fail_on_unmatched_files: true 47 | tag_name: ${{ steps.get_tag_name.outputs.tag_name }} 48 | body_path: release_notes 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build-aux/flatpak/page.kramo.Cartridges.json 2 | /subprojects/blueprint-compiler 3 | /build-aux/macos/build 4 | /build-aux/macos/dist 5 | /.flatpak 6 | /.flatpak-builder 7 | /.vscode 8 | .DS_Store 9 | .prettierignore 10 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | ignore=importers 4 | 5 | 6 | [MESSAGES CONTROL] 7 | 8 | disable=raw-checker-failed, 9 | bad-inline-option, 10 | locally-disabled, 11 | file-ignored, 12 | suppressed-message, 13 | useless-suppression, 14 | deprecated-pragma, 15 | use-symbolic-message-instead, 16 | too-few-public-methods, 17 | missing-function-docstring, 18 | missing-class-docstring, 19 | missing-module-docstring, 20 | relative-beyond-top-level, 21 | import-error, 22 | no-name-in-module 23 | 24 | 25 | [TYPECHECK] 26 | 27 | ignored-classes=Child 28 | 29 | 30 | [VARIABLES] 31 | 32 | additional-builtins=_ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | The project follows the [GNOME Code of Conduct](https://conduct.gnome.org/). 2 | 3 | If you believe that someone is violating the Code of Conduct, or have any other concerns, please contact us via [cartridges-community@kramo.page](mailto:cartridges-community@kramo.page). 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Code 4 | 5 | Be sure to follow the [code style](#code-style) of the project. 6 | 7 | ### Adding a feature 8 | [Create an issue](https://git.kramo.page/cartridges/issues/new) or join the [Discord](https://discord.gg/4KSFh3AmQR)/[Matrix](https://matrix.to/#/#cartridges:matrix.org) to discuss it with the maintainers. We will provide additional guidance. 9 | 10 | ### Fixing a bug 11 | Fork the repository, make your changes, then create a pull request. Be sure to mention the issue you're fixing if one was already open. 12 | 13 | ## Translations 14 | ### Weblate 15 | The project can be translated on [Weblate](https://hosted.weblate.org/engage/cartridges/). 16 | 17 | ### Manually 18 | 1. Clone the repository. 19 | 2. If it isn't already there, add your language to `/po/LINGUAS`. 20 | 3. Create a new translation from the `/po/cartridges.pot` file with a translation editor such as [Poedit](https://poedit.net/). 21 | 4. Save the file as `[YOUR LANGUAGE CODE].po` to `/po/`. 22 | 5. Create a pull request with your translations. 23 | 24 | # Building 25 | 26 | ## GNOME Builder 27 | 1. Install [GNOME Builder](https://flathub.org/apps/org.gnome.Builder). 28 | 2. Click "Clone Repository" with `https://git.kramo.page/cartridges.git` as the URL. 29 | 3. Click on the build button (hammer) at the top. 30 | 31 | ## For Windows 32 | 1. Install [MSYS2](https://www.msys2.org/). 33 | 2. From the MSYS2 shell, install the required dependencies listed [here](https://github.com/kra-mo/cartridges/blob/main/.github/workflows/ci.yml). 34 | 3. Build it via Meson. 35 | 36 | ## For macOS 37 | 1. Install [Homebrew](https://brew.sh/). 38 | 2. Using `brew` and `pip3`, install the required dependencies listed [here](https://github.com/kra-mo/cartridges/blob/main/.github/workflows/ci.yml). 39 | 3. Build it via Meson. 40 | 41 | ## Meson 42 | ```bash 43 | git clone https://git.kramo.page/cartridges.git 44 | cd cartridges 45 | meson setup build 46 | ninja -C build install 47 | ``` 48 | 49 | # Code style 50 | 51 | All code is auto-formatted with [Black](https://github.com/psf/black) and linted with [Pylint](https://github.com/pylint-dev/pylint). Imports are sorted by [isort](https://github.com/pycqa/isort). 52 | 53 | VSCode extensions are available for all of these and you can set them up with the following `settings.json` configuration: 54 | 55 | ```json 56 | "python.formatting.provider": "none", 57 | "[python]": { 58 | "editor.defaultFormatter": "ms-python.black-formatter", 59 | "editor.formatOnSave": true, 60 | "editor.codeActionsOnSave": { 61 | "source.organizeImports": true 62 | }, 63 | }, 64 | "isort.args":["--profile", "black"], 65 | ``` 66 | 67 | For other code editors, you can install them via `pip` and invoke them from the command line. 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [circle-url]: https://circle.gnome.org 2 | [circle-image]: https://circle.gnome.org/assets/button/badge.svg 3 | [weblate-url]: https://hosted.weblate.org/engage/cartridges/ 4 | [weblate-image]: https://hosted.weblate.org/widgets/cartridges/-/cartridges/svg-badge.svg 5 | [discord-url]: https://discord.gg/4KSFh3AmQR 6 | [discord-image]: https://img.shields.io/discord/1088155799299313754?color=%235865F2&label=discord&logo=discord&logoColor=%23FFFFFF&style=for-the-badge 7 | [matrix-url]: https://matrix.to/#/#cartridges:matrix.org 8 | [matrix-image]: https://img.shields.io/matrix/cartridges:matrix.org?label=Matrix&logo=matrix&color=%230dbd8b&style=for-the-badge 9 | [flathub-url]: https://flathub.org/apps/page.kramo.Cartridges 10 | [flathub-image]: https://img.shields.io/flathub/v/page.kramo.Cartridges?logo=flathub&style=for-the-badge 11 | [installs-image]: https://img.shields.io/flathub/downloads/page.kramo.Cartridges?style=for-the-badge 12 | 13 | > [!IMPORTANT] 14 | > Please use [Codeberg](https://codeberg.org/kramo/cartridges) for issues and pull requests. 15 | > The GitHub repository is a [mirror](https://en.wikipedia.org/wiki/Mirror_site). 16 | 17 |
18 | 19 | 20 | # Cartridges 21 | 22 | A GTK4 + Libadwaita game launcher 23 | 24 | [![GNOME Circle][circle-image]][circle-url] 25 | [![Translation Status][weblate-image]][weblate-url] 26 | 27 | [![Flathub][flathub-image]][flathub-url] 28 | [![Discord][discord-image]][discord-url] 29 | [![Matrix][matrix-image]][matrix-url] 30 | [![Installs][installs-image]][flathub-url] 31 | 32 | 33 |
34 | 35 | # The Project 36 | 37 | Cartridges is an easy-to-use, elegant game launcher written in Python using GTK4 and Libadwaita. 38 | 39 | ## Features 40 | 41 | - Manually adding and editing games 42 | - Importing games from various sources: 43 | - Steam 44 | - Lutris 45 | - Heroic 46 | - Bottles 47 | - itch 48 | - Legendary 49 | - RetroArch 50 | - Flatpak 51 | - Desktop Entries 52 | - Filtering games by source 53 | - Searching and sorting by title, date added and last played 54 | - Hiding games 55 | - Automatically downloading cover art from [SteamGridDB](https://www.steamgriddb.com/) 56 | - Searching for games on various databases 57 | - Animated covers 58 | - A search provider for GNOME 59 | 60 | For updates and questions, join our [Discord server][discord-url] (bridged to [Matrix](https://matrix.to/#/#cartridges:matrix.org))! 61 | 62 | ## Donations 63 | I accept donations through [GitHub Sponsors](https://github.com/sponsors/kra-mo) and [Liberapay](https://liberapay.com/kramo). 64 | 65 | Thank you for your generosity! 💜 66 | 67 | # Installation 68 | 69 | ## Linux 70 | 71 | The app is available on Flathub. 72 | 73 | Download on Flathub 74 | 75 | ## Windows 76 | 77 | ### From Releases 78 | 79 | 1. Download the latest release from [GitHub Releases](https://github.com/kra-mo/cartridges/releases). 80 | 2. Run the downloaded installer. 81 | 82 | Note: Windows might present you with a warning when trying to install the app. This is expected, just ignore the warning. 83 | 84 | ### Winget 85 | 86 | Install the latest release with the command: `winget install cartridges`. 87 | 88 | ## macOS 89 | 90 | 1. Download the latest release from [GitHub Releases](https://github.com/kra-mo/cartridges/releases). 91 | 2. Move the app into your Applications folder. 92 | 93 | Note: macOS might tell you that the application could not be checked for malicious software or something similar. In this case, open System Settings > Privacy & Security, scroll down, find the warning about Cartridges and click "Open Anyway". More information can be found [here](https://support.apple.com/en-us/102445). 94 | 95 | ## Building manually 96 | 97 | See [Building](https://codeberg.org/kramo/cartridges/src/branch/main/CONTRIBUTING.md#building). 98 | 99 | # Contributing 100 | 101 | See [CONTRIBUTING.md](https://codeberg.org/kramo/cartridges/src/branch/main/CONTRIBUTING.md). 102 | 103 | Thanks to [Weblate](https://weblate.org/) for hosting our translations! 104 | 105 | # Code of Conduct 106 | 107 | The project follows the [GNOME Code of Conduct](https://conduct.gnome.org/). 108 | 109 | See [CODE_OF_CONDUCT.md](https://codeberg.org/kramo/cartridges/src/branch/main/CODE_OF_CONDUCT.md). 110 | -------------------------------------------------------------------------------- /build-aux/flatpak/page.kramo.Cartridges.Devel.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "page.kramo.Cartridges.Devel", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "48", 5 | "sdk": "org.gnome.Sdk", 6 | "command": "cartridges", 7 | "finish-args": [ 8 | "--share=network", 9 | "--share=ipc", 10 | "--socket=fallback-x11", 11 | "--device=dri", 12 | "--socket=wayland", 13 | "--talk-name=org.freedesktop.Flatpak", 14 | "--filesystem=host", 15 | "--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro", 16 | "--filesystem=~/.var/app/net.lutris.Lutris/:ro", 17 | "--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro", 18 | "--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/legendary/:ro", 19 | "--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/:ro", 20 | "--filesystem=~/.var/app/io.itch.itch/config/itch/:ro", 21 | "--filesystem=~/.var/app/org.libretro.RetroArch/config/retroarch/:ro", 22 | "--filesystem=/var/lib/flatpak/app:ro", 23 | "--filesystem=/var/lib/flatpak/exports:ro", 24 | "--filesystem=xdg-data/flatpak/app:ro", 25 | "--filesystem=xdg-data/flatpak/exports:ro" 26 | ], 27 | "cleanup": [ 28 | "/include", 29 | "/lib/pkgconfig", 30 | "/man", 31 | "/share/doc", 32 | "/share/gtk-doc", 33 | "/share/man", 34 | "/share/pkgconfig", 35 | "*.la", 36 | "*.a" 37 | ], 38 | "modules": [ 39 | { 40 | "name": "python3-modules", 41 | "buildsystem": "simple", 42 | "build-commands": [], 43 | "modules": [ 44 | { 45 | "name": "python3-pyyaml", 46 | "buildsystem": "simple", 47 | "build-commands": [ 48 | "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pyyaml\" --no-build-isolation" 49 | ], 50 | "sources": [ 51 | { 52 | "type": "file", 53 | "url": "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", 54 | "sha256": "d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e" 55 | } 56 | ] 57 | }, 58 | { 59 | "name": "python3-pillow", 60 | "buildsystem": "simple", 61 | "build-commands": [ 62 | "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pillow\" --no-build-isolation" 63 | ], 64 | "sources": [ 65 | { 66 | "type": "file", 67 | "url": "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", 68 | "sha256": "166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06" 69 | } 70 | ] 71 | }, 72 | { 73 | "name": "python3-requests", 74 | "buildsystem": "simple", 75 | "build-commands": [ 76 | "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"requests\" --no-build-isolation" 77 | ], 78 | "sources": [ 79 | { 80 | "type": "file", 81 | "url": "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", 82 | "sha256": "922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8" 83 | }, 84 | { 85 | "type": "file", 86 | "url": "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", 87 | "sha256": "f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5" 88 | }, 89 | { 90 | "type": "file", 91 | "url": "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", 92 | "sha256": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" 93 | }, 94 | { 95 | "type": "file", 96 | "url": "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", 97 | "sha256": "70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" 98 | }, 99 | { 100 | "type": "file", 101 | "url": "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", 102 | "sha256": "ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac" 103 | } 104 | ] 105 | } 106 | ] 107 | }, 108 | { 109 | "name": "blueprint-compiler", 110 | "buildsystem": "meson", 111 | "sources": [ 112 | { 113 | "type": "git", 114 | "url": "https://gitlab.gnome.org/jwestman/blueprint-compiler", 115 | "tag": "v0.16.0" 116 | } 117 | ], 118 | "cleanup": ["*"] 119 | }, 120 | { 121 | "name": "cartridges", 122 | "builddir": true, 123 | "buildsystem": "meson", 124 | "run-tests": true, 125 | "config-opts": ["-Dprofile=development"], 126 | "sources": [ 127 | { 128 | "type": "dir", 129 | "path": "../.." 130 | } 131 | ] 132 | } 133 | ] 134 | } 135 | -------------------------------------------------------------------------------- /build-aux/macos/cartridges.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | a = Analysis( 5 | ["../../_build/cartridges/cartridges"], 6 | pathex=[], 7 | binaries=[], 8 | datas=[("../../_build/data/cartridges.gresource", "Resources")], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={ 12 | "gi": { 13 | "module-versions": { 14 | "Gtk": "4.0", 15 | }, 16 | }, 17 | }, 18 | runtime_hooks=[], 19 | excludes=[], 20 | noarchive=False, 21 | optimize=0, 22 | ) 23 | pyz = PYZ(a.pure) 24 | 25 | exe = EXE( 26 | pyz, 27 | a.scripts, 28 | [], 29 | exclude_binaries=True, 30 | name="Cartridges", 31 | debug=False, 32 | bootloader_ignore_signals=False, 33 | strip=False, 34 | upx=True, 35 | console=False, 36 | disable_windowed_traceback=False, 37 | argv_emulation=False, 38 | target_arch=None, 39 | codesign_identity=None, 40 | entitlements_file=None, 41 | ) 42 | coll = COLLECT( 43 | exe, 44 | a.binaries, 45 | a.datas, 46 | strip=False, 47 | upx=True, 48 | upx_exclude=[], 49 | name="Cartridges", 50 | ) 51 | app = BUNDLE( 52 | coll, 53 | name="Cartridges.app", 54 | icon="./icon.icns", 55 | bundle_identifier="page.kramo.Cartridges", 56 | info_plist={ 57 | "LSApplicationCategoryType": "public.app-category.games", 58 | }, 59 | ) 60 | -------------------------------------------------------------------------------- /build-aux/macos/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kra-mo/cartridges/22b16f7d38db45d64e49e7512e33a034b998205a/build-aux/macos/icon.icns -------------------------------------------------------------------------------- /build-aux/windows/Cartridges.iss.in: -------------------------------------------------------------------------------- 1 | #define MyAppName "Cartridges" 2 | #define MyAppVersion "@VERSION@" 3 | #define MyAppPublisher "kramo" 4 | #define MyAppURL "https://apps.gnome.org/Cartridges/" 5 | #define MyAppExeName "pythonw.exe" 6 | 7 | [Setup] 8 | AppId={{BC3F8D32-4BDC-4715-B149-D79F589CD7F0} 9 | AppName={#MyAppName} 10 | AppVersion={#MyAppVersion} 11 | AppVerName={#MyAppName} {#MyAppVersion} 12 | AppPublisher={#MyAppPublisher} 13 | AppPublisherURL={#MyAppURL} 14 | AppSupportURL=https://git.kramo.page/cartridges/issues 15 | AppUpdatesURL={#MyAppURL} 16 | DefaultDirName={autopf64}\{#MyAppName} 17 | DisableProgramGroupPage=yes 18 | LicenseFile=..\..\..\LICENSE 19 | PrivilegesRequiredOverridesAllowed=dialog 20 | OutputBaseFilename=Cartridges Windows 21 | SetupIconFile=..\..\..\build-aux\windows\icon.ico 22 | Compression=lzma 23 | SolidCompression=yes 24 | WizardStyle=modern 25 | 26 | [Languages] 27 | Name: "english"; MessagesFile: "compiler:Default.isl" 28 | 29 | [Tasks] 30 | Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked 31 | 32 | [Files] 33 | Source: "D:\a\_temp\msys64\ucrt64\bin\cartridges"; DestDir: "{app}\bin"; Flags: ignoreversion 34 | Source: "D:\a\_temp\msys64\ucrt64\bin\pythonw.exe"; DestDir: "{app}\bin"; Flags: ignoreversion 35 | Source: "D:\a\_temp\msys64\ucrt64\bin\python.exe"; DestDir: "{app}\bin"; Flags: ignoreversion 36 | Source: "D:\a\_temp\msys64\ucrt64\bin\gdbus.exe"; DestDir: "{app}\bin"; Flags: ignoreversion 37 | Source: "D:\a\_temp\msys64\ucrt64\bin\gspawn-win64-helper.exe"; DestDir: "{app}\bin"; Flags: ignoreversion 38 | Source: "D:\a\_temp\msys64\ucrt64\bin\gspawn-win64-helper-console.exe"; DestDir: "{app}\bin"; Flags: ignoreversion 39 | Source: "D:\a\_temp\msys64\ucrt64\bin\*.dll"; DestDir: "{app}\bin"; Flags: recursesubdirs ignoreversion 40 | 41 | Source: "D:\a\_temp\msys64\ucrt64\etc\ssl\*"; DestDir: "{app}\etc\ssl"; Flags: recursesubdirs ignoreversion 42 | 43 | Source: "D:\a\_temp\msys64\ucrt64\lib\gdk-pixbuf-2.0\*"; DestDir: "{app}\lib\gdk-pixbuf-2.0"; Flags: recursesubdirs ignoreversion 44 | Source: "D:\a\_temp\msys64\ucrt64\lib\girepository-1.0\*"; DestDir: "{app}\lib\girepository-1.0"; Flags: recursesubdirs ignoreversion 45 | Source: "D:\a\_temp\msys64\ucrt64\lib\python@PYTHON_VERSION@\*"; DestDir: "{app}\lib\python@PYTHON_VERSION@"; Excludes: "__pycache__"; Flags: recursesubdirs ignoreversion 46 | 47 | Source: "D:\a\_temp\msys64\ucrt64\share\cartridges\*"; DestDir: "{app}\share\cartridges"; Excludes: "__pycache__"; Flags: recursesubdirs ignoreversion 48 | Source: "D:\a\_temp\msys64\ucrt64\share\icons\*"; DestDir: "{app}\share\icons"; Excludes: "*.png,cursors\*"; Flags: recursesubdirs ignoreversion 49 | Source: "D:\a\_temp\msys64\ucrt64\share\glib-2.0\*"; DestDir: "{app}\share\glib-2.0"; Flags: recursesubdirs ignoreversion 50 | Source: "D:\a\_temp\msys64\ucrt64\share\gtk-4.0\*"; DestDir: "{app}\share\gtk-4.0"; Flags: recursesubdirs ignoreversion 51 | 52 | Source: "..\..\..\build-aux\windows\icon.ico"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion 53 | 54 | [Icons] 55 | Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\bin\{#MyAppExeName}"; Parameters: """{app}\bin\cartridges"""; IconFilename: "{app}\icon.ico" 56 | Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\bin\{#MyAppExeName}"; Parameters: """{app}\bin\cartridges"""; IconFilename: "{app}\icon.ico"; Tasks: desktopicon 57 | 58 | [Run] 59 | Filename: "{app}\bin\{#MyAppExeName}"; Parameters: """{app}\bin\cartridges"""; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall 60 | -------------------------------------------------------------------------------- /build-aux/windows/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kra-mo/cartridges/22b16f7d38db45d64e49e7512e33a034b998205a/build-aux/windows/icon.ico -------------------------------------------------------------------------------- /build-aux/windows/meson.build: -------------------------------------------------------------------------------- 1 | configure_file( 2 | input: './Cartridges.iss.in', 3 | output: 'Cartridges.iss', 4 | configuration: conf, 5 | install: true, 6 | install_dir: '.', 7 | ) 8 | -------------------------------------------------------------------------------- /cartridges.doap: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Cartridges 9 | Launch all your games 10 | 11 | Cartridges is a simple game launcher for all of your games. It has support for importing games from Steam, Lutris, Heroic and more with no login necessary. You can sort and hide games or download cover art from SteamGridDB. 12 | 13 | 14 | 15 | 16 | 17 | 18 | Python 19 | GTK 4 20 | Libadwaita 21 | 22 | 23 | 24 | kramo 25 | 26 | 27 | 28 | 29 | kra-mo 30 | 31 | 32 | 33 | 34 | 35 | kramo 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /cartridges/__builtins__.pyi: -------------------------------------------------------------------------------- 1 | def _(_msg: str, /) -> str: ... 2 | -------------------------------------------------------------------------------- /cartridges/application_delegate.py: -------------------------------------------------------------------------------- 1 | # application_delegate.py 2 | # 3 | # Copyright 2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """A set of methods that manage your app’s life cycle and its interaction 21 | with common system services.""" 22 | 23 | from typing import Any 24 | 25 | from AppKit import NSApp, NSApplication, NSMenu, NSMenuItem # type: ignore 26 | from Foundation import NSObject # type: ignore 27 | from gi.repository import Gio # type: ignore 28 | 29 | from cartridges import shared 30 | 31 | 32 | class ApplicationDelegate(NSObject): # type: ignore 33 | """A set of methods that manage your app’s life cycle and its interaction 34 | with common system services.""" 35 | 36 | def applicationDidFinishLaunching_(self, *_args: Any) -> None: 37 | main_menu = NSApp.mainMenu() 38 | 39 | add_game_menu_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 40 | "Add Game", "add:", "n" 41 | ) 42 | 43 | import_menu_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 44 | "Import", "import:", "i" 45 | ) 46 | 47 | file_menu = NSMenu.alloc().init() 48 | file_menu.addItem_(add_game_menu_item) 49 | file_menu.addItem_(import_menu_item) 50 | 51 | file_menu_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 52 | "File", None, "" 53 | ) 54 | file_menu_item.setSubmenu_(file_menu) 55 | main_menu.addItem_(file_menu_item) 56 | 57 | show_hidden_menu_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 58 | "Show Hidden", "hidden:", "h" 59 | ) 60 | 61 | windows_menu = NSMenu.alloc().init() 62 | 63 | view_menu = NSMenu.alloc().init() 64 | view_menu.addItem_(show_hidden_menu_item) 65 | 66 | view_menu_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 67 | "View", None, "" 68 | ) 69 | view_menu_item.setSubmenu_(view_menu) 70 | main_menu.addItem_(view_menu_item) 71 | 72 | windows_menu = NSMenu.alloc().init() 73 | 74 | windows_menu_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 75 | "Window", None, "" 76 | ) 77 | windows_menu_item.setSubmenu_(windows_menu) 78 | main_menu.addItem_(windows_menu_item) 79 | 80 | NSApp.setWindowsMenu_(windows_menu) 81 | 82 | keyboard_shortcuts_menu_item = ( 83 | NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 84 | "Keyboard Shortcuts", "shortcuts:", "?" 85 | ) 86 | ) 87 | 88 | help_menu = NSMenu.alloc().init() 89 | help_menu.addItem_(keyboard_shortcuts_menu_item) 90 | 91 | help_menu_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 92 | "Help", None, "" 93 | ) 94 | help_menu_item.setSubmenu_(help_menu) 95 | main_menu.addItem_(help_menu_item) 96 | 97 | NSApp.setHelpMenu_(help_menu) 98 | 99 | def add_(self, *_args: Any) -> None: 100 | if (not shared.win) or (not (app := shared.win.get_application())): 101 | return 102 | 103 | app.lookup_action("add_game").activate() 104 | 105 | def import_(self, *_args: Any) -> None: 106 | if (not shared.win) or (not (app := shared.win.get_application())): 107 | return 108 | 109 | app.lookup_action("import").activate() 110 | 111 | def hidden_(self, *_args: Any) -> None: 112 | if not shared.win: 113 | return 114 | 115 | shared.win.lookup_action("show_hidden").activate() 116 | 117 | def shortcuts_(self, *_args: Any) -> None: 118 | if (not shared.win) or (not (overlay := shared.win.get_help_overlay())): 119 | return 120 | 121 | overlay.present() 122 | -------------------------------------------------------------------------------- /cartridges/cartridges.in: -------------------------------------------------------------------------------- 1 | #!@PYTHON@ 2 | 3 | # cartridges.in 4 | # 5 | # Copyright 2022-2024 kramo 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | # 20 | # SPDX-License-Identifier: GPL-3.0-or-later 21 | 22 | import gettext 23 | import locale 24 | import os 25 | import signal 26 | import sys 27 | from pathlib import Path 28 | from platform import system 29 | 30 | VERSION = "@VERSION@" 31 | 32 | if os.name == "nt": 33 | PKGDATADIR = os.path.join(os.path.dirname(__file__), "..", "share", "cartridges") 34 | else: 35 | PKGDATADIR = "@pkgdatadir@" 36 | LOCALEDIR = "@localedir@" 37 | 38 | sys.path.insert(1, PKGDATADIR) 39 | signal.signal(signal.SIGINT, signal.SIG_DFL) 40 | 41 | if system() == "Linux": 42 | locale.bindtextdomain("cartridges", LOCALEDIR) 43 | locale.textdomain("cartridges") 44 | gettext.install("cartridges", LOCALEDIR, names=['ngettext']) 45 | else: 46 | gettext.install("cartridges", names=['ngettext']) 47 | 48 | if __name__ == "__main__": 49 | from gi.repository import Gio, GLib 50 | 51 | try: 52 | # For a macOS application bundle 53 | resource = Gio.Resource.load( 54 | str(Path(__file__).parent / "Resources" / "cartridges.gresource") 55 | ) 56 | except GLib.GError: 57 | resource = Gio.Resource.load(os.path.join(PKGDATADIR, "cartridges.gresource")) 58 | resource._register() # pylint: disable=protected-access 59 | 60 | from cartridges import main 61 | 62 | sys.exit(main.main(VERSION)) 63 | -------------------------------------------------------------------------------- /cartridges/errors/error_producer.py: -------------------------------------------------------------------------------- 1 | from threading import Lock 2 | 3 | 4 | class ErrorProducer: 5 | """ 6 | A mixin for objects that produce errors. 7 | 8 | Specifies the report_error and collect_errors methods in a thread-safe manner. 9 | """ 10 | 11 | errors: list[Exception] 12 | errors_lock: Lock 13 | 14 | def __init__(self) -> None: 15 | self.errors = [] 16 | self.errors_lock = Lock() 17 | 18 | def report_error(self, error: Exception) -> None: 19 | """Report an error""" 20 | with self.errors_lock: 21 | self.errors.append(error) 22 | 23 | def collect_errors(self) -> list[Exception]: 24 | """Collect and remove the errors produced by the object""" 25 | with self.errors_lock: 26 | errors = self.errors.copy() 27 | self.errors.clear() 28 | return errors 29 | -------------------------------------------------------------------------------- /cartridges/errors/friendly_error.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Optional 2 | 3 | 4 | class FriendlyError(Exception): 5 | """ 6 | An error that is supposed to be shown to the user in a nice format 7 | 8 | Use `raise ... from ...` to preserve context. 9 | """ 10 | 11 | title_format: str 12 | title_args: Iterable[str] 13 | subtitle_format: str 14 | subtitle_args: Iterable[str] 15 | 16 | @property 17 | def title(self) -> str: 18 | """Get the gettext translated error title""" 19 | return self.title_format.format(self.title_args) 20 | 21 | @property 22 | def subtitle(self) -> str: 23 | """Get the gettext translated error subtitle""" 24 | return self.subtitle_format.format(self.subtitle_args) 25 | 26 | def __init__( 27 | self, 28 | title: str, 29 | subtitle: str, 30 | title_args: Optional[Iterable[str]] = None, 31 | subtitle_args: Optional[Iterable[str]] = None, 32 | ) -> None: 33 | """Create a friendly error 34 | 35 | :param str title: The error's title, translatable with gettext 36 | :param str subtitle: The error's subtitle, translatable with gettext 37 | """ 38 | super().__init__() 39 | if title is not None: 40 | self.title_format = title 41 | if subtitle is not None: 42 | self.subtitle_format = subtitle 43 | self.title_args = title_args if title_args else () 44 | self.subtitle_args = subtitle_args if subtitle_args else () 45 | 46 | def __str__(self) -> str: 47 | return f"{self.title} - {self.subtitle}" 48 | -------------------------------------------------------------------------------- /cartridges/game.py: -------------------------------------------------------------------------------- 1 | # game.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import shlex 21 | from pathlib import Path 22 | from time import time 23 | from typing import Any, Optional 24 | 25 | from gi.repository import Adw, GObject, Gtk 26 | 27 | from cartridges import shared 28 | from cartridges.game_cover import GameCover 29 | from cartridges.utils.run_executable import run_executable 30 | 31 | 32 | # pylint: disable=too-many-instance-attributes 33 | @Gtk.Template(resource_path=shared.PREFIX + "/gtk/game.ui") 34 | class Game(Gtk.Box): 35 | __gtype_name__ = "Game" 36 | 37 | title = Gtk.Template.Child() 38 | play_button = Gtk.Template.Child() 39 | cover = Gtk.Template.Child() 40 | spinner = Gtk.Template.Child() 41 | cover_button = Gtk.Template.Child() 42 | menu_button = Gtk.Template.Child() 43 | play_revealer = Gtk.Template.Child() 44 | menu_revealer = Gtk.Template.Child() 45 | game_options = Gtk.Template.Child() 46 | hidden_game_options = Gtk.Template.Child() 47 | 48 | loading: int = 0 49 | filtered: bool = False 50 | 51 | added: int 52 | executable: str 53 | game_id: str 54 | source: str 55 | hidden: bool = False 56 | last_played: int = 0 57 | name: str 58 | developer: Optional[str] = None 59 | removed: bool = False 60 | blacklisted: bool = False 61 | game_cover: GameCover = None 62 | version: int = 0 63 | 64 | def __init__(self, data: dict[str, Any], **kwargs: Any) -> None: 65 | super().__init__(**kwargs) 66 | 67 | self.app = shared.win.get_application() 68 | self.version = shared.SPEC_VERSION 69 | 70 | self.update_values(data) 71 | self.base_source = self.source.split("_")[0] 72 | 73 | self.set_play_icon() 74 | 75 | self.event_contoller_motion = Gtk.EventControllerMotion.new() 76 | self.add_controller(self.event_contoller_motion) 77 | self.event_contoller_motion.connect("enter", self.toggle_play, False) 78 | self.event_contoller_motion.connect("leave", self.toggle_play, None, None) 79 | self.cover_button.connect("clicked", self.main_button_clicked, False) 80 | self.play_button.connect("clicked", self.main_button_clicked, True) 81 | 82 | shared.schema.connect("changed", self.schema_changed) 83 | 84 | def update_values(self, data: dict[str, Any]) -> None: 85 | for key, value in data.items(): 86 | # Convert executables to strings 87 | if key == "executable" and isinstance(value, list): 88 | value = shlex.join(value) 89 | setattr(self, key, value) 90 | 91 | def update(self) -> None: 92 | self.emit("update-ready", {}) 93 | 94 | def save(self) -> None: 95 | self.emit("save-ready", {}) 96 | 97 | def create_toast(self, title: str, action: Optional[str] = None) -> None: 98 | toast = Adw.Toast.new(title.format(self.name)) 99 | toast.set_priority(Adw.ToastPriority.HIGH) 100 | toast.set_use_markup(False) 101 | 102 | if action: 103 | toast.set_button_label(_("Undo")) 104 | toast.connect("button-clicked", shared.win.on_undo_action, self, action) 105 | 106 | if (self, action) in shared.win.toasts.keys(): 107 | # Dismiss the toast if there already is one 108 | shared.win.toasts[(self, action)].dismiss() 109 | 110 | shared.win.toasts[(self, action)] = toast 111 | 112 | shared.win.toast_overlay.add_toast(toast) 113 | 114 | def launch(self) -> None: 115 | self.last_played = int(time()) 116 | self.save() 117 | self.update() 118 | 119 | run_executable(self.executable) 120 | 121 | if shared.schema.get_boolean("exit-after-launch"): 122 | self.app.quit() 123 | 124 | # The variable is the title of the game 125 | self.create_toast(_("{} launched")) 126 | 127 | def toggle_hidden(self, toast: bool = True) -> None: 128 | self.hidden = not self.hidden 129 | self.save() 130 | 131 | if shared.win.navigation_view.get_visible_page() == shared.win.details_page: 132 | shared.win.navigation_view.pop() 133 | 134 | self.update() 135 | 136 | if toast: 137 | self.create_toast( 138 | # The variable is the title of the game 139 | (_("{} hidden") if self.hidden else _("{} unhidden")).format(self.name), 140 | "hide", 141 | ) 142 | 143 | def remove_game(self) -> None: 144 | # Add "removed=True" to the game properties so it can be deleted on next init 145 | self.removed = True 146 | self.save() 147 | self.update() 148 | 149 | if shared.win.navigation_view.get_visible_page() == shared.win.details_page: 150 | shared.win.navigation_view.pop() 151 | 152 | # The variable is the title of the game 153 | self.create_toast(_("{} removed").format(self.name), "remove") 154 | 155 | def set_loading(self, state: int) -> None: 156 | self.loading += state 157 | loading = self.loading > 0 158 | 159 | self.cover.set_opacity(int(not loading)) 160 | self.spinner.set_visible(loading) 161 | 162 | def get_cover_path(self) -> Optional[Path]: 163 | cover_path = shared.covers_dir / f"{self.game_id}.gif" 164 | if cover_path.is_file(): 165 | return cover_path # type: ignore 166 | 167 | cover_path = shared.covers_dir / f"{self.game_id}.tiff" 168 | if cover_path.is_file(): 169 | return cover_path # type: ignore 170 | 171 | return None 172 | 173 | def toggle_play( 174 | self, _widget: Any, _prop1: Any, _prop2: Any, state: bool = True 175 | ) -> None: 176 | if not self.menu_button.get_active(): 177 | self.play_revealer.set_reveal_child(not state) 178 | self.menu_revealer.set_reveal_child(not state) 179 | 180 | def main_button_clicked(self, _widget: Any, button: bool) -> None: 181 | if shared.schema.get_boolean("cover-launches-game") ^ button: 182 | self.launch() 183 | else: 184 | shared.win.show_details_page(self) 185 | 186 | def set_play_icon(self) -> None: 187 | self.play_button.set_icon_name( 188 | "help-about-symbolic" 189 | if shared.schema.get_boolean("cover-launches-game") 190 | else "media-playback-start-symbolic" 191 | ) 192 | 193 | def schema_changed(self, _settings: Any, key: str) -> None: 194 | if key == "cover-launches-game": 195 | self.set_play_icon() 196 | 197 | @GObject.Signal(name="update-ready", arg_types=[object]) 198 | def update_ready(self, _additional_data): # type: ignore 199 | """Signal emitted when the game needs updating""" 200 | 201 | @GObject.Signal(name="save-ready", arg_types=[object]) 202 | def save_ready(self, _additional_data): # type: ignore 203 | """Signal emitted when the game needs saving""" 204 | -------------------------------------------------------------------------------- /cartridges/game_cover.py: -------------------------------------------------------------------------------- 1 | # game_cover.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from io import BytesIO 21 | from pathlib import Path 22 | from typing import Optional 23 | 24 | from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk 25 | from PIL import Image, ImageFilter, ImageStat 26 | 27 | from cartridges import shared 28 | 29 | 30 | class GameCover: 31 | texture: Optional[Gdk.Texture] 32 | blurred: Optional[Gdk.Texture] 33 | luminance: Optional[tuple[float, float]] 34 | path: Optional[Path] 35 | animation: Optional[GdkPixbuf.PixbufAnimation] 36 | anim_iter: Optional[GdkPixbuf.PixbufAnimationIter] 37 | 38 | placeholder = Gdk.Texture.new_from_resource( 39 | shared.PREFIX + "/library_placeholder.svg" 40 | ) 41 | placeholder_small = Gdk.Texture.new_from_resource( 42 | shared.PREFIX + "/library_placeholder_small.svg" 43 | ) 44 | 45 | def __init__(self, pictures: set[Gtk.Picture], path: Optional[Path] = None) -> None: 46 | self.pictures = pictures 47 | self.new_cover(path) 48 | 49 | def new_cover(self, path: Optional[Path] = None) -> None: 50 | self.animation = None 51 | self.texture = None 52 | self.blurred = None 53 | self.luminance = None 54 | self.path = path 55 | 56 | if path: 57 | if path.suffix == ".gif": 58 | self.animation = GdkPixbuf.PixbufAnimation.new_from_file(str(path)) 59 | self.anim_iter = self.animation.get_iter() 60 | self.task = Gio.Task.new() 61 | self.task.run_in_thread( 62 | lambda *_: self.update_animation((self.task, self.animation)) 63 | ) 64 | else: 65 | self.texture = Gdk.Texture.new_from_filename(str(path)) 66 | 67 | if not self.animation: 68 | self.set_texture(self.texture) 69 | 70 | def get_texture(self) -> Gdk.Texture: 71 | return ( 72 | Gdk.Texture.new_for_pixbuf(self.animation.get_static_image()) 73 | if self.animation 74 | else self.texture 75 | ) 76 | 77 | def get_blurred(self) -> Gdk.Texture: 78 | if not self.blurred: 79 | if self.path: 80 | with Image.open(self.path) as image: 81 | image = ( 82 | image.convert("RGB") 83 | .resize((100, 150)) 84 | .filter(ImageFilter.GaussianBlur(20)) 85 | ) 86 | 87 | buffer = BytesIO() 88 | image.save(buffer, "tiff", compression=None) 89 | gbytes = GLib.Bytes.new(buffer.getvalue()) 90 | 91 | self.blurred = Gdk.Texture.new_from_bytes(gbytes) 92 | 93 | stat = ImageStat.Stat(image.convert("L")) 94 | 95 | # Luminance values for light and dark mode 96 | self.luminance = ( 97 | min((stat.mean[0] + stat.extrema[0][0]) / 510, 0.7), 98 | max((stat.mean[0] + stat.extrema[0][1]) / 510, 0.3), 99 | ) 100 | else: 101 | self.blurred = self.placeholder_small 102 | self.luminance = (0.3, 0.5) 103 | 104 | return self.blurred 105 | 106 | def add_picture(self, picture: Gtk.Picture) -> None: 107 | self.pictures.add(picture) 108 | if not self.animation: 109 | self.set_texture(self.texture) 110 | else: 111 | self.update_animation((self.task, self.animation)) 112 | 113 | def set_texture(self, texture: Gdk.Texture) -> None: 114 | self.pictures.discard( 115 | picture for picture in self.pictures if not picture.is_visible() 116 | ) 117 | if not self.pictures: 118 | self.animation = None 119 | else: 120 | for picture in self.pictures: 121 | picture.set_paintable(texture or self.placeholder) 122 | picture.queue_draw() 123 | 124 | def update_animation(self, data: GdkPixbuf.PixbufAnimation) -> None: 125 | if self.animation == data[1]: 126 | self.anim_iter.advance() # type: ignore 127 | 128 | self.set_texture(Gdk.Texture.new_for_pixbuf(self.anim_iter.get_pixbuf())) # type: ignore 129 | 130 | delay_time = self.anim_iter.get_delay_time() # type: ignore 131 | GLib.timeout_add( 132 | 20 if delay_time < 20 else delay_time, 133 | self.update_animation, 134 | data, 135 | ) 136 | -------------------------------------------------------------------------------- /cartridges/importer/bottles_source.py: -------------------------------------------------------------------------------- 1 | # bottles_source.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # Copyright 2023 Geoffrey Coulaud 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # SPDX-License-Identifier: GPL-3.0-or-later 20 | 21 | from pathlib import Path 22 | from typing import NamedTuple 23 | 24 | import yaml 25 | 26 | from cartridges import shared 27 | from cartridges.game import Game 28 | from cartridges.importer.location import Location, LocationSubPath 29 | from cartridges.importer.source import SourceIterable, URLExecutableSource 30 | 31 | 32 | class BottlesSourceIterable(SourceIterable): 33 | source: "BottlesSource" 34 | 35 | def __iter__(self): 36 | """Generator method producing games""" 37 | 38 | data = self.source.locations.data["library.yml"].read_text("utf-8") 39 | library: dict = yaml.safe_load(data) 40 | 41 | for entry in library.values(): 42 | # Build game 43 | values = { 44 | "source": self.source.source_id, 45 | "added": shared.import_time, 46 | "name": entry["name"], 47 | "game_id": self.source.game_id_format.format(game_id=entry["id"]), 48 | "executable": self.source.make_executable( 49 | bottle_name=entry["bottle"]["name"], 50 | game_name=entry["name"], 51 | ), 52 | } 53 | game = Game(values) 54 | 55 | # Get official cover path 56 | try: 57 | # This will not work if both Cartridges and Bottles are installed via Flatpak 58 | # as Cartridges can't access directories picked via Bottles' file picker portal 59 | bottles_location = Path( 60 | yaml.safe_load( 61 | self.source.locations.data["data.yml"].read_text("utf-8") 62 | )["custom_bottles_path"] 63 | ) 64 | except (FileNotFoundError, KeyError): 65 | bottles_location = self.source.locations.data.root / "bottles" 66 | 67 | bottle_path = entry["bottle"]["path"] 68 | 69 | additional_data = {} 70 | if entry["thumbnail"]: 71 | image_name = entry["thumbnail"].split(":")[1] 72 | image_path = bottles_location / bottle_path / "grids" / image_name 73 | additional_data = {"local_image_path": image_path} 74 | 75 | yield (game, additional_data) 76 | 77 | 78 | class BottlesLocations(NamedTuple): 79 | data: Location 80 | 81 | 82 | class BottlesSource(URLExecutableSource): 83 | """Generic Bottles source""" 84 | 85 | source_id = "bottles" 86 | name = _("Bottles") 87 | iterable_class = BottlesSourceIterable 88 | url_format = 'bottles:run/"{bottle_name}"/"{game_name}"' 89 | available_on = {"linux"} 90 | 91 | locations: BottlesLocations 92 | 93 | def __init__(self) -> None: 94 | super().__init__() 95 | self.locations = BottlesLocations( 96 | Location( 97 | schema_key="bottles-location", 98 | candidates=( 99 | shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles", 100 | shared.data_dir / "bottles/", 101 | shared.host_data_dir / "bottles", 102 | ), 103 | paths={ 104 | "library.yml": LocationSubPath("library.yml"), 105 | "data.yml": LocationSubPath("data.yml"), 106 | }, 107 | invalid_subtitle=Location.DATA_INVALID_SUBTITLE, 108 | ) 109 | ) 110 | -------------------------------------------------------------------------------- /cartridges/importer/desktop_source.py: -------------------------------------------------------------------------------- 1 | # desktop_source.py 2 | # 3 | # Copyright 2023 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import os 21 | import shlex 22 | import subprocess 23 | from pathlib import Path 24 | from typing import NamedTuple 25 | 26 | from gi.repository import GLib, Gtk 27 | 28 | from cartridges import shared 29 | from cartridges.game import Game 30 | from cartridges.importer.source import Source, SourceIterable 31 | 32 | 33 | class DesktopSourceIterable(SourceIterable): 34 | source: "DesktopSource" 35 | 36 | def __iter__(self): 37 | """Generator method producing games""" 38 | 39 | icon_theme = Gtk.IconTheme.new() 40 | 41 | search_paths = [ 42 | shared.host_data_dir, 43 | "/run/host/usr/local/share", 44 | "/run/host/usr/share", 45 | "/run/host/usr/share/pixmaps", 46 | "/usr/share/pixmaps", 47 | ] + GLib.get_system_data_dirs() 48 | 49 | for search_path in search_paths: 50 | path = Path(search_path) 51 | 52 | if not str(search_path).endswith("/pixmaps"): 53 | path = path / "icons" 54 | 55 | if not path.is_dir(): 56 | continue 57 | 58 | if str(path).startswith("/app/"): 59 | continue 60 | 61 | icon_theme.add_search_path(str(path)) 62 | 63 | launch_command, full_path = self.check_launch_commands() 64 | 65 | for path in search_paths: 66 | if str(path).startswith("/app/"): 67 | continue 68 | 69 | path = Path(path) / "applications" 70 | 71 | if not path.is_dir(): 72 | continue 73 | 74 | for entry in path.iterdir(): 75 | if entry.suffix != ".desktop": 76 | continue 77 | 78 | # Skip Lutris games 79 | if str(entry.name).startswith("net.lutris."): 80 | continue 81 | 82 | keyfile = GLib.KeyFile.new() 83 | 84 | try: 85 | keyfile.load_from_file(str(entry), 0) 86 | 87 | if "Game" not in keyfile.get_string_list( 88 | "Desktop Entry", "Categories" 89 | ): 90 | continue 91 | 92 | name = keyfile.get_string("Desktop Entry", "Name") 93 | executable = keyfile.get_string("Desktop Entry", "Exec").split( 94 | " %" 95 | )[0] 96 | except GLib.Error: 97 | continue 98 | 99 | try: 100 | try_exec = "which " + keyfile.get_string("Desktop Entry", "TryExec") 101 | if not self.check_command(try_exec): 102 | continue 103 | 104 | except GLib.Error: 105 | pass 106 | 107 | # Skip Steam games 108 | if "steam://rungameid/" in executable: 109 | continue 110 | 111 | # Skip Heroic games 112 | if "heroic://launch/" in executable: 113 | continue 114 | 115 | # Skip Bottles games 116 | if "bottles-cli " in executable: 117 | continue 118 | 119 | try: 120 | if keyfile.get_boolean("Desktop Entry", "NoDisplay"): 121 | continue 122 | except GLib.Error: 123 | pass 124 | 125 | try: 126 | if keyfile.get_boolean("Desktop Entry", "Hidden"): 127 | continue 128 | except GLib.Error: 129 | pass 130 | 131 | # Strip /run/host from Flatpak paths 132 | if entry.is_relative_to(prefix := "/run/host"): 133 | entry = Path("/") / entry.relative_to(prefix) 134 | 135 | launch_arg = shlex.quote(str(entry if full_path else entry.stem)) 136 | 137 | values = { 138 | "source": self.source.source_id, 139 | "added": shared.import_time, 140 | "name": name, 141 | "game_id": f"desktop_{entry.stem}", 142 | "executable": f"{launch_command} {launch_arg}", 143 | } 144 | game = Game(values) 145 | 146 | additional_data = {} 147 | 148 | try: 149 | icon_str = keyfile.get_string("Desktop Entry", "Icon") 150 | except GLib.Error: 151 | yield game 152 | continue 153 | else: 154 | if "/" in icon_str: 155 | additional_data = {"local_icon_path": Path(icon_str)} 156 | yield (game, additional_data) 157 | continue 158 | 159 | try: 160 | if ( 161 | icon_path := icon_theme.lookup_icon( 162 | icon_str, 163 | None, 164 | 512, 165 | 1, 166 | shared.win.get_direction(), 167 | 0, 168 | ) 169 | .get_file() 170 | .get_path() 171 | ): 172 | additional_data = {"local_icon_path": Path(icon_path)} 173 | except GLib.Error: 174 | pass 175 | 176 | yield (game, additional_data) 177 | 178 | def check_command(self, command) -> bool: 179 | flatpak_str = "flatpak-spawn --host /bin/sh -c " 180 | 181 | if os.getenv("FLATPAK_ID") == shared.APP_ID: 182 | command = flatpak_str + shlex.quote(command) 183 | 184 | try: 185 | subprocess.run(command, shell=True, check=True) 186 | except subprocess.CalledProcessError: 187 | return False 188 | 189 | return True 190 | 191 | def check_launch_commands(self) -> (str, bool): 192 | """Check whether `gio launch` `gtk4-launch` or `gtk-launch` are available on the system""" 193 | commands = (("gio launch", True), ("gtk4-launch", False), ("gtk-launch", False)) 194 | 195 | for command, full_path in commands: 196 | # Even if `gio` is available, `gio launch` is only available on GLib >= 2.67.2 197 | command_to_check = ( 198 | "gio help launch" if command == "gio launch" else f"which {command}" 199 | ) 200 | 201 | if self.check_command(command_to_check): 202 | return command, full_path 203 | 204 | return commands[2] 205 | 206 | 207 | class DesktopLocations(NamedTuple): 208 | pass 209 | 210 | 211 | class DesktopSource(Source): 212 | """Generic Flatpak source""" 213 | 214 | source_id = "desktop" 215 | name = _("Desktop Entries") 216 | iterable_class = DesktopSourceIterable 217 | available_on = {"linux"} 218 | 219 | locations: DesktopLocations 220 | 221 | def __init__(self) -> None: 222 | super().__init__() 223 | self.locations = DesktopLocations() 224 | -------------------------------------------------------------------------------- /cartridges/importer/flatpak_source.py: -------------------------------------------------------------------------------- 1 | # flatpak_source.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from itertools import chain 21 | from pathlib import Path 22 | from typing import NamedTuple 23 | 24 | from gi.repository import GLib, Gtk 25 | 26 | from cartridges import shared 27 | from cartridges.game import Game 28 | from cartridges.importer.location import Location, LocationSubPath 29 | from cartridges.importer.source import ExecutableFormatSource, SourceIterable 30 | 31 | 32 | class FlatpakSourceIterable(SourceIterable): 33 | source: "FlatpakSource" 34 | 35 | def __iter__(self): 36 | """Generator method producing games""" 37 | 38 | icon_theme = Gtk.IconTheme.new() 39 | if user_data := self.source.locations.user_data["icons"]: 40 | icon_theme.add_search_path(str(user_data)) 41 | 42 | if system_data := self.source.locations.system_data["icons"]: 43 | icon_theme.add_search_path(str(system_data)) 44 | 45 | if not (system_data or user_data): 46 | return 47 | 48 | blacklist = ( 49 | { 50 | "hu.kramo.Cartridges", 51 | "hu.kramo.Cartridges.Devel", 52 | "page.kramo.Cartridges", 53 | "page.kramo.Cartridges.Devel", 54 | } 55 | if shared.schema.get_boolean("flatpak-import-launchers") 56 | else { 57 | "hu.kramo.Cartridges", 58 | "hu.kramo.Cartridges.Devel", 59 | "page.kramo.Cartridges", 60 | "page.kramo.Cartridges.Devel", 61 | "com.valvesoftware.Steam", 62 | "net.lutris.Lutris", 63 | "com.heroicgameslauncher.hgl", 64 | "com.usebottles.Bottles", 65 | "io.itch.itch", 66 | "org.libretro.RetroArch", 67 | } 68 | ) 69 | 70 | generators = set( 71 | location.iterdir() 72 | for location in ( 73 | self.source.locations.user_data["applications"], 74 | self.source.locations.system_data["applications"], 75 | ) 76 | if location 77 | ) 78 | 79 | for entry in chain(*generators): 80 | if entry.suffix != ".desktop": 81 | continue 82 | 83 | keyfile = GLib.KeyFile.new() 84 | 85 | try: 86 | keyfile.load_from_file(str(entry), 0) 87 | 88 | if "Game" not in keyfile.get_string_list("Desktop Entry", "Categories"): 89 | continue 90 | 91 | if ( 92 | flatpak_id := keyfile.get_string("Desktop Entry", "X-Flatpak") 93 | ) in blacklist or flatpak_id != entry.stem: 94 | continue 95 | 96 | name = keyfile.get_string("Desktop Entry", "Name") 97 | 98 | except GLib.Error: 99 | continue 100 | 101 | values = { 102 | "source": self.source.source_id, 103 | "added": shared.import_time, 104 | "name": name, 105 | "game_id": self.source.game_id_format.format(game_id=flatpak_id), 106 | "executable": self.source.make_executable(flatpak_id=flatpak_id), 107 | } 108 | game = Game(values) 109 | 110 | additional_data = {} 111 | 112 | try: 113 | if ( 114 | icon_path := icon_theme.lookup_icon( 115 | keyfile.get_string("Desktop Entry", "Icon"), 116 | None, 117 | 512, 118 | 1, 119 | shared.win.get_direction(), 120 | 0, 121 | ) 122 | .get_file() 123 | .get_path() 124 | ): 125 | additional_data = {"local_icon_path": Path(icon_path)} 126 | else: 127 | pass 128 | except GLib.Error: 129 | pass 130 | 131 | yield (game, additional_data) 132 | 133 | 134 | class FlatpakLocations(NamedTuple): 135 | system_data: Location 136 | user_data: Location 137 | 138 | 139 | class FlatpakSource(ExecutableFormatSource): 140 | """Generic Flatpak source""" 141 | 142 | source_id = "flatpak" 143 | name = _("Flatpak") 144 | iterable_class = FlatpakSourceIterable 145 | executable_format = "flatpak run {flatpak_id}" 146 | available_on = {"linux"} 147 | 148 | locations: FlatpakLocations 149 | 150 | def __init__(self) -> None: 151 | super().__init__() 152 | self.locations = FlatpakLocations( 153 | Location( 154 | schema_key="flatpak-system-location", 155 | candidates=("/var/lib/flatpak/",), 156 | paths={ 157 | "applications": LocationSubPath("exports/share/applications", True), 158 | "icons": LocationSubPath("exports/share/icons", True), 159 | }, 160 | invalid_subtitle=Location.DATA_INVALID_SUBTITLE, 161 | optional=True, 162 | ), 163 | Location( 164 | schema_key="flatpak-user-location", 165 | candidates=(shared.data_dir / "flatpak",), 166 | paths={ 167 | "applications": LocationSubPath("exports/share/applications", True), 168 | "icons": LocationSubPath("exports/share/icons", True), 169 | }, 170 | invalid_subtitle=Location.DATA_INVALID_SUBTITLE, 171 | optional=True, 172 | ), 173 | ) 174 | -------------------------------------------------------------------------------- /cartridges/importer/itch_source.py: -------------------------------------------------------------------------------- 1 | # itch_source.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # Copyright 2023 Geoffrey Coulaud 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # SPDX-License-Identifier: GPL-3.0-or-later 20 | 21 | from shutil import rmtree 22 | from sqlite3 import connect 23 | from typing import NamedTuple 24 | 25 | from cartridges import shared 26 | from cartridges.game import Game 27 | from cartridges.importer.location import Location, LocationSubPath 28 | from cartridges.importer.source import SourceIterable, URLExecutableSource 29 | from cartridges.utils.sqlite import copy_db 30 | 31 | 32 | class ItchSourceIterable(SourceIterable): 33 | source: "ItchSource" 34 | 35 | def __iter__(self): 36 | """Generator method producing games""" 37 | 38 | # Query the database 39 | db_request = """ 40 | SELECT 41 | games.id, 42 | games.title, 43 | games.cover_url, 44 | games.still_cover_url, 45 | caves.id 46 | FROM 47 | 'caves' 48 | INNER JOIN 49 | 'games' 50 | ON 51 | caves.game_id = games.id 52 | ; 53 | """ 54 | db_path = copy_db(self.source.locations.config["butler.db"]) 55 | connection = connect(db_path) 56 | cursor = connection.execute(db_request) 57 | 58 | # Create games from the db results 59 | for row in cursor: 60 | values = { 61 | "added": shared.import_time, 62 | "source": self.source.source_id, 63 | "name": row[1], 64 | "game_id": self.source.game_id_format.format(game_id=row[0]), 65 | "executable": self.source.make_executable(cave_id=row[4]), 66 | } 67 | additional_data = {"online_cover_url": row[3] or row[2]} 68 | game = Game(values) 69 | yield (game, additional_data) 70 | 71 | # Cleanup 72 | rmtree(str(db_path.parent)) 73 | 74 | 75 | class ItchLocations(NamedTuple): 76 | config: Location 77 | 78 | 79 | class ItchSource(URLExecutableSource): 80 | source_id = "itch" 81 | name = _("itch") 82 | iterable_class = ItchSourceIterable 83 | url_format = "itch://caves/{cave_id}/launch" 84 | available_on = {"linux", "win32", "darwin"} 85 | 86 | locations: ItchLocations 87 | 88 | def __init__(self) -> None: 89 | super().__init__() 90 | self.locations = ItchLocations( 91 | Location( 92 | schema_key="itch-location", 93 | candidates=( 94 | shared.flatpak_dir / "io.itch.itch" / "config" / "itch", 95 | shared.config_dir / "itch", 96 | shared.host_config_dir / "itch", 97 | shared.appdata_dir / "itch", 98 | shared.app_support_dir / "itch", 99 | ), 100 | paths={ 101 | "butler.db": LocationSubPath("db/butler.db"), 102 | }, 103 | invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE, 104 | ) 105 | ) 106 | -------------------------------------------------------------------------------- /cartridges/importer/legendary_source.py: -------------------------------------------------------------------------------- 1 | # legendary_source.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import json 21 | import logging 22 | from json import JSONDecodeError 23 | from typing import NamedTuple 24 | 25 | from cartridges import shared 26 | from cartridges.game import Game 27 | from cartridges.importer.location import Location, LocationSubPath 28 | from cartridges.importer.source import ( 29 | ExecutableFormatSource, 30 | SourceIterable, 31 | SourceIterationResult, 32 | ) 33 | 34 | 35 | class LegendarySourceIterable(SourceIterable): 36 | source: "LegendarySource" 37 | 38 | def game_from_library_entry(self, entry: dict) -> SourceIterationResult: 39 | # Skip non-games 40 | if entry["is_dlc"]: 41 | return None 42 | 43 | # Build game 44 | app_name = entry["app_name"] 45 | values = { 46 | "added": shared.import_time, 47 | "source": self.source.source_id, 48 | "name": entry["title"], 49 | "game_id": self.source.game_id_format.format(game_id=app_name), 50 | "executable": self.source.make_executable(app_name=app_name), 51 | } 52 | data = {} 53 | 54 | # Get additional metadata from file (optional) 55 | metadata_file = self.source.locations.config["metadata"] / f"{app_name}.json" 56 | try: 57 | metadata = json.load(metadata_file.open()) 58 | values["developer"] = metadata["metadata"]["developer"] 59 | for image_entry in metadata["metadata"]["keyImages"]: 60 | if image_entry["type"] == "DieselGameBoxTall": 61 | data["online_cover_url"] = image_entry["url"] 62 | break 63 | except (JSONDecodeError, OSError, KeyError): 64 | pass 65 | 66 | game = Game(values) 67 | return (game, data) 68 | 69 | def __iter__(self): 70 | # Open library 71 | file = self.source.locations.config["installed.json"] 72 | try: 73 | library: dict = json.load(file.open()) 74 | except (JSONDecodeError, OSError): 75 | logging.warning("Couldn't open Legendary file: %s", str(file)) 76 | return 77 | 78 | # Generate games from library 79 | for entry in library.values(): 80 | try: 81 | result = self.game_from_library_entry(entry) 82 | except KeyError as error: 83 | # Skip invalid games 84 | logging.warning( 85 | "Invalid Legendary game skipped in %s", str(file), exc_info=error 86 | ) 87 | continue 88 | yield result 89 | 90 | 91 | class LegendaryLocations(NamedTuple): 92 | config: Location 93 | 94 | 95 | class LegendarySource(ExecutableFormatSource): 96 | source_id = "legendary" 97 | name = _("Legendary") 98 | executable_format = "legendary launch {app_name}" 99 | available_on = {"linux"} 100 | iterable_class = LegendarySourceIterable 101 | 102 | locations: LegendaryLocations 103 | 104 | def __init__(self) -> None: 105 | super().__init__() 106 | self.locations = LegendaryLocations( 107 | Location( 108 | schema_key="legendary-location", 109 | candidates=( 110 | shared.config_dir / "legendary", 111 | shared.host_config_dir / "legendary", 112 | ), 113 | paths={ 114 | "installed.json": LocationSubPath("installed.json"), 115 | "metadata": LocationSubPath("metadata", True), 116 | }, 117 | invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE, 118 | ) 119 | ) 120 | -------------------------------------------------------------------------------- /cartridges/importer/location.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from os import PathLike 3 | from pathlib import Path 4 | from typing import Iterable, Mapping, NamedTuple, Optional 5 | 6 | from cartridges import shared 7 | 8 | PathSegment = str | PathLike | Path 9 | PathSegments = Iterable[PathSegment] 10 | Candidate = PathSegments 11 | 12 | 13 | class LocationSubPath(NamedTuple): 14 | segment: PathSegment 15 | is_directory: bool = False 16 | 17 | 18 | class UnresolvableLocationError(Exception): 19 | def __init__(self, optional: Optional[bool] = False): 20 | self.optional = optional 21 | 22 | 23 | class Location: 24 | """ 25 | Class representing a filesystem location 26 | 27 | * A location may have multiple candidate roots 28 | * The path in the schema is always favored 29 | * From the candidate root, multiple subpaths should exist for it to be valid 30 | * When resolved, the schema is updated with the picked chosen 31 | """ 32 | 33 | # The variable is the name of the source 34 | CACHE_INVALID_SUBTITLE = _("Select the {} cache directory.") 35 | # The variable is the name of the source 36 | CONFIG_INVALID_SUBTITLE = _("Select the {} configuration directory.") 37 | # The variable is the name of the source 38 | DATA_INVALID_SUBTITLE = _("Select the {} data directory.") 39 | 40 | schema_key: str 41 | candidates: Iterable[Candidate] 42 | paths: Mapping[str, LocationSubPath] 43 | invalid_subtitle: str 44 | 45 | root: Optional[Path] = None 46 | 47 | def __init__( 48 | self, 49 | schema_key: str, 50 | candidates: Iterable[Candidate], 51 | paths: Mapping[str, LocationSubPath], 52 | invalid_subtitle: str, 53 | optional: Optional[bool] = False, 54 | ) -> None: 55 | super().__init__() 56 | self.schema_key = schema_key 57 | self.candidates = candidates 58 | self.paths = paths 59 | self.invalid_subtitle = invalid_subtitle 60 | self.optional = optional 61 | 62 | def check_candidate(self, candidate: Path) -> bool: 63 | """Check if a candidate root has the necessary files and directories""" 64 | for segment, is_directory in self.paths.values(): 65 | path = Path(candidate) / segment 66 | if is_directory: 67 | if not path.is_dir(): 68 | return False 69 | else: 70 | if not path.is_file(): 71 | return False 72 | return True 73 | 74 | def resolve(self) -> None: 75 | """Choose a root path from the candidates for the location. 76 | If none fits, raise an UnresolvableLocationError""" 77 | 78 | if self.root is not None: 79 | return 80 | 81 | # Get the schema candidate 82 | schema_candidate = shared.schema.get_string(self.schema_key) 83 | 84 | # Find the first matching candidate 85 | for candidate in (schema_candidate, *self.candidates): 86 | candidate = Path(candidate).expanduser() 87 | if not self.check_candidate(candidate): 88 | continue 89 | self.root = candidate 90 | break 91 | else: 92 | # No good candidate found 93 | raise UnresolvableLocationError(self.optional) 94 | 95 | # Update the schema with the found candidate 96 | value = str(candidate) 97 | shared.schema.set_string(self.schema_key, value) 98 | logging.debug("Resolved value for schema key %s: %s", self.schema_key, value) 99 | 100 | def __getitem__(self, key: str) -> Optional[Path]: 101 | """Get the computed path from its key for the location""" 102 | try: 103 | self.resolve() 104 | except UnresolvableLocationError as error: 105 | if error.optional: 106 | return None 107 | raise UnresolvableLocationError from error 108 | 109 | if self.root: 110 | return self.root / self.paths[key].segment 111 | return None 112 | -------------------------------------------------------------------------------- /cartridges/importer/lutris_source.py: -------------------------------------------------------------------------------- 1 | # lutris_source.py 2 | # 3 | # Copyright 2022-2024 kramo 4 | # Copyright 2023 Geoffrey Coulaud 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # SPDX-License-Identifier: GPL-3.0-or-later 20 | from shutil import rmtree 21 | from sqlite3 import connect 22 | from typing import NamedTuple 23 | 24 | from cartridges import shared 25 | from cartridges.game import Game 26 | from cartridges.importer.location import Location, LocationSubPath 27 | from cartridges.importer.source import SourceIterable, URLExecutableSource 28 | from cartridges.utils.sqlite import copy_db 29 | 30 | 31 | class LutrisSourceIterable(SourceIterable): 32 | source: "LutrisSource" 33 | 34 | def __iter__(self): 35 | """Generator method producing games""" 36 | 37 | # Query the database 38 | request = """ 39 | SELECT 40 | games.id, 41 | games.name, 42 | games.slug, 43 | games.runner, 44 | categories.name = ".hidden" as hidden 45 | FROM 46 | games 47 | LEFT JOIN 48 | games_categories ON games_categories.game_id = games.id 49 | FULL JOIN 50 | categories ON games_categories.category_id = categories.id 51 | WHERE 52 | games.name IS NOT NULL 53 | AND games.slug IS NOT NULL 54 | AND games.configPath IS NOT NULL 55 | AND games.installed 56 | AND (games.runner IS NOT "steam" OR :import_steam) 57 | AND (games.runner IS NOT "flatpak" OR :import_flatpak) 58 | ; 59 | """ 60 | 61 | params = { 62 | "import_steam": shared.schema.get_boolean("lutris-import-steam"), 63 | "import_flatpak": shared.schema.get_boolean("lutris-import-flatpak"), 64 | } 65 | db_path = copy_db(self.source.locations.data["pga.db"]) 66 | connection = connect(db_path) 67 | cursor = connection.execute(request, params) 68 | coverart_is_dir = ( 69 | coverart_path := self.source.locations.data.root / "coverart" 70 | ).is_dir() 71 | 72 | # Create games from the DB results 73 | for row in cursor: 74 | # Create game 75 | values = { 76 | "added": shared.import_time, 77 | "hidden": row[4], 78 | "name": row[1], 79 | "source": f"{self.source.source_id}_{row[3]}", 80 | "game_id": self.source.game_id_format.format( 81 | runner=row[3], game_id=row[0] 82 | ), 83 | "executable": self.source.make_executable(game_id=row[0]), 84 | } 85 | game = Game(values) 86 | additional_data = {} 87 | 88 | # Get official image path 89 | if coverart_is_dir: 90 | image_path = coverart_path / f"{row[2]}.jpg" 91 | additional_data["local_image_path"] = image_path 92 | 93 | yield (game, additional_data) 94 | 95 | # Cleanup 96 | rmtree(str(db_path.parent)) 97 | 98 | 99 | class LutrisLocations(NamedTuple): 100 | data: Location 101 | 102 | 103 | class LutrisSource(URLExecutableSource): 104 | """Generic Lutris source""" 105 | 106 | source_id = "lutris" 107 | name = _("Lutris") 108 | iterable_class = LutrisSourceIterable 109 | url_format = "lutris:rungameid/{game_id}" 110 | available_on = {"linux"} 111 | 112 | locations: LutrisLocations 113 | 114 | @property 115 | def game_id_format(self): 116 | return self.source_id + "_{runner}_{game_id}" 117 | 118 | def __init__(self) -> None: 119 | super().__init__() 120 | self.locations = LutrisLocations( 121 | Location( 122 | schema_key="lutris-location", 123 | candidates=( 124 | shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris", 125 | shared.data_dir / "lutris", 126 | shared.host_data_dir / "lutris", 127 | ), 128 | paths={ 129 | "pga.db": LocationSubPath("pga.db"), 130 | }, 131 | invalid_subtitle=Location.DATA_INVALID_SUBTITLE, 132 | ) 133 | ) 134 | -------------------------------------------------------------------------------- /cartridges/importer/source.py: -------------------------------------------------------------------------------- 1 | # source.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import sys 21 | from abc import abstractmethod 22 | from collections.abc import Iterable 23 | from typing import Any, Collection, Generator, Optional 24 | 25 | from cartridges.game import Game 26 | from cartridges.importer.location import Location, UnresolvableLocationError 27 | 28 | # Type of the data returned by iterating on a Source 29 | SourceIterationResult = Optional[Game | tuple[Game, tuple[Any]]] 30 | 31 | 32 | class SourceIterable(Iterable): 33 | """Data producer for a source of games""" 34 | 35 | source: "Source" 36 | 37 | def __init__(self, source: "Source") -> None: 38 | self.source = source 39 | 40 | @abstractmethod 41 | def __iter__(self) -> Generator[SourceIterationResult, None, None]: 42 | """ 43 | Method that returns a generator that produces games 44 | * Should be implemented as a generator method 45 | * May yield `None` when an iteration hasn't produced a game 46 | * In charge of handling per-game errors 47 | * Returns when exhausted 48 | """ 49 | 50 | 51 | class Source(Iterable): 52 | """Source of games. E.g an installed app with a config file that lists game directories""" 53 | 54 | source_id: str 55 | name: str 56 | variant: Optional[str] = None 57 | available_on: set[str] = set() 58 | iterable_class: type[SourceIterable] 59 | 60 | # NOTE: Locations must be set at __init__ time, not in the class definition. 61 | # They must not be shared between source instances. 62 | locations: Collection[Location] 63 | 64 | @property 65 | def full_name(self) -> str: 66 | """The source's full name""" 67 | full_name_ = self.name 68 | if self.variant: 69 | full_name_ += f" ({self.variant})" 70 | return full_name_ 71 | 72 | @property 73 | def game_id_format(self) -> str: 74 | """The string format used to construct game IDs""" 75 | return self.source_id + "_{game_id}" 76 | 77 | @property 78 | def is_available(self) -> bool: 79 | return any(sys.platform.startswith(platform) for platform in self.available_on) 80 | 81 | def make_executable(self, *args, **kwargs) -> str: 82 | """ 83 | Create a game executable command. 84 | Should be implemented by child classes. 85 | """ 86 | 87 | def __iter__(self) -> Generator[SourceIterationResult, None, None]: 88 | """ 89 | Get an iterator for the source 90 | :raises UnresolvableLocationError: Not iterable 91 | if any of the mandatory locations are unresolvable 92 | """ 93 | for location in self.locations: 94 | try: 95 | location.resolve() 96 | except UnresolvableLocationError as error: 97 | if not error.optional: 98 | raise UnresolvableLocationError from error 99 | return iter(self.iterable_class(self)) 100 | 101 | 102 | class ExecutableFormatSource(Source): 103 | """Source class that uses a simple executable format to start games""" 104 | 105 | @property 106 | @abstractmethod 107 | def executable_format(self) -> str: 108 | """The executable format used to construct game executables""" 109 | 110 | def make_executable(self, *args, **kwargs) -> str: 111 | """Use the executable format to""" 112 | return self.executable_format.format(*args, **kwargs) 113 | 114 | 115 | # pylint: disable=abstract-method 116 | class URLExecutableSource(ExecutableFormatSource): 117 | """Source class that use custom URLs to start games""" 118 | 119 | url_format: str 120 | 121 | @property 122 | def executable_format(self) -> str: 123 | if sys.platform.startswith("win32"): 124 | return f"start {self.url_format}" 125 | 126 | if sys.platform.startswith("linux"): 127 | return f"xdg-open {self.url_format}" 128 | 129 | if sys.platform.startswith("darwin"): 130 | return f"open {self.url_format}" 131 | 132 | raise NotImplementedError( 133 | f"No URL handler command available for {sys.platform}" 134 | ) 135 | -------------------------------------------------------------------------------- /cartridges/importer/steam_source.py: -------------------------------------------------------------------------------- 1 | # steam_source.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # Copyright 2023 Geoffrey Coulaud 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # SPDX-License-Identifier: GPL-3.0-or-later 20 | 21 | import logging 22 | import re 23 | from pathlib import Path 24 | from typing import Iterable, NamedTuple 25 | 26 | from cartridges import shared 27 | from cartridges.game import Game 28 | from cartridges.importer.location import Location, LocationSubPath 29 | from cartridges.importer.source import SourceIterable, URLExecutableSource 30 | from cartridges.utils.steam import SteamFileHelper, SteamInvalidManifestError 31 | 32 | 33 | class SteamSourceIterable(SourceIterable): 34 | source: "SteamSource" 35 | 36 | def get_manifest_dirs(self) -> Iterable[Path]: 37 | """Get dirs that contain Steam app manifests""" 38 | libraryfolders_path = self.source.locations.data["libraryfolders.vdf"] 39 | with open(libraryfolders_path, "r", encoding="utf-8") as file: 40 | contents = file.read() 41 | return [ 42 | Path(path) / "steamapps" 43 | for path in re.findall('"path"\\s+"(.*)"\n', contents, re.IGNORECASE) 44 | ] 45 | 46 | def get_manifests(self) -> Iterable[Path]: 47 | """Get app manifests""" 48 | manifests = set() 49 | for steamapps_dir in self.get_manifest_dirs(): 50 | if not steamapps_dir.is_dir(): 51 | continue 52 | manifests.update( 53 | [ 54 | manifest 55 | for manifest in steamapps_dir.glob("appmanifest_*.acf") 56 | if manifest.is_file() 57 | ] 58 | ) 59 | return manifests 60 | 61 | def __iter__(self): 62 | """Generator method producing games""" 63 | appid_cache = set() 64 | manifests = self.get_manifests() 65 | 66 | for manifest in manifests: 67 | # Get metadata from manifest 68 | steam = SteamFileHelper() 69 | try: 70 | local_data = steam.get_manifest_data(manifest) 71 | except (OSError, SteamInvalidManifestError) as error: 72 | logging.debug("Couldn't load appmanifest %s", manifest, exc_info=error) 73 | continue 74 | 75 | # Skip non installed games 76 | installed_mask = 4 77 | if not int(local_data["stateflags"]) & installed_mask: 78 | logging.debug("Skipped %s: not installed", manifest) 79 | continue 80 | 81 | # Skip duplicate appids 82 | appid = local_data["appid"] 83 | if appid in appid_cache: 84 | logging.debug("Skipped %s: appid already seen during import", manifest) 85 | continue 86 | appid_cache.add(appid) 87 | 88 | # Build game from local data 89 | values = { 90 | "added": shared.import_time, 91 | "name": local_data["name"], 92 | "source": self.source.source_id, 93 | "game_id": self.source.game_id_format.format(game_id=appid), 94 | "executable": self.source.make_executable(game_id=appid), 95 | } 96 | game = Game(values) 97 | 98 | # Add official cover image 99 | image_path = ( 100 | self.source.locations.data["librarycache"] 101 | / appid 102 | / "library_600x900.jpg" 103 | ) 104 | additional_data = {"local_image_path": image_path, "steam_appid": appid} 105 | 106 | yield (game, additional_data) 107 | 108 | 109 | class SteamLocations(NamedTuple): 110 | data: Location 111 | 112 | 113 | class SteamSource(URLExecutableSource): 114 | source_id = "steam" 115 | name = _("Steam") 116 | available_on = {"linux", "win32", "darwin"} 117 | iterable_class = SteamSourceIterable 118 | url_format = "steam://rungameid/{game_id}" 119 | 120 | locations: SteamLocations 121 | 122 | def __init__(self) -> None: 123 | super().__init__() 124 | self.locations = SteamLocations( 125 | Location( 126 | schema_key="steam-location", 127 | candidates=( 128 | shared.home / ".steam" / "steam", 129 | shared.data_dir / "Steam", 130 | shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam", 131 | shared.programfiles32_dir / "Steam", 132 | shared.app_support_dir / "Steam", 133 | ), 134 | paths={ 135 | "libraryfolders.vdf": LocationSubPath( 136 | "steamapps/libraryfolders.vdf" 137 | ), 138 | "librarycache": LocationSubPath("appcache/librarycache", True), 139 | }, 140 | invalid_subtitle=Location.DATA_INVALID_SUBTITLE, 141 | ) 142 | ) 143 | -------------------------------------------------------------------------------- /cartridges/logging/color_log_formatter.py: -------------------------------------------------------------------------------- 1 | # color_log_formatter.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from logging import Formatter, LogRecord 21 | 22 | 23 | class ColorLogFormatter(Formatter): 24 | """Formatter that outputs logs in a colored format""" 25 | 26 | RESET = "\033[0m" 27 | DIM = "\033[2m" 28 | BOLD = "\033[1m" 29 | RED = "\033[31m" 30 | YELLOW = "\033[33m" 31 | 32 | def format(self, record: LogRecord) -> str: 33 | super_format = super().format(record) 34 | match record.levelname: 35 | case "CRITICAL": 36 | return self.BOLD + self.RED + super_format + self.RESET 37 | case "ERROR": 38 | return self.RED + super_format + self.RESET 39 | case "WARNING": 40 | return self.YELLOW + super_format + self.RESET 41 | case "DEBUG": 42 | return self.DIM + super_format + self.RESET 43 | case _other: 44 | return super_format 45 | -------------------------------------------------------------------------------- /cartridges/logging/session_file_handler.py: -------------------------------------------------------------------------------- 1 | # session_file_handler.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import lzma 21 | from io import TextIOWrapper 22 | from logging import StreamHandler 23 | from lzma import FORMAT_XZ, PRESET_DEFAULT 24 | from os import PathLike 25 | from pathlib import Path 26 | from typing import Optional 27 | 28 | from cartridges import shared 29 | 30 | 31 | class SessionFileHandler(StreamHandler): 32 | """ 33 | A logging handler that writes to a new file on every app restart. 34 | The files are compressed and older sessions logs are kept up to a small limit. 35 | """ 36 | 37 | NUMBER_SUFFIX_POSITION = 1 38 | 39 | backup_count: int 40 | filename: Path 41 | log_file: Optional[TextIOWrapper] = None 42 | 43 | def create_dir(self) -> None: 44 | """Create the log dir if needed""" 45 | self.filename.parent.mkdir(exist_ok=True, parents=True) 46 | 47 | def path_is_logfile(self, path: Path) -> bool: 48 | return path.is_file() and path.name.startswith(self.filename.stem) 49 | 50 | def path_has_number(self, path: Path) -> bool: 51 | try: 52 | int(path.suffixes[self.NUMBER_SUFFIX_POSITION][1:]) 53 | except (ValueError, IndexError): 54 | return False 55 | return True 56 | 57 | def get_path_number(self, path: Path) -> int: 58 | """Get the number extension in the filename as an int""" 59 | suffixes = path.suffixes 60 | number = ( 61 | 0 62 | if not self.path_has_number(path) 63 | else int(suffixes[self.NUMBER_SUFFIX_POSITION][1:]) 64 | ) 65 | return number 66 | 67 | def set_path_number(self, path: Path, number: int) -> str: 68 | """Set or add the number extension in the filename""" 69 | suffixes = path.suffixes 70 | if self.path_has_number(path): 71 | suffixes.pop(self.NUMBER_SUFFIX_POSITION) 72 | suffixes.insert(self.NUMBER_SUFFIX_POSITION, f".{number}") 73 | stem = path.name.split(".", maxsplit=1)[0] 74 | new_name = stem + "".join(suffixes) 75 | return new_name 76 | 77 | def file_sort_key(self, path: Path) -> int: 78 | """Key function used to sort files""" 79 | return self.get_path_number(path) if self.path_has_number(path) else 0 80 | 81 | def get_logfiles(self) -> list[Path]: 82 | """Get the log files""" 83 | logfiles = list(filter(self.path_is_logfile, self.filename.parent.iterdir())) 84 | logfiles.sort(key=self.file_sort_key, reverse=True) 85 | return logfiles 86 | 87 | def rotate_file(self, path: Path) -> None: 88 | """Rotate a file's number suffix and remove it if it's too old""" 89 | 90 | # If uncompressed, compress 91 | if not path.name.endswith(".xz"): 92 | try: 93 | with open(path, "r", encoding="utf-8") as original_file: 94 | original_data = original_file.read() 95 | except UnicodeDecodeError: 96 | # If the file is corrupted, throw it away 97 | path.unlink() 98 | return 99 | 100 | # Compress the file 101 | compressed_path = path.with_suffix(path.suffix + ".xz") 102 | with lzma.open( 103 | compressed_path, 104 | "wt", 105 | format=FORMAT_XZ, 106 | preset=PRESET_DEFAULT, 107 | encoding="utf-8", 108 | ) as lzma_file: 109 | lzma_file.write(original_data) 110 | path.unlink() 111 | path = compressed_path 112 | 113 | # Rename with new number suffix 114 | new_number = self.get_path_number(path) + 1 115 | new_path_name = self.set_path_number(path, new_number) 116 | path = path.rename(path.with_name(new_path_name)) 117 | 118 | # Remove older files 119 | if new_number > self.backup_count: 120 | path.unlink() 121 | return 122 | 123 | def rotate(self) -> None: 124 | """Rotate the numbered suffix on the log files and remove old ones""" 125 | for path in self.get_logfiles(): 126 | self.rotate_file(path) 127 | 128 | def __init__(self, filename: PathLike, backup_count: int = 2) -> None: 129 | self.filename = Path(filename) 130 | self.backup_count = backup_count 131 | self.create_dir() 132 | self.rotate() 133 | self.log_file = open(self.filename, "w", encoding="utf-8") 134 | shared.log_files = self.get_logfiles() 135 | super().__init__(self.log_file) 136 | 137 | def close(self) -> None: 138 | if self.log_file: 139 | self.log_file.close() 140 | super().close() 141 | -------------------------------------------------------------------------------- /cartridges/logging/setup.py: -------------------------------------------------------------------------------- 1 | # setup.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import logging 21 | import logging.config as logging_dot_config 22 | import os 23 | import platform 24 | import subprocess 25 | import sys 26 | 27 | from cartridges import shared 28 | 29 | 30 | def setup_logging() -> None: 31 | """Intitate the app's logging""" 32 | 33 | is_dev = shared.PROFILE == "development" 34 | profile_app_log_level = "DEBUG" if is_dev else "INFO" 35 | profile_lib_log_level = "INFO" if is_dev else "WARNING" 36 | app_log_level = os.environ.get("LOGLEVEL", profile_app_log_level).upper() 37 | lib_log_level = os.environ.get("LIBLOGLEVEL", profile_lib_log_level).upper() 38 | 39 | log_filename = shared.cache_dir / "cartridges" / "logs" / "cartridges.log" 40 | 41 | config = { 42 | "version": 1, 43 | "formatters": { 44 | "file_formatter": { 45 | "format": "%(asctime)s - %(levelname)s: %(message)s", 46 | "datefmt": "%M:%S", 47 | }, 48 | "console_formatter": { 49 | "format": "%(name)s %(levelname)s - %(message)s", 50 | "class": "cartridges.logging.color_log_formatter.ColorLogFormatter", 51 | }, 52 | }, 53 | "handlers": { 54 | "file_handler": { 55 | "class": "cartridges.logging.session_file_handler.SessionFileHandler", 56 | "formatter": "file_formatter", 57 | "level": "DEBUG", 58 | "filename": log_filename, 59 | "backup_count": 2, 60 | }, 61 | "app_console_handler": { 62 | "class": "logging.StreamHandler", 63 | "formatter": "console_formatter", 64 | "level": app_log_level, 65 | }, 66 | "lib_console_handler": { 67 | "class": "logging.StreamHandler", 68 | "formatter": "console_formatter", 69 | "level": lib_log_level, 70 | }, 71 | }, 72 | "loggers": { 73 | "PIL": { 74 | "handlers": ["lib_console_handler", "file_handler"], 75 | "propagate": False, 76 | "level": "WARNING", 77 | }, 78 | "urllib3": { 79 | "handlers": ["lib_console_handler", "file_handler"], 80 | "propagate": False, 81 | "level": "NOTSET", 82 | }, 83 | }, 84 | "root": { 85 | "level": "NOTSET", 86 | "handlers": ["app_console_handler", "file_handler"], 87 | }, 88 | } 89 | logging_dot_config.dictConfig(config) 90 | 91 | 92 | def log_system_info() -> None: 93 | """Log system debug information""" 94 | 95 | logging.debug("Starting %s v%s (%s)", shared.APP_ID, shared.VERSION, shared.PROFILE) 96 | logging.debug("Python version: %s", sys.version) 97 | if os.getenv("FLATPAK_ID") == shared.APP_ID: 98 | process = subprocess.run( 99 | ("flatpak-spawn", "--host", "flatpak", "--version"), 100 | capture_output=True, 101 | encoding="utf-8", 102 | check=False, 103 | ) 104 | logging.debug("Flatpak version: %s", process.stdout.rstrip()) 105 | logging.debug("Platform: %s", platform.platform()) 106 | if os.name == "posix": 107 | for key, value in platform.uname()._asdict().items(): 108 | logging.debug("\t%s: %s", key.title(), value) 109 | logging.debug("─" * 37) 110 | -------------------------------------------------------------------------------- /cartridges/meson.build: -------------------------------------------------------------------------------- 1 | moduledir = python_dir / 'cartridges' 2 | 3 | configure_file( 4 | input: 'cartridges.in', 5 | output: 'cartridges', 6 | configuration: conf, 7 | install: true, 8 | install_dir: get_option('bindir'), 9 | ) 10 | 11 | install_subdir('importer', install_dir: moduledir) 12 | install_subdir('utils', install_dir: moduledir) 13 | install_subdir('store', install_dir: moduledir) 14 | install_subdir('logging', install_dir: moduledir) 15 | install_subdir('errors', install_dir: moduledir) 16 | install_data( 17 | [ 18 | 'application_delegate.py', 19 | 'main.py', 20 | 'window.py', 21 | 'preferences.py', 22 | 'details_dialog.py', 23 | 'game.py', 24 | 'game_cover.py', 25 | configure_file(input: 'shared.py.in', output: 'shared.py', configuration: conf), 26 | ], 27 | install_dir: moduledir, 28 | ) 29 | -------------------------------------------------------------------------------- /cartridges/shared.py.in: -------------------------------------------------------------------------------- 1 | # shared.py.in 2 | # 3 | # Copyright 2022-2023 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from enum import IntEnum, auto 21 | from os import getenv 22 | from pathlib import Path 23 | 24 | from gi.repository import Gdk, Gio, GLib 25 | 26 | 27 | class AppState(IntEnum): 28 | DEFAULT = auto() 29 | LOAD_FROM_DISK = auto() 30 | IMPORT = auto() 31 | REMOVE_ALL_GAMES = auto() 32 | UNDO_REMOVE_ALL_GAMES = auto() 33 | 34 | 35 | APP_ID = "@APP_ID@" 36 | VERSION = "@VERSION@" 37 | PREFIX = "@PREFIX@" 38 | PROFILE = "@PROFILE@" 39 | TIFF_COMPRESSION = "@TIFF_COMPRESSION@" 40 | SPEC_VERSION = 1.5 # The version of the game_id.json spec 41 | 42 | schema = Gio.Settings.new(APP_ID) 43 | state_schema = Gio.Settings.new(APP_ID + ".State") 44 | 45 | home = Path.home() 46 | 47 | data_dir = Path(GLib.get_user_data_dir()) 48 | host_data_dir = Path(getenv("HOST_XDG_DATA_HOME", Path.home() / ".local" / "share")) 49 | 50 | config_dir = Path(GLib.get_user_config_dir()) 51 | host_config_dir = Path(getenv("HOST_XDG_CONFIG_HOME", Path.home() / ".config")) 52 | 53 | cache_dir = Path(GLib.get_user_cache_dir()) 54 | host_cache_dir = Path(getenv("HOST_XDG_CACHE_HOME", Path.home() / ".cache")) 55 | 56 | flatpak_dir = home / ".var" / "app" 57 | 58 | games_dir = data_dir / "cartridges" / "games" 59 | covers_dir = data_dir / "cartridges" / "covers" 60 | 61 | appdata_dir = Path(getenv("appdata") or r"C:\Users\Default\AppData\Roaming") 62 | local_appdata_dir = Path( 63 | getenv("csidl_local_appdata") or r"C:\Users\Default\AppData\Local" 64 | ) 65 | programfiles32_dir = Path(getenv("programfiles(x86)") or r"C:\Program Files (x86)") 66 | 67 | app_support_dir = home / "Library" / "Application Support" 68 | 69 | try: 70 | scale_factor = max( 71 | monitor.get_scale_factor() 72 | for monitor in Gdk.Display.get_default().get_monitors() 73 | ) 74 | except AttributeError: # If shared.py is imported by the search provider 75 | pass 76 | else: 77 | image_size = (200 * scale_factor, 300 * scale_factor) 78 | 79 | # pylint: disable=invalid-name 80 | win = None 81 | importer = None 82 | import_time = None 83 | store = None 84 | log_files = [] 85 | -------------------------------------------------------------------------------- /cartridges/shared.pyi: -------------------------------------------------------------------------------- 1 | # shared.pyi 2 | # 3 | # Copyright 2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from pathlib import Path 21 | from typing import Optional 22 | 23 | from gi.repository import Gio 24 | 25 | from cartridges.importer.importer import Importer 26 | from cartridges.store.store import Store 27 | from cartridges.window import CartridgesWindow 28 | 29 | 30 | class AppState: 31 | DEFAULT: int 32 | LOAD_FROM_DISK: int 33 | IMPORT: int 34 | REMOVE_ALL_GAMES: int 35 | UNDO_REMOVE_ALL_GAMES: int 36 | 37 | 38 | APP_ID: str 39 | VERSION: str 40 | PREFIX: str 41 | PROFILE: str 42 | TIFF_COMPRESSION: str 43 | SPEC_VERSION: float 44 | 45 | schema: Gio.Settings 46 | state_schema: Gio.Settings 47 | 48 | home: Path 49 | 50 | data_dir: Path 51 | host_data_dir: Path 52 | 53 | config_dir: Path 54 | host_config_dir: Path 55 | 56 | cache_dir: Path 57 | host_cache_dir: Path 58 | 59 | flatpak_dir: Path 60 | 61 | games_dir: Path 62 | covers_dir: Path 63 | 64 | appdata_dir: Path 65 | local_appdata_dir: Path 66 | programfiles32_dir: Path 67 | 68 | app_support_dir: Path 69 | 70 | 71 | scale_factor: int 72 | image_size: int 73 | 74 | win: Optional[CartridgesWindow] 75 | importer: Optional[Importer] 76 | import_time: Optional[int] 77 | store = Optional[Store] 78 | log_files: list[Path] 79 | -------------------------------------------------------------------------------- /cartridges/store/managers/async_manager.py: -------------------------------------------------------------------------------- 1 | # async_manager.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from typing import Any, Callable 21 | 22 | from gi.repository import Gio 23 | 24 | from cartridges.game import Game 25 | from cartridges.store.managers.manager import Manager 26 | 27 | 28 | class AsyncManager(Manager): 29 | """Manager that can run asynchronously""" 30 | 31 | blocking = False 32 | cancellable: Gio.Cancellable = None 33 | 34 | def __init__(self) -> None: 35 | super().__init__() 36 | self.cancellable = Gio.Cancellable() 37 | 38 | def cancel_tasks(self): 39 | """Cancel all tasks for this manager""" 40 | self.cancellable.cancel() 41 | 42 | def reset_cancellable(self): 43 | """Reset the cancellable for this manager. 44 | Already scheduled Tasks will no longer be cancellable.""" 45 | self.cancellable = Gio.Cancellable() 46 | 47 | def process_game( 48 | self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any] 49 | ) -> None: 50 | """Create a task to process the game in a separate thread""" 51 | task = Gio.Task.new(None, self.cancellable, self._task_callback, (callback,)) 52 | task.run_in_thread(lambda *_: self._task_thread_func((game, additional_data))) 53 | 54 | def _task_thread_func(self, data): 55 | """Task thread entry point""" 56 | game, additional_data, *_rest = data 57 | self.run(game, additional_data) 58 | 59 | def _task_callback(self, _source_object, _result, data): 60 | """Method run after the task is done""" 61 | callback, *_rest = data 62 | callback(self) 63 | -------------------------------------------------------------------------------- /cartridges/store/managers/cover_manager.py: -------------------------------------------------------------------------------- 1 | # local_cover_manager.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # Copyright 2023 kramo 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # SPDX-License-Identifier: GPL-3.0-or-later 20 | 21 | from pathlib import Path 22 | from typing import NamedTuple 23 | 24 | import requests 25 | from gi.repository import GdkPixbuf, Gio 26 | from requests.exceptions import HTTPError, SSLError 27 | 28 | from cartridges import shared 29 | from cartridges.game import Game 30 | from cartridges.store.managers.manager import Manager 31 | from cartridges.store.managers.steam_api_manager import SteamAPIManager 32 | from cartridges.utils.save_cover import convert_cover, save_cover 33 | 34 | 35 | class ImageSize(NamedTuple): 36 | width: float = 0 37 | height: float = 0 38 | 39 | @property 40 | def aspect_ratio(self) -> float: 41 | return self.width / self.height 42 | 43 | def __str__(self): 44 | return f"{self.width}x{self.height}" 45 | 46 | def __mul__(self, scale: float | int) -> "ImageSize": 47 | return ImageSize( 48 | self.width * scale, 49 | self.height * scale, 50 | ) 51 | 52 | def __truediv__(self, divisor: float | int) -> "ImageSize": 53 | return self * (1 / divisor) 54 | 55 | def __add__(self, other_size: "ImageSize") -> "ImageSize": 56 | return ImageSize( 57 | self.width + other_size.width, 58 | self.height + other_size.height, 59 | ) 60 | 61 | def __sub__(self, other_size: "ImageSize") -> "ImageSize": 62 | return self + (other_size * -1) 63 | 64 | def element_wise_div(self, other_size: "ImageSize") -> "ImageSize": 65 | """Divide every element of self by the equivalent in the other size""" 66 | return ImageSize( 67 | self.width / other_size.width, 68 | self.height / other_size.height, 69 | ) 70 | 71 | def element_wise_mul(self, other_size: "ImageSize") -> "ImageSize": 72 | """Multiply every element of self by the equivalent in the other size""" 73 | return ImageSize( 74 | self.width * other_size.width, 75 | self.height * other_size.height, 76 | ) 77 | 78 | def invert(self) -> "ImageSize": 79 | """Invert the element of self""" 80 | return ImageSize(1, 1).element_wise_div(self) 81 | 82 | 83 | class CoverManager(Manager): 84 | """ 85 | Manager in charge of adding the cover image of the game 86 | 87 | Order of priority is: 88 | 1. local cover 89 | 2. icon cover 90 | 3. online cover 91 | """ 92 | 93 | run_after = (SteamAPIManager,) 94 | retryable_on = (HTTPError, SSLError, ConnectionError) 95 | 96 | def download_image(self, url: str) -> Path: 97 | image_file = Gio.File.new_tmp()[0] 98 | path = Path(image_file.get_path()) 99 | with requests.get(url, timeout=5) as cover: 100 | cover.raise_for_status() 101 | path.write_bytes(cover.content) 102 | return path 103 | 104 | def is_stretchable(self, source_size: ImageSize, cover_size: ImageSize) -> bool: 105 | is_taller = source_size.aspect_ratio < cover_size.aspect_ratio 106 | if is_taller: 107 | return True 108 | max_stretch = 0.12 109 | resized_height = (1 / source_size.aspect_ratio) * cover_size.width 110 | stretch = 1 - (resized_height / cover_size.height) 111 | return stretch <= max_stretch 112 | 113 | def composite_cover( 114 | self, 115 | image_path: Path, 116 | scale: float = 1, 117 | blur_size: ImageSize = ImageSize(2, 2), 118 | ) -> GdkPixbuf.Pixbuf: 119 | """ 120 | Return the image composited with a background blur. 121 | If the image is stretchable, just stretch it. 122 | 123 | :param path: Path where the source image is located 124 | :param scale: 125 | Scale of the smalled image side 126 | compared to the corresponding side in the cover 127 | :param blur_size: Size of the downscaled image used for the blur 128 | """ 129 | 130 | # Load source image 131 | source = GdkPixbuf.Pixbuf.new_from_file( 132 | str(convert_cover(image_path, resize=False)) 133 | ) 134 | source_size = ImageSize(source.get_width(), source.get_height()) 135 | cover_size = ImageSize._make(shared.image_size) 136 | 137 | # Stretch if possible 138 | if scale == 1 and self.is_stretchable(source_size, cover_size): 139 | return source 140 | 141 | # Create the blurred cover background 142 | # fmt: off 143 | cover = ( 144 | source 145 | .scale_simple(*blur_size, GdkPixbuf.InterpType.BILINEAR) 146 | .scale_simple(*cover_size, GdkPixbuf.InterpType.BILINEAR) 147 | ) 148 | # fmt: on 149 | 150 | # Scale to fit, apply scaling, then center 151 | uniform_scale = scale * min(cover_size.element_wise_div(source_size)) 152 | source_in_cover_size = source_size * uniform_scale 153 | source_in_cover_position = (cover_size - source_in_cover_size) / 2 154 | 155 | # Center the scaled source image in the cover 156 | source.composite( 157 | cover, 158 | *source_in_cover_position, 159 | *source_in_cover_size, 160 | *source_in_cover_position, 161 | uniform_scale, 162 | uniform_scale, 163 | GdkPixbuf.InterpType.BILINEAR, 164 | 255, 165 | ) 166 | return cover 167 | 168 | def main(self, game: Game, additional_data: dict) -> None: 169 | if game.blacklisted: 170 | return 171 | for key in ( 172 | "local_image_path", 173 | "local_icon_path", 174 | "online_cover_url", 175 | ): 176 | # Get an image path 177 | if not (value := additional_data.get(key)): 178 | continue 179 | if key == "online_cover_url": 180 | image_path = self.download_image(value) 181 | else: 182 | image_path = Path(value) 183 | if not image_path.is_file(): 184 | continue 185 | 186 | # Icon cover 187 | composite_kwargs = {} 188 | 189 | if key == "local_icon_path": 190 | composite_kwargs["scale"] = 0.7 191 | composite_kwargs["blur_size"] = ImageSize(1, 2) 192 | 193 | save_cover( 194 | game.game_id, 195 | convert_cover( 196 | pixbuf=self.composite_cover(image_path, **composite_kwargs) 197 | ), 198 | ) 199 | -------------------------------------------------------------------------------- /cartridges/store/managers/display_manager.py: -------------------------------------------------------------------------------- 1 | # display_manager.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from cartridges import shared 21 | from cartridges.game import Game 22 | from cartridges.game_cover import GameCover 23 | from cartridges.store.managers.manager import Manager 24 | from cartridges.store.managers.sgdb_manager import SgdbManager 25 | from cartridges.store.managers.steam_api_manager import SteamAPIManager 26 | 27 | 28 | class DisplayManager(Manager): 29 | """Manager in charge of adding a game to the UI""" 30 | 31 | run_after = (SteamAPIManager, SgdbManager) 32 | signals = {"update-ready"} 33 | 34 | def main(self, game: Game, _additional_data: dict) -> None: 35 | if game.get_parent(): 36 | game.get_parent().get_parent().remove(game) 37 | if game.get_parent(): 38 | game.get_parent().set_child() 39 | 40 | game.menu_button.set_menu_model( 41 | game.hidden_game_options if game.hidden else game.game_options 42 | ) 43 | 44 | game.title.set_label(game.name) 45 | 46 | game.menu_button.get_popover().connect( 47 | "notify::visible", game.toggle_play, None 48 | ) 49 | game.menu_button.get_popover().connect( 50 | "notify::visible", shared.win.set_active_game, game 51 | ) 52 | 53 | if game.game_id in shared.win.game_covers: 54 | game.game_cover = shared.win.game_covers[game.game_id] 55 | game.game_cover.add_picture(game.cover) 56 | else: 57 | game.game_cover = GameCover({game.cover}, game.get_cover_path()) 58 | shared.win.game_covers[game.game_id] = game.game_cover 59 | 60 | if ( 61 | shared.win.navigation_view.get_visible_page() == shared.win.details_page 62 | and shared.win.active_game == game 63 | ): 64 | shared.win.show_details_page(game) 65 | 66 | if not game.removed and not game.blacklisted: 67 | if game.hidden: 68 | shared.win.hidden_library.append(game) 69 | else: 70 | shared.win.library.append(game) 71 | game.get_parent().set_focusable(False) 72 | 73 | shared.win.set_library_child() 74 | 75 | if shared.win.get_application().state == shared.AppState.DEFAULT: 76 | shared.win.create_source_rows() 77 | -------------------------------------------------------------------------------- /cartridges/store/managers/file_manager.py: -------------------------------------------------------------------------------- 1 | # file_manager.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import json 21 | 22 | from cartridges import shared 23 | from cartridges.game import Game 24 | from cartridges.store.managers.async_manager import AsyncManager 25 | from cartridges.store.managers.steam_api_manager import SteamAPIManager 26 | 27 | 28 | class FileManager(AsyncManager): 29 | """Manager in charge of saving a game to a file""" 30 | 31 | run_after = (SteamAPIManager,) 32 | signals = {"save-ready"} 33 | 34 | def main(self, game: Game, additional_data: dict) -> None: 35 | if additional_data.get("skip_save"): # Skip saving when loading games from disk 36 | return 37 | 38 | shared.games_dir.mkdir(parents=True, exist_ok=True) 39 | 40 | attrs = ( 41 | "added", 42 | "executable", 43 | "game_id", 44 | "source", 45 | "hidden", 46 | "last_played", 47 | "name", 48 | "developer", 49 | "removed", 50 | "blacklisted", 51 | "version", 52 | ) 53 | 54 | json.dump( 55 | {attr: getattr(game, attr) for attr in attrs if attr}, 56 | (shared.games_dir / f"{game.game_id}.json").open("w", encoding="utf-8"), 57 | indent=4, 58 | sort_keys=True, 59 | ) 60 | -------------------------------------------------------------------------------- /cartridges/store/managers/manager.py: -------------------------------------------------------------------------------- 1 | # manager.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import logging 21 | from abc import abstractmethod 22 | from time import sleep 23 | from typing import Any, Callable, Container 24 | 25 | from cartridges.errors.error_producer import ErrorProducer 26 | from cartridges.errors.friendly_error import FriendlyError 27 | from cartridges.game import Game 28 | 29 | 30 | class Manager(ErrorProducer): 31 | """Class in charge of handling a post creation action for games. 32 | 33 | * May connect to signals on the game to handle them. 34 | * May cancel its running tasks on critical error, 35 | in that case a new cancellable must be generated for new tasks to run. 36 | * May be retried on some specific error types 37 | """ 38 | 39 | run_after: Container[type["Manager"]] = tuple() 40 | blocking: bool = True 41 | 42 | retryable_on: Container[type[Exception]] = tuple() 43 | continue_on: Container[type[Exception]] = tuple() 44 | signals: Container[type[str]] = set() 45 | retry_delay: int = 3 46 | max_tries: int = 3 47 | 48 | @property 49 | def name(self) -> str: 50 | return type(self).__name__ 51 | 52 | @abstractmethod 53 | def main(self, game: Game, additional_data: dict) -> None: 54 | """ 55 | Manager specific logic triggered by the run method 56 | * Implemented by final child classes 57 | * May block its thread 58 | * May raise retryable exceptions that will trigger a retry if possible 59 | * May raise other exceptions that will be reported 60 | """ 61 | 62 | def run(self, game: Game, additional_data: dict) -> None: 63 | """Handle errors (retry, ignore or raise) that occur in the manager logic""" 64 | 65 | # Keep track of the number of tries 66 | tries = 1 67 | 68 | def handle_error(error: Exception) -> None: 69 | nonlocal tries 70 | 71 | # If FriendlyError, handle its cause instead 72 | base_error = error 73 | if isinstance(error, FriendlyError): 74 | error = error.__cause__ 75 | 76 | log_args = ( 77 | type(error).__name__, 78 | self.name, 79 | f"{game.name} ({game.game_id})", 80 | ) 81 | 82 | out_of_retries_format = "Out of retries dues to %s in %s for %s" 83 | retrying_format = "Retrying %s in %s for %s" 84 | unretryable_format = "Unretryable %s in %s for %s" 85 | 86 | if type(error) in self.continue_on: 87 | # Handle skippable errors (skip silently) 88 | return 89 | 90 | if type(error) in self.retryable_on: 91 | if tries > self.max_tries: 92 | # Handle being out of retries 93 | logging.error(out_of_retries_format, *log_args) 94 | self.report_error(base_error) 95 | else: 96 | # Handle retryable errors 97 | logging.error(retrying_format, *log_args) 98 | sleep(self.retry_delay) 99 | tries += 1 100 | try_manager_logic() 101 | 102 | else: 103 | # Handle unretryable errors 104 | logging.error(unretryable_format, *log_args, exc_info=error) 105 | self.report_error(base_error) 106 | 107 | def try_manager_logic() -> None: 108 | try: 109 | self.main(game, additional_data) 110 | except Exception as error: # pylint: disable=broad-exception-caught 111 | handle_error(error) 112 | 113 | try_manager_logic() 114 | 115 | def process_game( 116 | self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any] 117 | ) -> None: 118 | """Pass the game through the manager""" 119 | self.run(game, additional_data) 120 | callback(self) 121 | -------------------------------------------------------------------------------- /cartridges/store/managers/sgdb_manager.py: -------------------------------------------------------------------------------- 1 | # sgdb_manager.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from json import JSONDecodeError 21 | 22 | from requests.exceptions import HTTPError, SSLError 23 | 24 | from cartridges.errors.friendly_error import FriendlyError 25 | from cartridges.game import Game 26 | from cartridges.store.managers.async_manager import AsyncManager 27 | from cartridges.store.managers.cover_manager import CoverManager 28 | from cartridges.store.managers.steam_api_manager import SteamAPIManager 29 | from cartridges.utils.steamgriddb import SgdbAuthError, SgdbHelper 30 | 31 | 32 | class SgdbManager(AsyncManager): 33 | """Manager in charge of downloading a game's cover from SteamGridDB""" 34 | 35 | run_after = (SteamAPIManager, CoverManager) 36 | retryable_on = (HTTPError, SSLError, ConnectionError, JSONDecodeError) 37 | 38 | def main(self, game: Game, _additional_data: dict) -> None: 39 | try: 40 | sgdb = SgdbHelper() 41 | sgdb.conditionaly_update_cover(game) 42 | except SgdbAuthError as error: 43 | # If invalid auth, cancel all SGDBManager tasks 44 | self.cancellable.cancel() 45 | raise FriendlyError( 46 | _("Couldn't Authenticate SteamGridDB"), 47 | _("Verify your API key in preferences"), 48 | ) from error 49 | -------------------------------------------------------------------------------- /cartridges/store/managers/steam_api_manager.py: -------------------------------------------------------------------------------- 1 | # steam_api_manager.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from requests.exceptions import HTTPError, SSLError 21 | from urllib3.exceptions import ConnectionError as Urllib3ConnectionError 22 | 23 | from cartridges.game import Game 24 | from cartridges.store.managers.async_manager import AsyncManager 25 | from cartridges.utils.steam import ( 26 | SteamAPIHelper, 27 | SteamGameNotFoundError, 28 | SteamNotAGameError, 29 | SteamRateLimiter, 30 | ) 31 | 32 | 33 | class SteamAPIManager(AsyncManager): 34 | """Manager in charge of completing a game's data from the Steam API""" 35 | 36 | retryable_on = (HTTPError, SSLError, Urllib3ConnectionError) 37 | 38 | steam_api_helper: SteamAPIHelper = None 39 | steam_rate_limiter: SteamRateLimiter = None 40 | 41 | def __init__(self) -> None: 42 | super().__init__() 43 | self.steam_rate_limiter = SteamRateLimiter() 44 | self.steam_api_helper = SteamAPIHelper(self.steam_rate_limiter) 45 | 46 | def main(self, game: Game, additional_data: dict) -> None: 47 | # Skip non-Steam games 48 | appid = additional_data.get("steam_appid", None) 49 | if appid is None: 50 | return 51 | # Get online metadata 52 | try: 53 | online_data = self.steam_api_helper.get_api_data(appid=appid) 54 | except (SteamNotAGameError, SteamGameNotFoundError): 55 | game.update_values({"blacklisted": True}) 56 | else: 57 | game.update_values(online_data) 58 | -------------------------------------------------------------------------------- /cartridges/store/pipeline.py: -------------------------------------------------------------------------------- 1 | # pipeline.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import logging 21 | from typing import Iterable 22 | 23 | from gi.repository import GObject 24 | 25 | from cartridges.game import Game 26 | from cartridges.store.managers.manager import Manager 27 | 28 | 29 | class Pipeline(GObject.Object): 30 | """Class representing a set of managers for a game""" 31 | 32 | game: Game 33 | additional_data: dict 34 | 35 | waiting: set[Manager] 36 | running: set[Manager] 37 | done: set[Manager] 38 | 39 | def __init__( 40 | self, game: Game, additional_data: dict, managers: Iterable[Manager] 41 | ) -> None: 42 | super().__init__() 43 | self.game = game 44 | self.additional_data = additional_data 45 | self.waiting = set(managers) 46 | self.running = set() 47 | self.done = set() 48 | 49 | @property 50 | def not_done(self) -> set[Manager]: 51 | """Get the managers that are not done yet""" 52 | return self.waiting | self.running 53 | 54 | @property 55 | def is_done(self) -> bool: 56 | return len(self.waiting) == 0 and len(self.running) == 0 57 | 58 | @property 59 | def blocked(self) -> set[Manager]: 60 | """Get the managers that cannot run because their dependencies aren't done""" 61 | blocked = set() 62 | for waiting in self.waiting: 63 | for not_done in self.not_done: 64 | if waiting == not_done: 65 | continue 66 | if type(not_done) in waiting.run_after: 67 | blocked.add(waiting) 68 | return blocked 69 | 70 | @property 71 | def ready(self) -> set[Manager]: 72 | """Get the managers that can be run""" 73 | return self.waiting - self.blocked 74 | 75 | @property 76 | def progress(self) -> float: 77 | """Get the pipeline progress. Should only be a rough idea.""" 78 | n_done = len(self.done) 79 | n_total = len(self.waiting) + len(self.running) + n_done 80 | try: 81 | progress = n_done / n_total 82 | except ZeroDivisionError: 83 | progress = 1 84 | return progress 85 | 86 | def advance(self) -> None: 87 | """Spawn tasks for managers that are able to run for a game""" 88 | 89 | # Separate blocking / async managers 90 | managers = self.ready 91 | blocking = set(filter(lambda manager: manager.blocking, managers)) 92 | parallel = managers - blocking 93 | 94 | # Schedule parallel managers, then run the blocking ones 95 | for manager in (*parallel, *blocking): 96 | self.waiting.remove(manager) 97 | self.running.add(manager) 98 | manager.process_game(self.game, self.additional_data, self.manager_callback) 99 | 100 | def manager_callback(self, manager: Manager) -> None: 101 | """Method called by a manager when it's done""" 102 | logging.debug("%s done for %s", manager.name, self.game.game_id) 103 | self.running.remove(manager) 104 | self.done.add(manager) 105 | self.emit("advanced") 106 | self.advance() 107 | 108 | @GObject.Signal(name="advanced") 109 | def advanced(self): # type: ignore 110 | """Signal emitted when the pipeline has advanced""" 111 | -------------------------------------------------------------------------------- /cartridges/store/store.py: -------------------------------------------------------------------------------- 1 | # store.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import logging 21 | from typing import Any, Generator, MutableMapping, Optional 22 | 23 | from cartridges import shared 24 | from cartridges.game import Game 25 | from cartridges.store.managers.manager import Manager 26 | from cartridges.store.pipeline import Pipeline 27 | 28 | 29 | class Store: 30 | """Class in charge of handling games being added to the app.""" 31 | 32 | managers: dict[type[Manager], Manager] 33 | pipeline_managers: set[Manager] 34 | pipelines: dict[str, Pipeline] 35 | source_games: MutableMapping[str, MutableMapping[str, Game]] 36 | new_game_ids: set[str] 37 | duplicate_game_ids: set[str] 38 | 39 | def __init__(self) -> None: 40 | self.managers = {} 41 | self.pipeline_managers = set() 42 | self.pipelines = {} 43 | self.source_games = {} 44 | self.new_game_ids = set() 45 | self.duplicate_game_ids = set() 46 | 47 | def __contains__(self, obj: object) -> bool: 48 | """Check if the game is present in the store with the `in` keyword""" 49 | if not isinstance(obj, Game): 50 | return False 51 | if not (source_mapping := self.source_games.get(obj.base_source)): 52 | return False 53 | return obj.game_id in source_mapping 54 | 55 | def __iter__(self) -> Generator[Game, None, None]: 56 | """Iterate through the games in the store with `for ... in`""" 57 | for _source_id, games_mapping in self.source_games.items(): 58 | for _game_id, game in games_mapping.items(): 59 | yield game 60 | 61 | def __len__(self) -> int: 62 | """Get the number of games in the store with the `len` builtin""" 63 | return sum(len(source_mapping) for source_mapping in self.source_games.values()) 64 | 65 | def __getitem__(self, game_id: str) -> Game: 66 | """Get a game by its id with `store["game_id_goes_here"]`""" 67 | for game in iter(self): 68 | if game.game_id == game_id: 69 | return game 70 | raise KeyError("Game not found in store") 71 | 72 | def get(self, game_id: str, default: Any = None) -> Game | Any: 73 | """Get a game by its ID, with a fallback if not found""" 74 | try: 75 | game = self[game_id] 76 | return game 77 | except KeyError: 78 | return default 79 | 80 | def add_manager(self, manager: Manager, in_pipeline: bool = True) -> None: 81 | """Add a manager to the store""" 82 | manager_type = type(manager) 83 | self.managers[manager_type] = manager 84 | self.toggle_manager_in_pipelines(manager_type, in_pipeline) 85 | 86 | def toggle_manager_in_pipelines( 87 | self, manager_type: type[Manager], enable: bool 88 | ) -> None: 89 | """Change if a manager should run in new pipelines""" 90 | if enable: 91 | self.pipeline_managers.add(self.managers[manager_type]) 92 | else: 93 | self.pipeline_managers.discard(self.managers[manager_type]) 94 | 95 | def cleanup_game(self, game: Game) -> None: 96 | """Remove a game's files, dismiss any loose toasts""" 97 | for path in ( 98 | shared.games_dir / f"{game.game_id}.json", 99 | shared.covers_dir / f"{game.game_id}.tiff", 100 | shared.covers_dir / f"{game.game_id}.gif", 101 | ): 102 | path.unlink(missing_ok=True) 103 | 104 | # TODO: don't run this if the state is startup 105 | for undo in ("remove", "hide"): 106 | try: 107 | shared.win.toasts[(game, undo)].dismiss() 108 | shared.win.toasts.pop((game, undo)) 109 | except KeyError: 110 | pass 111 | 112 | def add_game( 113 | self, game: Game, additional_data: dict, run_pipeline: bool = True 114 | ) -> Optional[Pipeline]: 115 | """Add a game to the app""" 116 | 117 | # Ignore games from a newer spec version 118 | if game.version > shared.SPEC_VERSION: 119 | return None 120 | 121 | # Scanned game is already removed, just clean it up 122 | if game.removed: 123 | self.cleanup_game(game) 124 | return None 125 | 126 | # Handle game duplicates 127 | stored_game = self.get(game.game_id) 128 | if not stored_game: 129 | # New game, do as normal 130 | logging.debug("New store game %s (%s)", game.name, game.game_id) 131 | self.new_game_ids.add(game.game_id) 132 | elif stored_game.removed: 133 | # Will replace a removed game, cleanup its remains 134 | logging.debug( 135 | "New store game %s (%s) (replacing a removed one)", 136 | game.name, 137 | game.game_id, 138 | ) 139 | self.cleanup_game(stored_game) 140 | self.new_game_ids.add(game.game_id) 141 | else: 142 | # Duplicate game, ignore it 143 | logging.debug("Duplicate store game %s (%s)", game.name, game.game_id) 144 | self.duplicate_game_ids.add(game.game_id) 145 | return None 146 | 147 | # Connect signals 148 | for manager in self.managers.values(): 149 | for signal in manager.signals: 150 | game.connect(signal, manager.run) 151 | 152 | # Add the game to the store 153 | if not game.base_source in self.source_games: 154 | self.source_games[game.base_source] = {} 155 | self.source_games[game.base_source][game.game_id] = game 156 | 157 | # Run the pipeline for the game 158 | if not run_pipeline: 159 | return None 160 | pipeline = Pipeline(game, additional_data, self.pipeline_managers) 161 | self.pipelines[game.game_id] = pipeline 162 | pipeline.advance() 163 | return pipeline 164 | -------------------------------------------------------------------------------- /cartridges/utils/create_dialog.py: -------------------------------------------------------------------------------- 1 | # create_dialog.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from typing import Optional 21 | 22 | from gi.repository import Adw, Gtk 23 | 24 | 25 | def create_dialog( 26 | win: Gtk.Window, 27 | heading: str, 28 | body: str, 29 | extra_option: Optional[str] = None, 30 | extra_label: Optional[str] = None, 31 | ) -> Adw.AlertDialog: 32 | dialog = Adw.AlertDialog.new(heading, body) 33 | dialog.add_response("dismiss", _("Dismiss")) 34 | 35 | if extra_option: 36 | dialog.add_response(extra_option, extra_label or "") 37 | 38 | dialog.choose(win) 39 | return dialog 40 | -------------------------------------------------------------------------------- /cartridges/utils/rate_limiter.py: -------------------------------------------------------------------------------- 1 | # rate_limiter.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from collections import deque 21 | from contextlib import AbstractContextManager 22 | from threading import BoundedSemaphore, Lock, Thread 23 | from time import sleep, time 24 | from typing import Any, Sized 25 | 26 | 27 | class PickHistory(Sized): 28 | """Utility class used for rate limiters, counting how many picks 29 | happened in a given period""" 30 | 31 | period: int 32 | 33 | timestamps: list[float] 34 | timestamps_lock: Lock 35 | 36 | def __init__(self, period: int) -> None: 37 | self.period = period 38 | self.timestamps = [] 39 | self.timestamps_lock = Lock() 40 | 41 | def remove_old_entries(self) -> None: 42 | """Remove history entries older than the period""" 43 | now = time() 44 | cutoff = now - self.period 45 | with self.timestamps_lock: 46 | self.timestamps = [entry for entry in self.timestamps if entry > cutoff] 47 | 48 | def add(self, *new_timestamps: float) -> None: 49 | """Add timestamps to the history. 50 | If none given, will add the current timestamp""" 51 | if len(new_timestamps) == 0: 52 | new_timestamps = (time(),) 53 | with self.timestamps_lock: 54 | self.timestamps.extend(new_timestamps) 55 | 56 | def __len__(self) -> int: 57 | """How many entries were logged in the period""" 58 | self.remove_old_entries() 59 | with self.timestamps_lock: 60 | return len(self.timestamps) 61 | 62 | @property 63 | def start(self) -> float: 64 | """Get the time at which the history started""" 65 | self.remove_old_entries() 66 | with self.timestamps_lock: 67 | try: 68 | entry = self.timestamps[0] 69 | except IndexError: 70 | entry = time() 71 | return entry 72 | 73 | def copy_timestamps(self) -> list[float]: 74 | """Get a copy of the timestamps history""" 75 | self.remove_old_entries() 76 | with self.timestamps_lock: 77 | return self.timestamps.copy() 78 | 79 | 80 | # pylint: disable=too-many-instance-attributes 81 | class RateLimiter(AbstractContextManager): 82 | """ 83 | Base rate limiter implementing the token bucket algorithm. 84 | 85 | Do not use directly, create a child class to tailor the rate limiting to the 86 | underlying service's limits. 87 | 88 | Subclasses must provide values to the following attributes: 89 | * refill_period_seconds - Period in which we have a max amount of tokens 90 | * refill_period_tokens - Number of tokens allowed in this period 91 | * burst_tokens - Max number of tokens that can be consumed instantly 92 | """ 93 | 94 | refill_period_seconds: int 95 | refill_period_tokens: int 96 | burst_tokens: int 97 | 98 | pick_history: PickHistory 99 | bucket: BoundedSemaphore 100 | queue: deque[Lock] 101 | queue_lock: Lock 102 | 103 | # Protect the number of tokens behind a lock 104 | __n_tokens_lock: Lock 105 | __n_tokens = 0 106 | 107 | @property 108 | def n_tokens(self) -> int: 109 | with self.__n_tokens_lock: 110 | return self.__n_tokens 111 | 112 | @n_tokens.setter 113 | def n_tokens(self, value: int) -> None: 114 | with self.__n_tokens_lock: 115 | self.__n_tokens = value 116 | 117 | def _init_pick_history(self) -> None: 118 | """ 119 | Initialize the tocken pick history 120 | (only for use in this class and its children) 121 | 122 | By default, creates an empty pick history. 123 | Should be overriden or extended by subclasses. 124 | """ 125 | self.pick_history = PickHistory(self.refill_period_seconds) 126 | 127 | def __init__(self) -> None: 128 | """Initialize the limiter""" 129 | 130 | self._init_pick_history() 131 | 132 | # Create synchronization data 133 | self.__n_tokens_lock = Lock() 134 | self.queue_lock = Lock() 135 | self.queue = deque() 136 | 137 | # Initialize the token bucket 138 | self.bucket = BoundedSemaphore(self.burst_tokens) 139 | self.n_tokens = self.burst_tokens 140 | 141 | # Spawn daemon thread that refills the bucket 142 | refill_thread = Thread(target=self.refill_thread_func, daemon=True) 143 | refill_thread.start() 144 | 145 | @property 146 | def refill_spacing(self) -> float: 147 | """ 148 | Get the current refill spacing. 149 | 150 | Ensures that even with a burst in the period, the limit will not be exceeded. 151 | """ 152 | 153 | # Compute ideal spacing 154 | tokens_left = self.refill_period_tokens - len(self.pick_history) # type: ignore 155 | seconds_left = self.pick_history.start + self.refill_period_seconds - time() # type: ignore 156 | try: 157 | spacing_seconds = seconds_left / tokens_left 158 | except ZeroDivisionError: 159 | # There were no remaining tokens, gotta wait until end of the period 160 | spacing_seconds = seconds_left 161 | 162 | # Prevent spacing dropping down lower than the natural spacing 163 | natural_spacing = self.refill_period_seconds / self.refill_period_tokens 164 | return max(natural_spacing, spacing_seconds) 165 | 166 | def refill(self) -> None: 167 | """Add a token back in the bucket""" 168 | sleep(self.refill_spacing) 169 | try: 170 | self.bucket.release() 171 | except ValueError: 172 | # Bucket was full 173 | pass 174 | else: 175 | self.n_tokens += 1 176 | 177 | def refill_thread_func(self) -> None: 178 | """Entry point for the daemon thread that is refilling the bucket""" 179 | while True: 180 | self.refill() 181 | 182 | def update_queue(self) -> None: 183 | """Update the queue, moving it forward if possible. Non-blocking.""" 184 | update_thread = Thread(target=self.queue_update_thread_func, daemon=True) 185 | update_thread.start() 186 | 187 | def queue_update_thread_func(self) -> None: 188 | """Queue-updating thread's entry point""" 189 | with self.queue_lock: 190 | if len(self.queue) == 0: 191 | return 192 | # Not using with because we don't want to release to the bucket 193 | self.bucket.acquire() # pylint: disable=consider-using-with 194 | self.n_tokens -= 1 195 | lock = self.queue.pop() 196 | lock.release() 197 | 198 | def add_to_queue(self) -> Lock: 199 | """Create a lock, add it to the queue and return it""" 200 | lock = Lock() 201 | # We want the lock locked until its turn in queue 202 | lock.acquire() # pylint: disable=consider-using-with 203 | with self.queue_lock: 204 | self.queue.appendleft(lock) 205 | return lock 206 | 207 | def acquire(self) -> None: 208 | """Acquires a token from the bucket when it's your turn in queue""" 209 | lock = self.add_to_queue() 210 | self.update_queue() 211 | # Wait until our turn in queue 212 | lock.acquire() # pylint: disable=consider-using-with 213 | self.pick_history.add() # type: ignore 214 | 215 | # --- Support for use in with statements 216 | 217 | def __enter__(self) -> None: 218 | self.acquire() 219 | 220 | def __exit__(self, *_args: Any) -> None: 221 | pass 222 | -------------------------------------------------------------------------------- /cartridges/utils/relative_date.py: -------------------------------------------------------------------------------- 1 | # relative_date.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | from datetime import datetime 21 | from typing import Any 22 | 23 | from gi.repository import GLib 24 | 25 | 26 | def relative_date(timestamp: int) -> Any: # pylint: disable=too-many-return-statements 27 | days_no = ((today := datetime.today()) - datetime.fromtimestamp(timestamp)).days 28 | 29 | if days_no == 0: 30 | return _("Today") 31 | if days_no == 1: 32 | return _("Yesterday") 33 | if days_no <= (day_of_week := today.weekday()): 34 | return GLib.DateTime.new_from_unix_utc(timestamp).format("%A") 35 | if days_no <= day_of_week + 7: 36 | return _("Last Week") 37 | if days_no <= (day_of_month := today.day): 38 | return _("This Month") 39 | if days_no <= day_of_month + 30: 40 | return _("Last Month") 41 | if days_no < (day_of_year := today.timetuple().tm_yday): 42 | return GLib.DateTime.new_from_unix_utc(timestamp).format("%B") 43 | if days_no <= day_of_year + 365: 44 | return _("Last Year") 45 | return GLib.DateTime.new_from_unix_utc(timestamp).format("%Y") 46 | -------------------------------------------------------------------------------- /cartridges/utils/run_executable.py: -------------------------------------------------------------------------------- 1 | # run_executable.py 2 | # 3 | # Copyright 2023 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | import logging 21 | import os 22 | import subprocess 23 | from shlex import quote 24 | 25 | from cartridges import shared 26 | 27 | 28 | def run_executable(executable) -> None: 29 | args = ( 30 | "flatpak-spawn --host /bin/sh -c " + quote(executable) # Flatpak 31 | if os.getenv("FLATPAK_ID") == shared.APP_ID 32 | else executable # Others 33 | ) 34 | 35 | logging.info("Launching `%s`", str(args)) 36 | # pylint: disable=consider-using-with 37 | subprocess.Popen( 38 | args, 39 | cwd=shared.home, 40 | shell=True, 41 | start_new_session=True, 42 | creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, # type: ignore 43 | ) 44 | -------------------------------------------------------------------------------- /cartridges/utils/save_cover.py: -------------------------------------------------------------------------------- 1 | # save_cover.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | 21 | from pathlib import Path 22 | from shutil import copyfile 23 | from typing import Optional 24 | 25 | from gi.repository import Gdk, GdkPixbuf, Gio, GLib 26 | from PIL import Image, ImageSequence, UnidentifiedImageError 27 | 28 | from cartridges import shared 29 | 30 | 31 | def convert_cover( 32 | cover_path: Optional[Path] = None, 33 | pixbuf: Optional[GdkPixbuf.Pixbuf] = None, 34 | resize: bool = True, 35 | ) -> Optional[Path]: 36 | if not cover_path and not pixbuf: 37 | return None 38 | 39 | pixbuf_extensions = set() 40 | for pixbuf_format in GdkPixbuf.Pixbuf.get_formats(): 41 | for pixbuf_extension in pixbuf_format.get_extensions(): 42 | pixbuf_extensions.add(pixbuf_extension) 43 | 44 | if not resize and cover_path and cover_path.suffix.lower()[1:] in pixbuf_extensions: 45 | return cover_path 46 | 47 | if pixbuf: 48 | cover_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path()) 49 | pixbuf.savev(str(cover_path), "tiff", ["compression"], ["1"]) 50 | 51 | try: 52 | with Image.open(cover_path) as image: 53 | if getattr(image, "is_animated", False): 54 | frames = tuple( 55 | frame.resize((200, 300)) if resize else frame 56 | for frame in ImageSequence.Iterator(image) 57 | ) 58 | 59 | tmp_path = Path(Gio.File.new_tmp("XXXXXX.gif")[0].get_path()) 60 | frames[0].save( 61 | tmp_path, 62 | save_all=True, 63 | append_images=frames[1:], 64 | ) 65 | 66 | else: 67 | # This might not be necessary in the future 68 | # https://github.com/python-pillow/Pillow/issues/2663 69 | if image.mode not in ("RGB", "RGBA"): 70 | image = image.convert("RGBA") 71 | 72 | tmp_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path()) 73 | (image.resize(shared.image_size) if resize else image).save( 74 | tmp_path, 75 | compression="tiff_adobe_deflate" 76 | if shared.schema.get_boolean("high-quality-images") 77 | else shared.TIFF_COMPRESSION, 78 | ) 79 | except UnidentifiedImageError: 80 | try: 81 | Gdk.Texture.new_from_filename(str(cover_path)).save_to_tiff( 82 | tmp_path := Gio.File.new_tmp("XXXXXX.tiff")[0].get_path() 83 | ) 84 | return convert_cover(tmp_path) 85 | except GLib.Error: 86 | return None 87 | 88 | return tmp_path 89 | 90 | 91 | def save_cover(game_id: str, cover_path: Path) -> None: 92 | shared.covers_dir.mkdir(parents=True, exist_ok=True) 93 | 94 | animated_path = shared.covers_dir / f"{game_id}.gif" 95 | static_path = shared.covers_dir / f"{game_id}.tiff" 96 | 97 | # Remove previous covers 98 | animated_path.unlink(missing_ok=True) 99 | static_path.unlink(missing_ok=True) 100 | 101 | if not cover_path: 102 | return 103 | 104 | copyfile( 105 | cover_path, 106 | animated_path if cover_path.suffix == ".gif" else static_path, 107 | ) 108 | 109 | if game_id in shared.win.game_covers: 110 | shared.win.game_covers[game_id].new_cover( 111 | animated_path if cover_path.suffix == ".gif" else static_path 112 | ) 113 | -------------------------------------------------------------------------------- /cartridges/utils/sqlite.py: -------------------------------------------------------------------------------- 1 | # sqlite.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # Copyright 2023 Geoffrey Coulaud 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # SPDX-License-Identifier: GPL-3.0-or-later 20 | 21 | from glob import escape 22 | from pathlib import Path 23 | from shutil import copyfile 24 | 25 | from gi.repository import GLib 26 | 27 | 28 | def copy_db(original_path: Path) -> Path: 29 | """ 30 | Copy a sqlite database to a cache dir and return its new path. 31 | The caller in in charge of deleting the returned path's parent dir. 32 | """ 33 | tmp = Path(GLib.Dir.make_tmp()) 34 | for file in original_path.parent.glob(f"{escape(original_path.name)}*"): 35 | copy = tmp / file.name 36 | copyfile(str(file), str(copy)) 37 | return tmp / original_path.name 38 | -------------------------------------------------------------------------------- /cartridges/utils/steam.py: -------------------------------------------------------------------------------- 1 | # steam.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # Copyright 2023 Geoffrey Coulaud 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # SPDX-License-Identifier: GPL-3.0-or-later 20 | 21 | import json 22 | import logging 23 | import re 24 | from pathlib import Path 25 | from typing import TypedDict 26 | 27 | import requests 28 | from requests.exceptions import HTTPError 29 | 30 | from cartridges import shared 31 | from cartridges.utils.rate_limiter import RateLimiter 32 | 33 | 34 | class SteamError(Exception): 35 | pass 36 | 37 | 38 | class SteamGameNotFoundError(SteamError): 39 | pass 40 | 41 | 42 | class SteamNotAGameError(SteamError): 43 | pass 44 | 45 | 46 | class SteamInvalidManifestError(SteamError): 47 | pass 48 | 49 | 50 | class SteamManifestData(TypedDict): 51 | """Dict returned by SteamFileHelper.get_manifest_data""" 52 | 53 | name: str 54 | appid: str 55 | stateflags: str 56 | 57 | 58 | class SteamAPIData(TypedDict): 59 | """Dict returned by SteamAPIHelper.get_api_data""" 60 | 61 | developer: str 62 | 63 | 64 | class SteamRateLimiter(RateLimiter): 65 | """Rate limiter for the Steam web API""" 66 | 67 | # Steam web API limit 68 | # 200 requests per 5 min seems to be the limit 69 | # https://stackoverflow.com/questions/76047820/how-am-i-exceeding-steam-apis-rate-limit 70 | # https://stackoverflow.com/questions/51795457/avoiding-error-429-too-many-requests-steam-web-api 71 | refill_period_seconds = 5 * 60 72 | refill_period_tokens = 200 73 | burst_tokens = 100 74 | 75 | def _init_pick_history(self) -> None: 76 | """ 77 | Load the pick history from schema. 78 | 79 | Allows remembering API limits through restarts of Cartridges. 80 | """ 81 | super()._init_pick_history() 82 | timestamps_str = shared.state_schema.get_string("steam-limiter-tokens-history") 83 | self.pick_history.add(*json.loads(timestamps_str)) 84 | self.pick_history.remove_old_entries() 85 | 86 | def acquire(self) -> None: 87 | """Get a token from the bucket and store the pick history in the schema""" 88 | super().acquire() 89 | timestamps_str = json.dumps(self.pick_history.copy_timestamps()) 90 | shared.state_schema.set_string("steam-limiter-tokens-history", timestamps_str) 91 | 92 | 93 | class SteamFileHelper: 94 | """Helper for Steam file formats""" 95 | 96 | def get_manifest_data(self, manifest_path: Path) -> SteamManifestData: 97 | """Get local data for a game from its manifest""" 98 | 99 | with open(manifest_path, "r", encoding="utf-8") as file: 100 | contents = file.read() 101 | 102 | data = {} 103 | 104 | for key in SteamManifestData.__required_keys__: # pylint: disable=no-member 105 | regex = f'"{key}"\\s+"(.*)"\n' 106 | if (match := re.search(regex, contents, re.IGNORECASE)) is None: 107 | raise SteamInvalidManifestError() 108 | data[key] = match.group(1) 109 | 110 | return SteamManifestData( 111 | name=data["name"], 112 | appid=data["appid"], 113 | stateflags=data["stateflags"], 114 | ) 115 | 116 | 117 | class SteamAPIHelper: 118 | """Helper around the Steam API""" 119 | 120 | base_url = "https://store.steampowered.com/api" 121 | rate_limiter: RateLimiter 122 | 123 | def __init__(self, rate_limiter: RateLimiter) -> None: 124 | self.rate_limiter = rate_limiter 125 | 126 | def get_api_data(self, appid: str) -> SteamAPIData: 127 | """ 128 | Get online data for a game from its appid. 129 | May block to satisfy the Steam web API limitations. 130 | 131 | See https://wiki.teamfortress.com/wiki/User:RJackson/StorefrontAPI#appdetails 132 | """ 133 | 134 | # Get data from the API (way block to satisfy its limits) 135 | with self.rate_limiter: 136 | try: 137 | with requests.get( 138 | f"{self.base_url}/appdetails?appids={appid}", timeout=5 139 | ) as response: 140 | response.raise_for_status() 141 | data = response.json()[appid] 142 | except HTTPError as error: 143 | logging.warning("Steam API HTTP error for %s", appid, exc_info=error) 144 | raise error 145 | 146 | # Handle not found 147 | if not data["success"]: 148 | logging.debug("Appid %s not found", appid) 149 | raise SteamGameNotFoundError() 150 | 151 | # Handle appid is not a game 152 | if data["data"]["type"] not in {"game", "demo", "mod"}: 153 | logging.debug("Appid %s is not a game", appid) 154 | raise SteamNotAGameError() 155 | 156 | # Return API values we're interested in 157 | values = SteamAPIData(developer=", ".join(data["data"]["developers"])) 158 | return values 159 | -------------------------------------------------------------------------------- /cartridges/utils/steamgriddb.py: -------------------------------------------------------------------------------- 1 | # steamgriddb.py 2 | # 3 | # Copyright 2022-2023 kramo 4 | # Copyright 2023 Geoffrey Coulaud 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # SPDX-License-Identifier: GPL-3.0-or-later 20 | 21 | import logging 22 | from pathlib import Path 23 | from typing import Any 24 | 25 | import requests 26 | from gi.repository import Gio 27 | from requests.exceptions import HTTPError 28 | 29 | from cartridges import shared 30 | from cartridges.game import Game 31 | from cartridges.utils.save_cover import convert_cover, save_cover 32 | 33 | 34 | class SgdbError(Exception): 35 | pass 36 | 37 | 38 | class SgdbAuthError(SgdbError): 39 | pass 40 | 41 | 42 | class SgdbGameNotFound(SgdbError): 43 | pass 44 | 45 | 46 | class SgdbBadRequest(SgdbError): 47 | pass 48 | 49 | 50 | class SgdbNoImageFound(SgdbError): 51 | pass 52 | 53 | 54 | class SgdbHelper: 55 | """Helper class to make queries to SteamGridDB""" 56 | 57 | base_url = "https://www.steamgriddb.com/api/v2/" 58 | 59 | @property 60 | def auth_headers(self) -> dict[str, str]: 61 | key = shared.schema.get_string("sgdb-key") 62 | headers = {"Authorization": f"Bearer {key}"} 63 | return headers 64 | 65 | def get_game_id(self, game: Game) -> Any: 66 | """Get grid results for a game. Can raise an exception.""" 67 | uri = f"{self.base_url}search/autocomplete/{game.name}" 68 | res = requests.get(uri, headers=self.auth_headers, timeout=5) 69 | match res.status_code: 70 | case 200: 71 | return res.json()["data"][0]["id"] 72 | case 401: 73 | raise SgdbAuthError(res.json()["errors"][0]) 74 | case 404: 75 | raise SgdbGameNotFound(res.status_code) 76 | case _: 77 | res.raise_for_status() 78 | 79 | def get_image_uri(self, game_id: str, animated: bool = False) -> Any: 80 | """Get the image for a SGDB game id""" 81 | uri = f"{self.base_url}grids/game/{game_id}?dimensions=600x900" 82 | if animated: 83 | uri += "&types=animated" 84 | res = requests.get(uri, headers=self.auth_headers, timeout=5) 85 | match res.status_code: 86 | case 200: 87 | data = res.json()["data"] 88 | if len(data) == 0: 89 | raise SgdbNoImageFound() 90 | return data[0]["url"] 91 | case 401: 92 | raise SgdbAuthError(res.json()["errors"][0]) 93 | case 404: 94 | raise SgdbGameNotFound(res.status_code) 95 | case _: 96 | res.raise_for_status() 97 | 98 | def conditionaly_update_cover(self, game: Game) -> None: 99 | """Update the game's cover if appropriate""" 100 | 101 | # Obvious skips 102 | use_sgdb = shared.schema.get_boolean("sgdb") 103 | if not use_sgdb or game.blacklisted: 104 | return 105 | 106 | image_trunk = shared.covers_dir / game.game_id 107 | still = image_trunk.with_suffix(".tiff") 108 | animated = image_trunk.with_suffix(".gif") 109 | prefer_sgdb = shared.schema.get_boolean("sgdb-prefer") 110 | 111 | # Do nothing if file present and not prefer SGDB 112 | if not prefer_sgdb and (still.is_file() or animated.is_file()): 113 | return 114 | 115 | # Get ID for the game 116 | try: 117 | sgdb_id = self.get_game_id(game) 118 | except (HTTPError, SgdbError) as error: 119 | logging.warning( 120 | "%s while getting SGDB ID for %s", type(error).__name__, game.name 121 | ) 122 | raise error 123 | 124 | # Build different SGDB options to try 125 | image_uri_kwargs_sets = [{"animated": False}] 126 | if shared.schema.get_boolean("sgdb-animated"): 127 | image_uri_kwargs_sets.insert(0, {"animated": True}) 128 | 129 | # Download covers 130 | for uri_kwargs in image_uri_kwargs_sets: 131 | try: 132 | uri = self.get_image_uri(sgdb_id, **uri_kwargs) 133 | response = requests.get(uri, timeout=5) 134 | tmp_file = Gio.File.new_tmp()[0] 135 | tmp_file_path = tmp_file.get_path() 136 | Path(tmp_file_path).write_bytes(response.content) 137 | save_cover(game.game_id, convert_cover(tmp_file_path)) 138 | except SgdbAuthError as error: 139 | # Let caller handle auth errors 140 | raise error 141 | except (HTTPError, SgdbError) as error: 142 | logging.warning( 143 | "%s while getting image for %s kwargs=%s", 144 | type(error).__name__, 145 | game.name, 146 | str(uri_kwargs), 147 | ) 148 | continue 149 | else: 150 | # Stop as soon as one is finished 151 | return 152 | 153 | # No image was added 154 | logging.warning( 155 | 'No matching image found for game "%s" (SGDB ID %d)', 156 | game.name, 157 | sgdb_id, 158 | ) 159 | raise SgdbNoImageFound() 160 | -------------------------------------------------------------------------------- /data/cartridges.gresource.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @APP_ID@.metainfo.xml 5 | gtk/window.ui 6 | gtk/help-overlay.ui 7 | gtk/game.ui 8 | gtk/preferences.ui 9 | gtk/details-dialog.ui 10 | gtk/style.css 11 | gtk/style-dark.css 12 | library_placeholder.svg 13 | library_placeholder_small.svg 14 | 15 | 16 | icons/sources/bottles-source-symbolic.svg 17 | icons/sources/flatpak-source-symbolic.svg 18 | icons/sources/heroic-source-symbolic.svg 19 | icons/sources/itch-source-symbolic.svg 20 | icons/sources/legendary-source-symbolic.svg 21 | icons/sources/lutris-source-symbolic.svg 22 | icons/sources/retroarch-source-symbolic.svg 23 | icons/sources/steam-source-symbolic.svg 24 | 25 | 26 | -------------------------------------------------------------------------------- /data/gtk/details-dialog.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $DetailsDialog: Adw.Dialog { 5 | content-width: 480; 6 | 7 | Adw.ToolbarView { 8 | [top] 9 | Adw.HeaderBar HeaderBar { 10 | show-start-title-buttons: false; 11 | show-end-title-buttons: false; 12 | 13 | [start] 14 | Button cancel_button { 15 | label: _("Cancel"); 16 | action-name: "window.close"; 17 | } 18 | 19 | [end] 20 | Button apply_button { 21 | styles [ 22 | "suggested-action" 23 | ] 24 | } 25 | } 26 | 27 | Adw.PreferencesPage { 28 | Adw.PreferencesGroup cover_group { 29 | Adw.Clamp cover_clamp { 30 | maximum-size: 200; 31 | 32 | Overlay { 33 | [overlay] 34 | Adw.Spinner spinner { 35 | visible: false; 36 | } 37 | 38 | Overlay cover_overlay { 39 | halign: center; 40 | valign: center; 41 | 42 | [overlay] 43 | Button cover_button_edit { 44 | icon-name: "document-edit-symbolic"; 45 | tooltip-text: _("New Cover"); 46 | halign: end; 47 | valign: end; 48 | margin-bottom: 6; 49 | margin-end: 6; 50 | 51 | styles [ 52 | "circular", 53 | "osd" 54 | ] 55 | } 56 | 57 | [overlay] 58 | Revealer cover_button_delete_revealer { 59 | transition-type: crossfade; 60 | margin-end: 40; 61 | 62 | Button cover_button_delete { 63 | icon-name: "user-trash-symbolic"; 64 | tooltip-text: _("Delete Cover"); 65 | halign: end; 66 | valign: end; 67 | margin-bottom: 6; 68 | margin-end: 6; 69 | 70 | styles [ 71 | "circular", 72 | "osd" 73 | ] 74 | } 75 | } 76 | 77 | Picture cover { 78 | width-request: 200; 79 | height-request: 300; 80 | 81 | styles [ 82 | "card" 83 | ] 84 | } 85 | } 86 | } 87 | } 88 | } 89 | 90 | Adw.PreferencesGroup { 91 | Adw.EntryRow name { 92 | title: _("Title"); 93 | } 94 | 95 | Adw.EntryRow developer { 96 | title: _("Developer (optional)"); 97 | } 98 | } 99 | 100 | Adw.PreferencesGroup { 101 | Adw.EntryRow executable { 102 | title: _("Executable"); 103 | 104 | [suffix] 105 | Button file_chooser_button { 106 | valign: center; 107 | icon-name: "document-open-symbolic"; 108 | tooltip-text: _("Select File"); 109 | 110 | styles [ 111 | "flat", 112 | ] 113 | } 114 | 115 | [suffix] 116 | MenuButton exec_info_button { 117 | valign: center; 118 | icon-name: "help-about-symbolic"; 119 | tooltip-text: _("More Info"); 120 | 121 | popover: Popover exec_info_popover { 122 | focusable: true; 123 | 124 | Label exec_info_label { 125 | use-markup: true; 126 | wrap: true; 127 | max-width-chars: 50; 128 | halign: center; 129 | valign: center; 130 | margin-top: 6; 131 | margin-bottom: 6; 132 | margin-start: 6; 133 | margin-end: 6; 134 | } 135 | }; 136 | 137 | styles [ 138 | "flat" 139 | ] 140 | } 141 | } 142 | } 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /data/gtk/game.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $Game: Box { 5 | orientation: vertical; 6 | halign: center; 7 | valign: start; 8 | 9 | Adw.Clamp { 10 | maximum-size: 200; 11 | unit: px; 12 | 13 | Overlay { 14 | [overlay] 15 | Revealer play_revealer { 16 | transition-type: crossfade; 17 | valign: start; 18 | halign: start; 19 | 20 | Button play_button { 21 | icon-name: "media-playback-start-symbolic"; 22 | margin-start: 6; 23 | margin-end: 3; 24 | margin-top: 6; 25 | margin-bottom: 3; 26 | 27 | styles [ 28 | "circular", 29 | "osd", 30 | ] 31 | } 32 | } 33 | 34 | [overlay] 35 | Revealer menu_revealer { 36 | transition-type: crossfade; 37 | valign: start; 38 | halign: end; 39 | 40 | MenuButton menu_button { 41 | icon-name: "view-more-symbolic"; 42 | margin-start: 3; 43 | margin-end: 6; 44 | margin-top: 6; 45 | margin-bottom: 3; 46 | 47 | styles [ 48 | "circular", 49 | "osd", 50 | ] 51 | } 52 | } 53 | 54 | Button cover_button { 55 | name: "cover_button"; 56 | overflow: hidden; 57 | 58 | accessibility { 59 | labelled-by: title; 60 | } 61 | 62 | Box { 63 | orientation: vertical; 64 | 65 | Overlay { 66 | [overlay] 67 | Adw.Spinner spinner { 68 | visible: false; 69 | } 70 | 71 | Picture cover { 72 | width-request: 200; 73 | height-request: 300; 74 | hexpand: true; 75 | vexpand: true; 76 | } 77 | } 78 | 79 | Label title { 80 | label: _("Title"); 81 | ellipsize: end; 82 | hexpand: true; 83 | halign: start; 84 | margin-top: 15; 85 | margin-bottom: 15; 86 | margin-start: 12; 87 | margin-end: 12; 88 | } 89 | } 90 | 91 | styles [ 92 | "card", 93 | ] 94 | } 95 | } 96 | } 97 | } 98 | 99 | menu game_options { 100 | section { 101 | item (_("Edit"), "app.edit_game") 102 | item (_("Hide"), "app.hide_game") 103 | item (_("Remove"), "app.remove_game") 104 | } 105 | } 106 | 107 | menu hidden_game_options { 108 | section { 109 | item (_("Edit"), "app.edit_game") 110 | item (_("Unhide"), "app.hide_game") 111 | item (_("Remove"), "app.remove_game") 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /data/gtk/help-overlay.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | ShortcutsWindow help_overlay { 4 | modal: true; 5 | 6 | ShortcutsSection { 7 | section-name: "shortcuts"; 8 | max-height: 10; 9 | 10 | ShortcutsGroup { 11 | title: _("General"); 12 | 13 | ShortcutsShortcut { 14 | title: _("Search"); 15 | action-name: "win.toggle_search"; 16 | } 17 | 18 | ShortcutsShortcut { 19 | title: _("Preferences"); 20 | action-name: "app.preferences"; 21 | } 22 | 23 | ShortcutsShortcut { 24 | title: _("Keyboard Shortcuts"); 25 | action-name: "win.show-help-overlay"; 26 | } 27 | 28 | ShortcutsShortcut { 29 | title: _("Undo"); 30 | action-name: "win.undo"; 31 | } 32 | 33 | ShortcutsShortcut { 34 | title: _("Quit"); 35 | action-name: "app.quit"; 36 | } 37 | 38 | ShortcutsShortcut { 39 | title: _("Toggle Sidebar"); 40 | action-name: "win.show_sidebar"; 41 | } 42 | 43 | ShortcutsShortcut { 44 | title: _("Main Menu"); 45 | action-name: "win.open_menu"; 46 | } 47 | } 48 | 49 | ShortcutsGroup { 50 | title: _("Games"); 51 | 52 | ShortcutsShortcut { 53 | title: _("Add Game"); 54 | action-name: "app.add_game"; 55 | } 56 | 57 | ShortcutsShortcut { 58 | title: _("Import"); 59 | action-name: "app.import"; 60 | } 61 | 62 | ShortcutsShortcut { 63 | title: _("Show Hidden Games"); 64 | action-name: "win.show_hidden"; 65 | } 66 | 67 | ShortcutsShortcut { 68 | title: _("Remove Game"); 69 | action-name: "app.remove_game_details_view"; 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /data/gtk/style-dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --accent-color: var(--purple-1); 3 | --accent-bg-color: var(--purple-4); 4 | } 5 | 6 | #details_view { 7 | background-color: black; 8 | } 9 | 10 | #details_view_play_button { 11 | color: rgba(0, 0, 0, .8); 12 | background-color: white; 13 | } 14 | -------------------------------------------------------------------------------- /data/gtk/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --accent-color: var(--purple-5); 3 | --accent-bg-color: var(--purple-3); 4 | } 5 | 6 | #details_view { 7 | background-color: white; 8 | } 9 | 10 | #details_view_play_button { 11 | color: white; 12 | background-color: rgba(0, 0, 0, .8); 13 | } 14 | -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/apps/page.kramo.Cartridges.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/page.kramo.Cartridges-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/page.kramo.Cartridges.Devel-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | scalable_dir = join_paths('hicolor', 'scalable', 'apps') 2 | install_data( 3 | join_paths(scalable_dir, ('@0@.svg').format(app_id)), 4 | install_dir: join_paths(get_option('datadir'), 'icons', scalable_dir), 5 | ) 6 | 7 | symbolic_dir = join_paths('hicolor', 'symbolic', 'apps') 8 | install_data( 9 | join_paths(symbolic_dir, ('@0@-symbolic.svg').format(app_id)), 10 | install_dir: join_paths(get_option('datadir'), 'icons', symbolic_dir), 11 | ) 12 | -------------------------------------------------------------------------------- /data/icons/sources/bottles-source-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/sources/flatpak-source-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/sources/heroic-source-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/sources/itch-source-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/sources/legendary-source-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/sources/lutris-source-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/sources/retroarch-source-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/sources/steam-source-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/library_placeholder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/library_placeholder_small.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | blueprints = custom_target( 2 | 'blueprints', 3 | input: files( 4 | 'gtk/details-dialog.blp', 5 | 'gtk/game.blp', 6 | 'gtk/help-overlay.blp', 7 | 'gtk/preferences.blp', 8 | 'gtk/window.blp', 9 | ), 10 | output: '.', 11 | command: [ 12 | find_program('blueprint-compiler'), 13 | 'batch-compile', 14 | '@OUTPUT@', 15 | '@CURRENT_SOURCE_DIR@', 16 | '@INPUT@', 17 | ], 18 | ) 19 | 20 | gnome.compile_resources( 21 | 'cartridges', 22 | configure_file( 23 | input: 'cartridges.gresource.xml.in', 24 | output: 'cartridges.gresource.xml', 25 | configuration: conf, 26 | ), 27 | gresource_bundle: true, 28 | install: true, 29 | install_dir: pkgdatadir, 30 | dependencies: blueprints, 31 | ) 32 | 33 | if host_machine.system() == 'windows' 34 | desktop_file = configure_file( 35 | input: 'page.kramo.Cartridges.desktop.in', 36 | output: app_id + '.desktop.in', 37 | configuration: conf, 38 | install: true, 39 | install_dir: join_paths(get_option('datadir'), 'applications'), 40 | ) 41 | else 42 | desktop_file = i18n.merge_file( 43 | input: configure_file( 44 | input: 'page.kramo.Cartridges.desktop.in', 45 | output: app_id + '.desktop.in', 46 | configuration: conf, 47 | ), 48 | output: app_id + '.desktop', 49 | type: 'desktop', 50 | po_dir: '../po', 51 | install: true, 52 | install_dir: join_paths(get_option('datadir'), 'applications'), 53 | ) 54 | endif 55 | 56 | if host_machine.system() != 'windows' 57 | desktop_utils = find_program('desktop-file-validate', required: false) 58 | if desktop_utils.found() 59 | test('Validate desktop file', desktop_utils, args: [desktop_file]) 60 | endif 61 | endif 62 | 63 | if host_machine.system() == 'windows' 64 | appstream_file = configure_file( 65 | input: 'page.kramo.Cartridges.metainfo.xml.in', 66 | output: app_id + '.metainfo.xml', 67 | configuration: conf, 68 | install: true, 69 | install_dir: join_paths(get_option('datadir'), 'metainfo'), 70 | ) 71 | else 72 | appstream_file = i18n.merge_file( 73 | input: configure_file( 74 | input: 'page.kramo.Cartridges.metainfo.xml.in', 75 | output: app_id + '.metainfo.xml.in', 76 | configuration: conf, 77 | ), 78 | output: app_id + '.metainfo.xml', 79 | po_dir: '../po', 80 | install: true, 81 | install_dir: join_paths(get_option('datadir'), 'metainfo'), 82 | ) 83 | endif 84 | 85 | if host_machine.system() != 'windows' 86 | appstreamcli = find_program('appstreamcli', required: false) 87 | if appstreamcli.found() 88 | test( 89 | 'Validate appstream file', 90 | appstreamcli, 91 | args: ['validate', '--no-net', '--explain', appstream_file], 92 | workdir: meson.current_build_dir(), 93 | ) 94 | endif 95 | endif 96 | 97 | install_data( 98 | configure_file( 99 | input: 'page.kramo.Cartridges.gschema.xml.in', 100 | output: app_id + '.gschema.xml', 101 | configuration: conf, 102 | ), 103 | install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas'), 104 | ) 105 | 106 | compile_schemas = find_program('glib-compile-schemas', required: false) 107 | if compile_schemas.found() 108 | test( 109 | 'Validate schema file', 110 | compile_schemas, 111 | args: ['--strict', '--dry-run', meson.current_source_dir()], 112 | ) 113 | endif 114 | 115 | subdir('icons') 116 | -------------------------------------------------------------------------------- /data/page.kramo.Cartridges.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Cartridges 3 | GenericName=Game Launcher 4 | Comment=Launch all your games 5 | Exec=cartridges 6 | Icon=@APP_ID@ 7 | Terminal=false 8 | Type=Application 9 | Categories=GNOME;GTK;Game;PackageManager; 10 | Keywords=gaming;launcher;steam;lutris;heroic;bottles;itch;flatpak;legendary;retroarch; 11 | StartupNotify=true 12 | -------------------------------------------------------------------------------- /data/page.kramo.Cartridges.gschema.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | 8 | 9 | false 10 | 11 | 12 | false 13 | 14 | 15 | false 16 | 17 | 18 | true 19 | 20 | 21 | true 22 | 23 | 24 | "~/.steam/steam" 25 | 26 | 27 | true 28 | 29 | 30 | "~/.var/app/net.lutris.Lutris/data/lutris/" 31 | 32 | 33 | "~/.var/app/net.lutris.Lutris/cache/lutris" 34 | 35 | 36 | false 37 | 38 | 39 | false 40 | 41 | 42 | true 43 | 44 | 45 | "~/.config/heroic/" 46 | 47 | 48 | true 49 | 50 | 51 | true 52 | 53 | 54 | true 55 | 56 | 57 | true 58 | 59 | 60 | true 61 | 62 | 63 | "~/.var/app/com.usebottles.bottles/data/bottles/" 64 | 65 | 66 | true 67 | 68 | 69 | "~/.var/app/io.itch.itch/config/itch/" 70 | 71 | 72 | true 73 | 74 | 75 | "~/.config/legendary/" 76 | 77 | 78 | true 79 | 80 | 81 | "~/.var/app/org.libretro.RetroArch/config/retroarch/" 82 | 83 | 84 | true 85 | 86 | 87 | true 88 | 89 | 90 | "/var/lib/flatpak/" 91 | 92 | 93 | "~/.local/share/flatpak/" 94 | 95 | 96 | false 97 | 98 | 99 | "" 100 | 101 | 102 | false 103 | 104 | 105 | false 106 | 107 | 108 | false 109 | 110 | 111 | 0 112 | 113 | 114 | 115 | 116 | 117 | 1170 118 | 119 | 120 | 795 121 | 122 | 123 | false 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | "last_played" 134 | 135 | 136 | false 137 | 138 | 139 | "[]" 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /data/screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kra-mo/cartridges/22b16f7d38db45d64e49e7512e33a034b998205a/data/screenshots/1.png -------------------------------------------------------------------------------- /data/screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kra-mo/cartridges/22b16f7d38db45d64e49e7512e33a034b998205a/data/screenshots/2.png -------------------------------------------------------------------------------- /data/screenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kra-mo/cartridges/22b16f7d38db45d64e49e7512e33a034b998205a/data/screenshots/3.png -------------------------------------------------------------------------------- /data/screenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kra-mo/cartridges/22b16f7d38db45d64e49e7512e33a034b998205a/data/screenshots/4.png -------------------------------------------------------------------------------- /docs/game_id.json.md: -------------------------------------------------------------------------------- 1 | # [game_id].json specification 2 | #### Version 2.0 3 | 4 | Games are saved to disk in the form of [game_id].json files. These files contain all information about a game excluding its cover, which is handled separately. 5 | 6 | ## Location 7 | 8 | The standard location for these files is `/cartridges/games/` under the data directory of the user (`XDG_DATA_HOME` on Linux). 9 | 10 | ## Contents 11 | 12 | The following attributes are saved: 13 | 14 | - [added](#added) 15 | - [executable](#executable) 16 | - [game_id](#game_id) 17 | - [source](#source) 18 | - [hidden](#hidden) 19 | - [last_played](#last_played) 20 | - [name](#name) 21 | - [developer](#developer) 22 | - [removed](#removed) 23 | - [blacklisted](#blacklisted) 24 | - [version](#version) 25 | 26 | ### added 27 | 28 | The date at which the game was added. 29 | 30 | Cartridges will set the value for itself. Don't touch it. 31 | 32 | Stored as a Unix time stamp. 33 | 34 | ### executable 35 | 36 | The executable to run when launching a game. 37 | 38 | If the source has a URL handler, using that is preferred. In that case, the value should be `"xdg-open url://example/url"` for Linux, `"start url://example/url"` for Windows and `"open url://example/url"` for macOS. 39 | 40 | Stored as either a string (preferred) or an argument vector to be passed to the shell through [subprocess.Popen](https://docs.python.org/3/library/subprocess.html#popen-constructor). 41 | 42 | ### game_id 43 | 44 | The unique ID of the game, prefixed with [`[source]_`](#source) to avoid clashes. 45 | 46 | If the game's source uses a consistent internal ID system, use the ID from there. If not, use a hash function that always returns the same hash for the same game, even if some of its attributes change inside of the source. 47 | 48 | Stored as a string. 49 | 50 | ### source 51 | 52 | A unique ID for the source of the game in lowercase, without spaces or underscores. 53 | 54 | If a source provides multiple internal sources, these should be separately labeled, but share a common prefix. eg. `heoic_gog`, `heroic_epic`. This is the only place you should use an underscore. 55 | 56 | Stored as a string. 57 | 58 | ### hidden 59 | 60 | Whether or not a game is hidden. 61 | 62 | If the source provides a way of hiding games, take the value from there. Otherwise it should be set to false by default. 63 | 64 | Stored as a boolean. 65 | 66 | ### last_played 67 | 68 | The date at which the game was last launched from Cartridges. 69 | 70 | Cartridges will set the value for itself. Don't touch it. 71 | 72 | Stored as a Unix time stamp. 0 if the game hasn't been played yet. 73 | 74 | ### name 75 | 76 | The title of the game. 77 | 78 | Stored as a string. 79 | 80 | ### developer 81 | 82 | The developer or publisher of the game. 83 | 84 | If there are multiple developers or publishers, they should be joined with a comma and a space (`, `) into one string. 85 | 86 | This is an optional attribute. If it can't be retrieved from the source, don't touch it. 87 | 88 | Stored as a string. 89 | 90 | ### removed 91 | 92 | Whether or not a game has been removed. 93 | 94 | Cartridges will set the value for itself. Don't touch it. 95 | 96 | Stored as a boolean. 97 | 98 | ### blacklisted 99 | 100 | Whether or not a game is blacklisted. Blacklisting a game means it is going to still be imported, but not displayed to the user. 101 | 102 | You should only blacklist a game based on information you pull from the web. This is to ensure that games which you would skip based on information online are still skipped even if the user loses their internet connection. If an entry is broken locally, just skip it. 103 | 104 | The only reason to blacklist a game is if you find out that the locally cached entry is not actually a game (eg. Proton) or is otherwise invalid. 105 | 106 | Unless the above criteria is met, don't touch the attribute. 107 | 108 | Stored as a boolean. 109 | 110 | ### version 111 | 112 | The version number of the [game_id].json specification. 113 | 114 | Cartridges will set the value for itself. Don't touch it. 115 | 116 | Stored as a number. 117 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project( 2 | 'cartridges', 3 | version: '2.12.1', 4 | meson_version: '>= 0.59.0', 5 | default_options: [ 6 | 'warning_level=2', 7 | 'werror=false', 8 | ], 9 | ) 10 | 11 | dependency('gtk4', version: '>= 4.15.0') 12 | dependency('libadwaita-1', version: '>= 1.6.beta') 13 | 14 | # Translations are broken on Windows for multiple reasons 15 | # gresources don't work and MSYS2 seems to have also broken the gettext package 16 | if host_machine.system() != 'windows' 17 | i18n = import('i18n') 18 | endif 19 | 20 | gnome = import('gnome') 21 | python = import('python') 22 | 23 | py_installation = python.find_installation('python3') 24 | 25 | python_dir = join_paths(get_option('prefix'), py_installation.get_install_dir()) 26 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 27 | libexecdir = join_paths(get_option('prefix'), get_option('libexecdir')) 28 | 29 | profile = get_option('profile') 30 | if profile == 'development' 31 | app_id = 'page.kramo.Cartridges.Devel' 32 | prefix = '/page/kramo/Cartridges/Devel' 33 | elif profile == 'release' 34 | app_id = 'page.kramo.Cartridges' 35 | prefix = '/page/kramo/Cartridges' 36 | endif 37 | 38 | conf = configuration_data() 39 | conf.set('PYTHON', py_installation.full_path()) 40 | conf.set('PYTHON_VERSION', py_installation.language_version()) 41 | conf.set('APP_ID', app_id) 42 | conf.set('PREFIX', prefix) 43 | conf.set('VERSION', meson.project_version()) 44 | conf.set('PROFILE', profile) 45 | conf.set('TIFF_COMPRESSION', get_option('tiff_compression')) 46 | conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir'))) 47 | conf.set('pkgdatadir', pkgdatadir) 48 | conf.set('libexecdir', libexecdir) 49 | 50 | subdir('data') 51 | subdir('cartridges') 52 | 53 | if host_machine.system() == 'windows' 54 | subdir('build-aux/windows') 55 | else 56 | subdir('search-provider') 57 | subdir('po') 58 | endif 59 | 60 | gnome.post_install( 61 | glib_compile_schemas: true, 62 | gtk_update_icon_cache: true, 63 | update_desktop_database: true, 64 | ) 65 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option( 2 | 'profile', 3 | type: 'combo', 4 | choices: [ 5 | 'release', 6 | 'development', 7 | ], 8 | value: 'release' 9 | ) 10 | option( 11 | 'tiff_compression', 12 | type: 'combo', 13 | choices: [ 14 | 'webp', 15 | 'jpeg', 16 | ], 17 | value: 'webp' 18 | ) -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | hu 2 | ta 3 | uk 4 | nb_NO 5 | fr 6 | nl 7 | it 8 | ar 9 | es 10 | fi 11 | pt 12 | ru 13 | ko 14 | de 15 | ro 16 | pt_BR 17 | fa 18 | pl 19 | sv 20 | tr 21 | el 22 | cs 23 | zh_Hans 24 | be 25 | hr 26 | ca 27 | ja 28 | hi 29 | en_GB 30 | ie 31 | te 32 | ia 33 | -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | data/page.kramo.Cartridges.desktop.in 2 | data/page.kramo.Cartridges.gschema.xml.in 3 | data/page.kramo.Cartridges.metainfo.xml.in 4 | 5 | data/gtk/details-dialog.blp 6 | data/gtk/game.blp 7 | data/gtk/help-overlay.blp 8 | data/gtk/preferences.blp 9 | data/gtk/window.blp 10 | 11 | cartridges/main.py 12 | cartridges/window.py 13 | cartridges/details_dialog.py 14 | cartridges/game.py 15 | cartridges/preferences.py 16 | 17 | cartridges/utils/create_dialog.py 18 | cartridges/utils/relative_date.py 19 | 20 | cartridges/importer/importer.py 21 | cartridges/importer/source.py 22 | cartridges/importer/location.py 23 | cartridges/importer/location.py 24 | cartridges/importer/bottles_source.py 25 | cartridges/importer/desktop_source.py 26 | cartridges/importer/flatpak_source.py 27 | cartridges/importer/heroic_source.py 28 | cartridges/importer/itch_source.py 29 | cartridges/importer/legendary_source.py 30 | cartridges/importer/lutris_source.py 31 | cartridges/importer/retroarch_source.py 32 | cartridges/importer/steam_source.py 33 | 34 | cartridges/store/managers/sgdb_manager.py 35 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext('cartridges', preset: 'glib', args: ['--copyright-holder=kramo', '--package-name=Cartridges']) 2 | -------------------------------------------------------------------------------- /pyrightconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "reportRedeclaration": "none", 3 | "reportMissingModuleSource": "none" 4 | } 5 | -------------------------------------------------------------------------------- /search-provider/meson.build: -------------------------------------------------------------------------------- 1 | # Heavily inspired by https://gitlab.gnome.org/World/lollypop/-/blob/master/search-provider/meson.build 2 | 3 | service_dir = join_paths(get_option('datadir'), 'dbus-1', 'services') 4 | serarch_provider_dir = join_paths(get_option('datadir'), 'gnome-shell', 'search-providers') 5 | 6 | configure_file( 7 | input: 'cartridges-search-provider.in', 8 | output: 'cartridges-search-provider', 9 | configuration: conf, 10 | install_dir: libexecdir, 11 | ) 12 | 13 | configure_file( 14 | input: 'page.kramo.Cartridges.SearchProvider.service.in', 15 | output: app_id + '.SearchProvider.service', 16 | configuration: conf, 17 | install_dir: service_dir, 18 | ) 19 | 20 | configure_file( 21 | input: 'page.kramo.Cartridges.SearchProvider.ini', 22 | output: app_id + '.SearchProvider.ini', 23 | configuration: conf, 24 | install_dir: serarch_provider_dir, 25 | ) 26 | -------------------------------------------------------------------------------- /search-provider/page.kramo.Cartridges.SearchProvider.ini: -------------------------------------------------------------------------------- 1 | [Shell Search Provider] 2 | DesktopId=@APP_ID@.desktop 3 | BusName=@APP_ID@.SearchProvider 4 | ObjectPath=@PREFIX@/SearchProvider 5 | Version=2 -------------------------------------------------------------------------------- /search-provider/page.kramo.Cartridges.SearchProvider.service.in: -------------------------------------------------------------------------------- 1 | [D-BUS Service] 2 | Name=@APP_ID@.SearchProvider 3 | Exec=@libexecdir@/cartridges-search-provider -------------------------------------------------------------------------------- /subprojects/blueprint-compiler.wrap: -------------------------------------------------------------------------------- 1 | [wrap-git] 2 | directory = blueprint-compiler 3 | url = https://gitlab.gnome.org/jwestman/blueprint-compiler.git 4 | revision = v0.16.0 5 | depth = 1 6 | 7 | [provide] 8 | program_names = blueprint-compiler 9 | --------------------------------------------------------------------------------