Compare commits

...

66 Commits

Author SHA1 Message Date
GeoffreyCoulaud
927275cda9 🚧 Prototype import undo v2
bad:
- Not optimised at all
- copies a lot of unnecessary things
- not reliable

good:
- works, a little

---

For it to work more than "a little", we need to fix that bug:
- Import
- Remove a game
- Import (game comes back)
- Undo Import (game gone)
- Import
- Undo (everything gone)
2023-07-03 17:48:32 +02:00
GeoffreyCoulaud
df9f7dbca8 Revert "🚧 Very unfinished initial work on import undo"
This reverts commit d252b6b97d.
2023-07-03 14:00:23 +02:00
GeoffreyCoulaud
6c6fd29faa Merge branch 'main' into add-import-undo 2023-07-03 13:58:22 +02:00
kramo
e320e58ffc Fix Appstream ID 2023-07-03 12:43:33 +02:00
kramo
c9a1104b44 Revert test 2023-07-02 14:46:54 +02:00
kramo
ee5740c21c Test Actions 2023-07-02 14:46:37 +02:00
kramo
1498326f45 Exit differently 2023-07-02 13:03:34 +02:00
kramo
69fc7e1b03 Remove test module 2023-07-02 12:55:19 +02:00
kramo
8a3397bef5 This should work 2023-07-02 12:54:49 +02:00
kramo
d87048ee64 Test worked 2023-07-02 12:26:56 +02:00
kramo
3d6601238d Actions Test 2023-07-02 12:21:55 +02:00
kramo
a7393ba9b9 Rename Windows Action 2023-07-02 01:25:08 +02:00
kramo
c2f429a29c Fix Flatpak action caching 2023-07-02 00:57:54 +02:00
kramo
8fadcc8524 oops 2023-07-02 00:53:20 +02:00
kramo
c544901519 I'm one with the cloud ☁️ (#130) 2023-07-02 00:51:28 +02:00
GeoffreyCoulaud
d252b6b97d 🚧 Very unfinished initial work on import undo 2023-07-02 00:08:00 +02:00
kramo
7756f75bb9 Test Release Action 2023-07-01 20:18:26 +02:00
Geoffrey Coulaud
fabd9828f6 Merge pull request #129 from kra-mo/online-managers-improvements
Retry all online managers on ConnectionError
2023-07-01 18:52:26 +02:00
GeoffreyCoulaud
29e022327b Retry all online managers on ConnectionError 2023-07-01 18:51:50 +02:00
Geoffrey Coulaud
49fb3705e4 Merge pull request #128 from kra-mo/windows-logging-fix
Windows fixes
2023-07-01 18:29:47 +02:00
GeoffreyCoulaud
0c6cfc14f0 Fix hiding unavailable source rows 2023-07-01 18:27:11 +02:00
GeoffreyCoulaud
75a5255806 Sepcified utf-8 for lzma log backups 2023-07-01 18:14:10 +02:00
Geoffrey Coulaud
dd1dd2b7e5 Merge pull request #127 from kra-mo/sources-paths-fix
Sources paths fix
2023-07-01 17:10:47 +02:00
GeoffreyCoulaud
4a204442b5 Using new shared paths in sources 2023-07-01 15:53:52 +02:00
GeoffreyCoulaud
fb0c47c1f1 Re added hardcoded candidates for linux
- Real fix would be to get the XDG_*_DIR
2023-07-01 15:17:12 +02:00
GeoffreyCoulaud
3af968fee7 Simplified source available on 2023-07-01 15:10:40 +02:00
kramo
9fbb45cfa5 Fix Steam API developers 2023-07-01 14:30:26 +02:00
kramo
bbfe478ac3 Fix Legendary location labels 2023-07-01 13:58:47 +02:00
kramo
339ec1c20a Update translations 2023-07-01 13:48:30 +02:00
kramo
97e40e0a80 Fix Legendary, warning for missing locations 2023-07-01 13:44:14 +02:00
kramo
95d47815ab Add translation comments 2023-07-01 11:36:45 +02:00
kramo
772bf30468 Fix translations 2023-07-01 11:35:17 +02:00
kramo
7311015549 Fix translations 2023-07-01 11:30:44 +02:00
kramo
e7d2f58416 Merge pull request #126 from kra-mo/kra-mo-patch-1
Update README.md
2023-07-01 11:23:46 +02:00
kramo
a040b058d2 Update README.md 2023-07-01 11:23:38 +02:00
kramo
7be20c64bd Update translations 2023-07-01 11:15:55 +02:00
kramo
036e5814f3 Fix Flatpak custom paths 2023-07-01 11:11:16 +02:00
kramo
e46c9b6a30 Fix Flatpak desktop entry search 2023-07-01 10:54:48 +02:00
kramo
495755f278 More reliance on GLib 2023-07-01 10:44:15 +02:00
kramo
1f25bed842 Move away from PyXDG 2023-07-01 10:05:16 +02:00
Geoffrey Coulaud
ad38dc6d49 Merge pull request #125 from kra-mo/remove-allow-side-effects
Various cleanups
2023-07-01 03:12:08 +02:00
GeoffreyCoulaud
a7efe0a920 Removed allow_side_effects 2023-07-01 03:01:15 +02:00
GeoffreyCoulaud
8d082ab158 Removed win.games 2023-07-01 02:56:40 +02:00
GeoffreyCoulaud
b3c2437618 fixed flatpak location preferences 2023-07-01 02:55:42 +02:00
kramo
8e7d08b3b2 Fix indentation and trailing / for Flatpak 2023-07-01 00:10:36 +02:00
kramo
d22d3820b9 Merge pull request #124 from kra-mo/flatpak-source
Flatpak source
2023-06-30 23:50:58 +02:00
kramo
721a46c5b8 Finalize Flatpak source 2023-06-30 23:49:48 +02:00
kramo
8efb1c6c5e Fix Flatpak source for .svgs 2023-06-30 22:33:21 +02:00
kramo
07960182c7 Flatpak source cleanups 2023-06-30 21:13:06 +02:00
kramo
fccf302c4b Flatpak source initial work 2023-06-30 19:51:44 +02:00
kramo
7a1e5e0968 Show details of first search entry on eneter 2023-06-30 14:34:56 +02:00
kramo
19a1c856ac Merge pull request #123 from kra-mo/fix-importer-progress
Fixed importer progressbar stuck at 100%
2023-06-30 14:01:28 +02:00
GeoffreyCoulaud
e9b8c0d01e Fixed importer progressbar stuck at 100%
- Progress is 0 by default
- 10% for the sources progress
- 90% for the pipelines progress
2023-06-30 13:57:22 +02:00
kramo
95cd65bc7a Add min and max for luminance 2023-06-30 13:56:08 +02:00
kramo
073dbdb97b Merge pull request #122 from kra-mo/fix-bad-source-location-error
Silently skip sources with bad locations
2023-06-30 13:37:01 +02:00
kramo
ad0e5b2abf Allow escape to leave the hidden library 2023-06-30 13:36:27 +02:00
GeoffreyCoulaud
9ed085e1a0 Silently skip sources with bad locations 2023-06-30 13:25:01 +02:00
kramo
f4f6d73d4a Display logs from current session in about window 2023-06-30 10:58:47 +02:00
kramo
107c1c96b5 Merge pull request #121 from kra-mo/v2-logging 2023-06-29 17:49:02 +02:00
GeoffreyCoulaud
299333c959 Added horizontal line between runs 2023-06-29 17:39:52 +02:00
GeoffreyCoulaud
e4b315f252 Added log files contents to aboutwindow debug info 2023-06-29 17:29:16 +02:00
kramo
455c6891d8 Make logs prettier 2023-06-29 15:39:29 +02:00
GeoffreyCoulaud
2311f3dff7 better compression
Compress old logs at startup
Added back the .log suffix
2023-06-29 15:26:21 +02:00
kramo
f5b527cc51 Make logs pretty 2023-06-29 15:19:57 +02:00
GeoffreyCoulaud
0a89061218 Moved log number to first suffix 2023-06-29 14:41:28 +02:00
kramo
20bfe9309c OCD 2023-06-29 13:24:00 +02:00
36 changed files with 796 additions and 330 deletions

View File

@@ -2,20 +2,40 @@ on:
push:
branches: [main]
pull_request:
name: "Build for Windows"
name: CI
concurrency:
group: release-${{ github.sha }}
jobs:
flatpak:
name: Flatpak
runs-on: ubuntu-latest
container:
image: bilelmoussaoui/flatpak-github-actions:gnome-44
options: --privileged
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Flatpak Builder
uses: flatpak/flatpak-github-actions/flatpak-builder@v6.1
with:
bundle: hu.kramo.Cartridges.Devel.flatpak
manifest-path: flatpak/hu.kramo.Cartridges.Devel.json
windows:
name: "Build"
name: Windows
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: MSYS2
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
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
- name: Compile
shell: msys2 {0}
run: |
@@ -23,10 +43,18 @@ jobs:
ninja -C _build install
pacman --noconfirm -Rs mingw-w64-ucrt-x86_64-desktop-file-utils mingw-w64-ucrt-x86_64-meson git
find /ucrt64/share/locale/ -type f ! -name "*cartridges.mo" -delete
- name: "Inno Setup"
- name: Test
shell: msys2 {0}
run: |
set +e
timeout 2 cartridges; [ "$?" -eq "124" ]
- name: Inno Setup
run: iscc ".\_build\Cartridges.iss"
- name: "Upload Artifact"
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: "Installer"
path: "_build/Output/Cartridges Setup.exe"
name: Windows Installer
path: _build/Output/Cartridges Setup.exe

View File

@@ -1,19 +0,0 @@
on:
push:
branches: [main]
pull_request:
name: CI
jobs:
flatpak:
name: "Flatpak"
runs-on: ubuntu-latest
container:
image: bilelmoussaoui/flatpak-github-actions:gnome-44
options: --privileged
steps:
- uses: actions/checkout@v3
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: hu.kramo.Cartridges.Devel.flatpak
manifest-path: flatpak/hu.kramo.Cartridges.Devel.json
cache-key: flatpak-builder-${{ github.sha }}

45
.github/workflows/publish-release.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
on:
push:
tags:
"*"
name: Publish Release
concurrency:
group: release-${{ github.sha }}
jobs:
publish-release:
name: Publish Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Download workflow artifact
uses: dawidd6/action-download-artifact@v2.27.0
with:
workflow: ci.yml
commit: ${{ github.sha }}
- name: Get release notes
shell: python
run: |
import re, textwrap
open_file = open("./data/hu.kramo.Cartridges.metainfo.xml.in", "r", encoding="utf-8")
string = open_file.read()
open_file.close()
string = re.findall("<release.*>\s*<description.*>\n([\s\S]*?)\s*</description>\s*<\/release>", string)[0]
string = textwrap.dedent(string)
open_file = open("release_notes", "w", encoding="utf-8")
open_file.write(string)
open_file.close()
- name: Get tag name
id: get_tag_name
run: echo tag_name=${GITHUB_REF#refs/tags/} >> $GITHUB_OUTPUT
- name: Publish release
uses: softprops/action-gh-release@v0.1.15
with:
files: Windows Installer/Cartridges Setup.exe
fail_on_unmatched_files: true
tag_name: ${{ steps.get_tag_name.outputs.tag_name }}
body_path: release_notes

View File

@@ -2,9 +2,9 @@
ignore=importers
[MESSAGES CONTROL]
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,

View File

@@ -23,7 +23,7 @@ The project can be translated on [Weblate](https://hosted.weblate.org/engage/car
## For Windows
1. Install [MSYS2](https://www.msys2.org/).
2. From the MSYS2 shell, install the required dependencies listed [here](https://github.com/kra-mo/cartridges/blob/main/.github/workflows/windows.yml).
2. From the MSYS2 shell, install the required dependencies listed [here](https://github.com/kra-mo/cartridges/blob/main/.github/workflows/ci.yml).
3. Build it via Meson.
## Meson

View File

@@ -10,16 +10,13 @@
[![Flathub][flathub-image]][flathub-url]
[![Build status][github-actions-image]][github-actions-url]
[![Translation Status][weblate-image]][weblate-url]
[![License][license-image]][license-url]
[![Code style][code-style-image]][code-style-url]
[![Discord][discord-image]][discord-url]
[circle-url]: https://circle.gnome.org
[circle-image]: https://circle.gnome.org/assets/button/badge.svg
[github-actions-url]: https://github.com/kra-mo/cartridges
[github-actions-image]: https://github.com/kra-mo/cartridges/actions/workflows/flatpak-builder.yml/badge.svg
[license-url]: https://github.com/kra-mo/cartridges/blob/main/LICENSE
[license-image]: https://img.shields.io/github/license/kra-mo/cartridges
[github-actions-image]: https://github.com/kra-mo/cartridges/actions/workflows/ci.yml/badge.svg
[code-style-url]: https://github.com/psf/black
[code-style-image]: https://img.shields.io/badge/code%20style-black-000000?style=flat
[weblate-url]: https://hosted.weblate.org/engage/cartridges/
@@ -39,8 +36,14 @@ Cartridges is a simple game launcher written in Python using GTK4 and Libadwaita
## Features
- Manually adding and editing games
- Importing games from Steam, Lutris, Heroic, Bottles and itch
- Support for multiple Steam install locations
- Importing games from various sources:
- Steam
- Lutris
- Heroic
- Bottles
- itch
- Legendary
- Flatpak
- Hiding games
- Searching and sorting by title, date added and last played
- Automatically downloading cover art from [SteamGridDB](https://www.steamgriddb.com/)

View File

@@ -132,6 +132,15 @@ template $PreferencesWindow : Adw.PreferencesWindow {
valign: center;
}
}
Adw.ActionRow {
title: _("Import Flatpak Games");
activatable-widget: lutris_import_flatpak_switch;
Switch lutris_import_flatpak_switch {
valign: center;
}
}
}
Adw.ExpanderRow heroic_expander_row {
@@ -216,6 +225,29 @@ template $PreferencesWindow : Adw.PreferencesWindow {
}
}
}
Adw.ExpanderRow flatpak_expander_row {
title: _("Flatpak");
show-enable-switch: true;
Adw.ActionRow flatpak_data_action_row {
title: _("Install Location");
Button flatpak_data_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
}
Adw.ActionRow flatpak_import_launchers_row {
title: _("Import Game Launchers");
activatable-widget: flatpak_import_launchers_switch;
Switch flatpak_import_launchers_switch {
valign: center;
}
}
}
}
}

View File

@@ -28,6 +28,9 @@
<key name="lutris-import-steam" type="b">
<default>false</default>
</key>
<key name="lutris-import-flatpak" type="b">
<default>false</default>
</key>
<key name="heroic" type="b">
<default>true</default>
</key>
@@ -61,6 +64,15 @@
<key name="legendary-location" type="s">
<default>"~/.config/legendary/"</default>
</key>
<key name="flatpak" type="b">
<default>true</default>
</key>
<key name="flatpak-location" type="s">
<default>"/var/lib/flatpak/"</default>
</key>
<key name="flatpak-import-launchers" type="b">
<default>false</default>
</key>
<key name="sgdb-key" type="s">
<default>""</default>
</key>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>@APP_ID@.desktop</id>
<id>@APP_ID@</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<name>Cartridges</name>

View File

@@ -16,7 +16,8 @@
"--filesystem=~/.var/app/net.lutris.Lutris/:ro",
"--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro",
"--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/:ro",
"--filesystem=~/.var/app/io.itch.itch/config/itch/:ro"
"--filesystem=~/.var/app/io.itch.itch/config/itch/:ro",
"--filesystem=/var/lib/flatpak:ro"
],
"cleanup" : [
"/include",

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Cartridges\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-06-26 22:22+0200\n"
"POT-Creation-Date: 2023-07-01 13:45+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -19,7 +19,7 @@ msgstr ""
#: data/hu.kramo.Cartridges.desktop.in:3
#: data/hu.kramo.Cartridges.metainfo.xml.in:6 data/gtk/window.blp:47
#: src/main.py:153
#: src/main.py:162
msgid "Cartridges"
msgstr ""
@@ -57,7 +57,7 @@ msgid "Game Details"
msgstr ""
#: data/hu.kramo.Cartridges.metainfo.xml.in:42 data/gtk/window.blp:416
#: src/details_window.py:239
#: src/details_window.py:238
msgid "Preferences"
msgstr ""
@@ -106,7 +106,7 @@ msgstr ""
msgid "Edit"
msgstr ""
#: data/gtk/game.blp:107 src/window.py:169
#: data/gtk/game.blp:107 src/window.py:171
msgid "Hide"
msgstr ""
@@ -115,7 +115,7 @@ msgstr ""
msgid "Remove"
msgstr ""
#: data/gtk/game.blp:126 src/window.py:171
#: data/gtk/game.blp:126 src/window.py:173
msgid "Unhide"
msgstr ""
@@ -140,7 +140,7 @@ msgstr ""
msgid "Shortcuts"
msgstr ""
#: data/gtk/help-overlay.blp:34 src/game.py:105 src/preferences.py:103
#: data/gtk/help-overlay.blp:34 src/game.py:102 src/preferences.py:112
msgid "Undo"
msgstr ""
@@ -168,7 +168,7 @@ msgstr ""
msgid "Remove game"
msgstr ""
#: data/gtk/preferences.blp:13 data/gtk/preferences.blp:236
#: data/gtk/preferences.blp:13 data/gtk/preferences.blp:268
msgid "Behavior"
msgstr ""
@@ -217,8 +217,9 @@ msgid "Steam"
msgstr ""
#: data/gtk/preferences.blp:96 data/gtk/preferences.blp:110
#: data/gtk/preferences.blp:142 data/gtk/preferences.blp:183
#: data/gtk/preferences.blp:197 data/gtk/preferences.blp:211
#: data/gtk/preferences.blp:151 data/gtk/preferences.blp:192
#: data/gtk/preferences.blp:206 data/gtk/preferences.blp:220
#: data/gtk/preferences.blp:234
msgid "Install Location"
msgstr ""
@@ -234,59 +235,71 @@ msgstr ""
msgid "Import Steam Games"
msgstr ""
#: data/gtk/preferences.blp:138
#: data/gtk/preferences.blp:137
msgid "Import Flatpak Games"
msgstr ""
#: data/gtk/preferences.blp:147
msgid "Heroic"
msgstr ""
#: data/gtk/preferences.blp:151
#: data/gtk/preferences.blp:160
msgid "Import Epic Games"
msgstr ""
#: data/gtk/preferences.blp:160
#: data/gtk/preferences.blp:169
msgid "Import GOG Games"
msgstr ""
#: data/gtk/preferences.blp:169
#: data/gtk/preferences.blp:178
msgid "Import Sideloaded Games"
msgstr ""
#: data/gtk/preferences.blp:179
#: data/gtk/preferences.blp:188
msgid "Bottles"
msgstr ""
#: data/gtk/preferences.blp:193
#: data/gtk/preferences.blp:202
msgid "itch"
msgstr ""
#: data/gtk/preferences.blp:207
#: data/gtk/preferences.blp:216
msgid "Legendary"
msgstr ""
#: data/gtk/preferences.blp:224
#: data/gtk/preferences.blp:230
msgid "Flatpak"
msgstr ""
#: data/gtk/preferences.blp:243
msgid "Import Game Launchers"
msgstr ""
#: data/gtk/preferences.blp:256
msgid "SteamGridDB"
msgstr ""
#: data/gtk/preferences.blp:228
#: data/gtk/preferences.blp:260
msgid "Authentication"
msgstr ""
#: data/gtk/preferences.blp:231
#: data/gtk/preferences.blp:263
msgid "API Key"
msgstr ""
#: data/gtk/preferences.blp:239
#: data/gtk/preferences.blp:271
msgid "Use SteamGridDB"
msgstr ""
#: data/gtk/preferences.blp:240
#: data/gtk/preferences.blp:272
msgid "Download images when adding or importing games"
msgstr ""
#: data/gtk/preferences.blp:249
#: data/gtk/preferences.blp:281
msgid "Prefer Over Official Images"
msgstr ""
#: data/gtk/preferences.blp:258
#: data/gtk/preferences.blp:290
msgid "Prefer Animated Images"
msgstr ""
@@ -375,21 +388,21 @@ msgid "About Cartridges"
msgstr ""
#. Translators: Replace this with your name for it to show up in the about window
#: src/main.py:171
#: src/main.py:180
msgid "translator_credits"
msgstr ""
#. The variable is the date when the game was added
#: src/window.py:192
#: src/window.py:194
msgid "Added: {}"
msgstr ""
#: src/window.py:195
#: src/window.py:197
msgid "Never"
msgstr ""
#. The variable is the date when the game was last played
#: src/window.py:199
#: src/window.py:201
msgid "Last played: {}"
msgstr ""
@@ -442,46 +455,68 @@ msgstr ""
msgid "Couldn't Add Game"
msgstr ""
#: src/details_window.py:146 src/details_window.py:181
#: src/details_window.py:146 src/details_window.py:180
msgid "Game title cannot be empty."
msgstr ""
#: src/details_window.py:152 src/details_window.py:189
#: src/details_window.py:152 src/details_window.py:188
msgid "Executable cannot be empty."
msgstr ""
#: src/details_window.py:180 src/details_window.py:188
#: src/details_window.py:179 src/details_window.py:187
msgid "Couldn't Apply Preferences"
msgstr ""
#. The variable is the title of the game
#: src/game.py:141
#: src/game.py:138
msgid "{} launched"
msgstr ""
#. The variable is the title of the game
#: src/game.py:154
#: src/game.py:151
msgid "{} hidden"
msgstr ""
#: src/game.py:154
#: src/game.py:151
msgid "{} unhidden"
msgstr ""
#: src/game.py:171
#: src/game.py:168
msgid "{} removed"
msgstr ""
#: src/preferences.py:102
#: src/preferences.py:111
msgid "All games removed"
msgstr ""
#: src/preferences.py:149
#: src/preferences.py:159
msgid ""
"An API key is required to use SteamGridDB. You can generate one {}here{}."
msgstr ""
#: src/preferences.py:289
#: src/preferences.py:284
msgid "Installation Not Found"
msgstr ""
#: src/preferences.py:286
msgid "Select a valid directory."
msgstr ""
#: src/preferences.py:349 src/preferences.py:353
msgid "Invalid Directory"
msgstr ""
#. The variable is the name of the source
#: src/preferences.py:351
msgid "Select the {} cache directory."
msgstr ""
#. The variable is the name of the source
#: src/preferences.py:355
msgid "Select the {} installation directory."
msgstr ""
#: src/preferences.py:361
msgid "Set Location"
msgstr ""

View File

@@ -114,7 +114,7 @@ class DetailsWindow(Adw.Window):
self.exec_info_label.set_label(exec_info_text)
def clear_info_selection(*_args):
self.exec_info_label.select_region(0, 0)
self.exec_info_label.select_region(-1, -1)
self.exec_info_popover.connect("show", clear_info_selection)
@@ -169,8 +169,7 @@ class DetailsWindow(Adw.Window):
"hidden": False,
"source": "imported",
"added": int(time()),
},
allow_side_effects=False,
}
)
else:

View File

@@ -61,7 +61,7 @@ class Game(Gtk.Box):
game_cover = None
version = 0
def __init__(self, data, allow_side_effects=True, **kwargs):
def __init__(self, data, **kwargs):
super().__init__(**kwargs)
self.win = shared.win
@@ -70,9 +70,6 @@ class Game(Gtk.Box):
self.update_values(data)
if allow_side_effects:
self.win.games[self.game_id] = self
self.set_play_icon()
self.event_contoller_motion = Gtk.EventControllerMotion.new()

View File

@@ -94,10 +94,10 @@ class GameCover:
stat = ImageStat.Stat(image.convert("L"))
# Luminance values for light and dark mode
self.luminance = (
(stat.mean[0] + stat.extrema[0][0]) / 510,
(stat.mean[0] + stat.extrema[0][1]) / 510,
)
self.luminance = [
min((stat.mean[0] + stat.extrema[0][0]) / 510, 0.7),
max((stat.mean[0] + stat.extrema[0][1]) / 510, 0.3),
]
else:
self.blurred = self.placeholder_small
self.luminance = (0.3, 0.5)

View File

@@ -18,7 +18,9 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
from shutil import copytree, rmtree
from gi.repository import Adw, GLib, Gtk
@@ -26,7 +28,15 @@ from src import shared
from src.errors.error_producer import ErrorProducer
from src.errors.friendly_error import FriendlyError
from src.game import Game
from src.importer.sources.bottles_source import BottlesSource
from src.importer.sources.flatpak_source import FlatpakSource
from src.importer.sources.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource
from src.importer.sources.location import UnresolvableLocationError
from src.importer.sources.lutris_source import LutrisSource
from src.importer.sources.source import Source
from src.importer.sources.steam_source import SteamSource
from src.store.managers.async_manager import AsyncManager
from src.store.pipeline import Pipeline
from src.utils.task import Task
@@ -52,6 +62,20 @@ class Importer(ErrorProducer):
super().__init__()
self.game_pipelines = set()
self.sources = set()
if shared.schema.get_boolean("lutris"):
self.sources.add(LutrisSource())
if shared.schema.get_boolean("steam"):
self.sources.add(SteamSource())
if shared.schema.get_boolean("heroic"):
self.sources.add(HeroicSource())
if shared.schema.get_boolean("bottles"):
self.sources.add(BottlesSource())
if shared.schema.get_boolean("flatpak"):
self.sources.add(FlatpakSource())
if shared.schema.get_boolean("itch"):
self.sources.add(ItchSource())
if shared.schema.get_boolean("legendary"):
self.sources.add(LegendarySource())
@property
def n_games_added(self):
@@ -66,7 +90,15 @@ class Importer(ErrorProducer):
try:
progress = progress / len(self.game_pipelines)
except ZeroDivisionError:
progress = 1
progress = 0
return progress
@property
def sources_progress(self):
try:
progress = self.n_source_tasks_done / self.n_source_tasks_created
except ZeroDivisionError:
progress = 0
return progress
@property
@@ -76,12 +108,47 @@ class Importer(ErrorProducer):
and len(self.game_pipelines) == self.n_pipelines_done
)
def add_source(self, source):
self.sources.add(source)
def load_games_from_disk(self):
"""Load the games from disk"""
if shared.games_dir.is_dir():
for game_file in shared.games_dir.iterdir():
data = json.load(game_file.open())
game = Game(data)
shared.store.add_game(game, {"skip_save": True})
game.update()
def delete_backup(self):
"""Delete a a previously made backup"""
rmtree(shared.backup_games_dir, ignore_errors=True)
rmtree(shared.backup_covers_dir, ignore_errors=True)
def create_backup(self):
"""Make a games and covers backup"""
self.delete_backup()
copytree(shared.games_dir, shared.backup_games_dir)
copytree(shared.covers_dir, shared.backup_covers_dir)
def restore_backup(self):
"""Restore a previously made backup"""
# Remove games from the store and UI
for game in shared.store.games.values():
game.update_values({"removed": True})
game.update()
# Restore the disk backup
rmtree(shared.games_dir, ignore_errors=True)
rmtree(shared.covers_dir, ignore_errors=True)
shared.backup_games_dir.rename(shared.games_dir)
shared.backup_covers_dir.rename(shared.covers_dir)
# Reload games from disk
self.load_games_from_disk()
def run(self):
"""Use several Gio.Task to import games from added sources"""
self.create_backup()
self.create_dialog()
# Collect all errors and reset the cancellables for the managers
@@ -125,16 +192,18 @@ class Importer(ErrorProducer):
source: Source
source, *_rest = data
# Early exit if not installed
# Early exit if not available or not installed
if not source.is_available:
logging.info("Source %s skipped, not installed", source.id)
logging.info("Source %s skipped, not available", source.id)
return
try:
iterator = iter(source)
except UnresolvableLocationError:
logging.info("Source %s skipped, bad location", source.id)
return
logging.info("Scanning source %s", source.id)
# Initialize source iteration
iterator = iter(source)
# Get games from source
logging.info("Scanning source %s", source.id)
while True:
# Handle exceptions raised when iterating
try:
@@ -172,8 +241,11 @@ class Importer(ErrorProducer):
self.game_pipelines.add(pipeline)
def update_progressbar(self):
"""Update the progressbar to show the percentage of game pipelines done"""
self.progressbar.set_fraction(self.pipelines_progress)
"""Update the progressbar to show the overall import progress"""
# Reserve 10% for the sources discovery, the rest is the pipelines
self.progressbar.set_fraction(
(0.1 * self.sources_progress) + (0.9 * self.pipelines_progress)
)
def source_callback(self, _obj, _result, data):
"""Callback executed when a source is fully scanned"""
@@ -274,13 +346,17 @@ class Importer(ErrorProducer):
"open_preferences",
"import",
)
elif self.n_games_added == 1:
toast.set_title(_("1 game imported"))
elif self.n_games_added > 1:
# The variable is the number of games
toast.set_title(_("{} games imported").format(self.n_games_added))
else:
toast.set_button_label(_("Undo"))
toast.connect(
"button-clicked", self.dialog_response_callback, "undo_import"
)
toast.set_title(
_("1 game imported")
if self.n_games_added == 1
# The variable is the number of games
else _("{} games imported").format(self.n_games_added)
)
shared.win.toast_overlay.add_toast(toast)
return toast
@@ -301,5 +377,7 @@ class Importer(ErrorProducer):
self.open_preferences(*args)
elif response == "open_preferences_import":
self.open_preferences(*args).connect("close-request", self.timeout_toast)
elif response == "undo_import":
self.restore_backup()
else:
self.timeout_toast()

View File

@@ -41,20 +41,20 @@ class BottlesSourceIterator(SourceIterator):
data = self.source.data_location["library.yml"].read_text("utf-8")
library: dict = yaml.safe_load(data)
added_time = int(time())
for entry in library.values():
# Build game
values = {
"version": shared.SPEC_VERSION,
"source": self.source.id,
"added": int(time()),
"added": added_time,
"name": entry["name"],
"game_id": self.source.game_id_format.format(game_id=entry["id"]),
"executable": self.source.executable_format.format(
bottle_name=entry["bottle"]["name"], game_name=entry["name"]
),
}
game = Game(values, allow_side_effects=False)
game = Game(values)
# Get official cover path
try:
@@ -86,13 +86,14 @@ class BottlesSource(URLExecutableSource):
name = "Bottles"
iterator_class = BottlesSourceIterator
url_format = 'bottles:run/"{bottle_name}"/"{game_name}"'
available_on = set(("linux",))
available_on = {"linux"}
data_location = Location(
schema_key="bottles-location",
candidates=(
"~/.var/app/com.usebottles.bottles/data/bottles/",
shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles",
shared.data_dir / "bottles/",
shared.home / ".local" / "share" / "bottles",
),
paths={
"library.yml": (False, "library.yml"),

View File

@@ -0,0 +1,131 @@
# flatpak_source.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from time import time
from gi.repository import GLib, Gtk
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.source import Source, SourceIterationResult, SourceIterator
class FlatpakSourceIterator(SourceIterator):
source: "FlatpakSource"
def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games"""
added_time = int(time())
icon_theme = Gtk.IconTheme.new()
icon_theme.add_search_path(str(self.source.data_location["icons"]))
blacklist = (
{"hu.kramo.Cartridges"}
if shared.schema.get_boolean("flatpak-import-launchers")
else {
"hu.kramo.Cartridges",
"com.valvesoftware.Steam",
"net.lutris.Lutris",
"com.heroicgameslauncher.hgl",
"com.usebottles.Bottles",
"io.itch.itch",
}
)
for entry in (self.source.data_location["applications"]).iterdir():
if entry.suffix != ".desktop":
continue
keyfile = GLib.KeyFile.new()
try:
keyfile.load_from_file(str(entry), 0)
if "Game" not in keyfile.get_string_list("Desktop Entry", "Categories"):
continue
if (
flatpak_id := keyfile.get_string("Desktop Entry", "X-Flatpak")
) in blacklist or flatpak_id != entry.stem:
continue
name = keyfile.get_string("Desktop Entry", "Name")
except GLib.GError:
continue
values = {
"source": self.source.id,
"added": added_time,
"name": name,
"game_id": self.source.game_id_format.format(game_id=flatpak_id),
"executable": self.source.executable_format.format(
flatpak_id=flatpak_id
),
}
game = Game(values)
additional_data = {}
try:
if (
icon_path := icon_theme.lookup_icon(
keyfile.get_string("Desktop Entry", "Icon"),
None,
512,
1,
shared.win.get_direction(),
0,
)
.get_file()
.get_path()
):
additional_data = {"local_icon_path": Path(icon_path)}
else:
pass
except GLib.GError:
pass
# Produce game
yield (game, additional_data)
class FlatpakSource(Source):
"""Generic Flatpak source"""
name = "Flatpak"
iterator_class = FlatpakSourceIterator
executable_format = "flatpak run {flatpak_id}"
available_on = {"linux"}
data_location = Location(
schema_key="flatpak-location",
candidates=(
"/var/lib/flatpak/",
shared.data_dir / "flatpak",
),
paths={
"applications": (True, "exports/share/applications"),
"icons": (True, "exports/share/icons"),
},
)

View File

@@ -68,7 +68,7 @@ class HeroicSourceIterator(SourceIterator):
}
def game_from_library_entry(
self, entry: HeroicLibraryEntry
self, entry: HeroicLibraryEntry, added_time: int
) -> SourceIterationResult:
"""Helper method used to build a Game from a Heroic library entry"""
@@ -81,9 +81,8 @@ class HeroicSourceIterator(SourceIterator):
runner = entry["runner"]
service = self.sub_sources[runner]["service"]
values = {
"version": shared.SPEC_VERSION,
"source": self.source.id,
"added": int(time()),
"added": added_time,
"name": entry["title"],
"developer": entry["developer"],
"game_id": self.source.game_id_format.format(
@@ -91,7 +90,7 @@ class HeroicSourceIterator(SourceIterator):
),
"executable": self.source.executable_format.format(app_name=app_name),
}
game = Game(values, allow_side_effects=False)
game = Game(values)
# Get the image path from the heroic cache
# Filenames are derived from the URL that heroic used to get the file
@@ -119,9 +118,12 @@ class HeroicSourceIterator(SourceIterator):
# Invalid library.json file, skip it
logging.warning("Couldn't open Heroic file: %s", str(file))
continue
added_time = int(time())
for entry in library:
try:
result = self.game_from_library_entry(entry)
result = self.game_from_library_entry(entry, added_time)
except KeyError:
# Skip invalid games
logging.warning("Invalid Heroic game skipped in %s", str(file))
@@ -130,20 +132,20 @@ class HeroicSourceIterator(SourceIterator):
class HeroicSource(URLExecutableSource):
"""Generic heroic games launcher source"""
"""Generic Heroic Games Launcher source"""
name = "Heroic"
iterator_class = HeroicSourceIterator
url_format = "heroic://launch/{app_name}"
available_on = set(("linux", "win32"))
available_on = {"linux", "win32"}
config_location = Location(
schema_key="heroic-location",
candidates=(
"~/.var/app/com.heroicgameslauncher.hgl/config/heroic/",
shared.config_dir / "heroic/",
"~/.config/heroic/",
shared.appdata_dir / "heroic/",
shared.flatpak_dir / "com.heroicgameslauncher.hgl" / "config" / "heroic",
shared.config_dir / "heroic",
shared.home / ".config" / "heroic",
shared.appdata_dir / "heroic",
),
paths={
"config.json": (False, "config.json"),

View File

@@ -59,18 +59,19 @@ class ItchSourceIterator(SourceIterator):
connection = connect(db_path)
cursor = connection.execute(db_request)
added_time = int(time())
# Create games from the db results
for row in cursor:
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"added": added_time,
"source": self.source.id,
"name": row[1],
"game_id": self.source.game_id_format.format(game_id=row[0]),
"executable": self.source.executable_format.format(cave_id=row[4]),
}
additional_data = {"online_cover_url": row[3] or row[2]}
game = Game(values, allow_side_effects=False)
game = Game(values)
yield (game, additional_data)
# Cleanup
@@ -81,15 +82,15 @@ class ItchSource(URLExecutableSource):
name = "Itch"
iterator_class = ItchSourceIterator
url_format = "itch://caves/{cave_id}/launch"
available_on = set(("linux", "win32"))
available_on = {"linux", "win32"}
config_location = Location(
schema_key="itch-location",
candidates=(
"~/.var/app/io.itch.itch/config/itch/",
shared.config_dir / "itch/",
"~/.config/itch/",
shared.appdata_dir / "itch/",
shared.flatpak_dir / "io.itch.itch" / "config" / "itch",
shared.config_dir / "itch",
shared.home / ".config" / "itch",
shared.appdata_dir / "itch",
),
paths={"butler.db": (False, "db/butler.db")},
)

View File

@@ -32,7 +32,9 @@ from src.importer.sources.source import Source, SourceIterationResult, SourceIte
class LegendarySourceIterator(SourceIterator):
source: "LegendarySource"
def game_from_library_entry(self, entry: dict) -> SourceIterationResult:
def game_from_library_entry(
self, entry: dict, added_time: int
) -> SourceIterationResult:
# Skip non-games
if entry["is_dlc"]:
return None
@@ -40,8 +42,7 @@ class LegendarySourceIterator(SourceIterator):
# Build game
app_name = entry["app_name"]
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"added": added_time,
"source": self.source.id,
"name": entry["title"],
"game_id": self.source.game_id_format.format(game_id=app_name),
@@ -61,7 +62,7 @@ class LegendarySourceIterator(SourceIterator):
except (JSONDecodeError, OSError, KeyError):
pass
game = Game(values, allow_side_effects=False)
game = Game(values)
return (game, data)
def generator_builder(self) -> Generator[SourceIterationResult, None, None]:
@@ -72,10 +73,13 @@ class LegendarySourceIterator(SourceIterator):
except (JSONDecodeError, OSError):
logging.warning("Couldn't open Legendary file: %s", str(file))
return
added_time = int(time())
# Generate games from library
for entry in library.values():
try:
result = self.game_from_library_entry(entry)
result = self.game_from_library_entry(entry, added_time)
except KeyError as error:
# Skip invalid games
logging.warning(
@@ -88,14 +92,14 @@ class LegendarySourceIterator(SourceIterator):
class LegendarySource(Source):
name = "Legendary"
executable_format = "legendary launch {app_name}"
available_on = set(("linux", "win32"))
available_on = {"linux", "win32"}
iterator_class = LegendarySourceIterator
data_location: Location = Location(
config_location: Location = Location(
schema_key="legendary-location",
candidates=(
shared.config_dir / "legendary/",
"~/.config/legendary",
shared.config_dir / "legendary",
shared.home / ".config" / "legendary",
),
paths={
"installed.json": (False, "installed.json"),

View File

@@ -48,19 +48,24 @@ class LutrisSourceIterator(SourceIterator):
AND configPath IS NOT NULL
AND installed
AND (runner IS NOT "steam" OR :import_steam)
AND (runner IS NOT "flatpak" OR :import_flatpak)
;
"""
params = {"import_steam": shared.schema.get_boolean("lutris-import-steam")}
params = {
"import_steam": shared.schema.get_boolean("lutris-import-steam"),
"import_flatpak": shared.schema.get_boolean("lutris-import-flatpak"),
}
db_path = copy_db(self.source.data_location["pga.db"])
connection = connect(db_path)
cursor = connection.execute(request, params)
added_time = int(time())
# Create games from the DB results
for row in cursor:
# Create game
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"added": added_time,
"hidden": row[4],
"name": row[1],
"source": f"{self.source.id}_{row[3]}",
@@ -69,7 +74,7 @@ class LutrisSourceIterator(SourceIterator):
),
"executable": self.source.executable_format.format(game_id=row[2]),
}
game = Game(values, allow_side_effects=False)
game = Game(values)
# Get official image path
image_path = self.source.cache_location["coverart"] / f"{row[2]}.jpg"
@@ -83,21 +88,21 @@ class LutrisSourceIterator(SourceIterator):
class LutrisSource(URLExecutableSource):
"""Generic lutris source"""
"""Generic Lutris source"""
name = "Lutris"
iterator_class = LutrisSourceIterator
url_format = "lutris:rungameid/{game_id}"
available_on = set(("linux",))
available_on = {"linux"}
# FIXME possible bug: location picks ~/.var... and cache_lcoation picks ~/.local...
data_location = Location(
schema_key="lutris-location",
candidates=(
"~/.var/app/net.lutris.Lutris/data/lutris/",
shared.data_dir / "lutris/",
"~/.local/share/lutris/",
shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris",
shared.data_dir / "lutris",
shared.home / ".local" / "share" / "lutris",
),
paths={
"pga.db": (False, "pga.db"),
@@ -107,9 +112,9 @@ class LutrisSource(URLExecutableSource):
cache_location = Location(
schema_key="lutris-cache-location",
candidates=(
"~/.var/app/net.lutris.Lutris/cache/lutris/",
shared.cache_dir / "lutris/",
"~/.cache/lutris",
shared.flatpak_dir / "net.lutris.Lutris" / "cache" / "lutris",
shared.cache_dir / "lutris",
shared.home / ".cache" / "lutris",
),
paths={
"coverart": (True, "coverart"),

View File

@@ -22,9 +22,8 @@ from abc import abstractmethod
from collections.abc import Iterable, Iterator
from typing import Any, Generator, Optional
from src.errors.friendly_error import FriendlyError
from src.game import Game
from src.importer.sources.location import Location, UnresolvableLocationError
from src.importer.sources.location import Location
# Type of the data returned by iterating on a Source
SourceIterationResult = None | Game | tuple[Game, tuple[Any]]
@@ -88,7 +87,7 @@ class Source(Iterable):
@property
def game_id_format(self) -> str:
"""The string format used to construct game IDs"""
return self.name.lower() + "_{game_id}"
return self.id + "_{game_id}"
@property
def is_available(self):
@@ -100,27 +99,15 @@ class Source(Iterable):
"""The executable format used to construct game executables"""
def __iter__(self) -> SourceIterator:
"""Get an iterator for the source"""
for location_name in (
locations := {
"data": "Data",
"cache": "Cache",
"config": "Configuration",
}.keys()
):
"""
Get an iterator for the source
:raises UnresolvableLocationError: Not iterable if any of the locations are unresolvable
"""
for location_name in ("data", "cache", "config"):
location = getattr(self, f"{location_name}_location", None)
if location is None:
continue
try:
location.resolve()
except UnresolvableLocationError as error:
raise FriendlyError(
# The variables are the type of location (eg. cache) and the source's name (eg. Steam)
"Invalid {} Location for {{}}".format(locations[location_name]),
"Pick a new one or disable the source in preferences",
(self.name,),
(self.name,),
) from error
location.resolve()
return self.iterator_class(self)

View File

@@ -66,6 +66,9 @@ class SteamSourceIterator(SourceIterator):
"""Generator method producing games"""
appid_cache = set()
manifests = self.get_manifests()
added_time = int(time())
for manifest in manifests:
# Get metadata from manifest
steam = SteamFileHelper()
@@ -87,14 +90,13 @@ class SteamSourceIterator(SourceIterator):
# Build game from local data
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"added": added_time,
"name": local_data["name"],
"source": self.source.id,
"game_id": self.source.game_id_format.format(game_id=appid),
"executable": self.source.executable_format.format(game_id=appid),
}
game = Game(values, allow_side_effects=False)
game = Game(values)
# Add official cover image
image_path = (
@@ -109,16 +111,16 @@ class SteamSourceIterator(SourceIterator):
class SteamSource(URLExecutableSource):
name = "Steam"
available_on = set(("linux", "win32"))
available_on = {"linux", "win32"}
iterator_class = SteamSourceIterator
url_format = "steam://rungameid/{game_id}"
data_location = Location(
schema_key="steam-location",
candidates=(
"~/.var/app/com.valvesoftware.Steam/data/Steam/",
shared.data_dir / "Steam/",
"~/.steam/",
shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam",
shared.data_dir / "Steam",
shared.home / ".steam",
shared.programfiles32_dir / "Steam",
),
paths={

View File

@@ -33,6 +33,8 @@ class SessionFileHandler(StreamHandler):
The files are compressed and older sessions logs are kept up to a small limit.
"""
NUMBER_SUFFIX_POSITION = 1
backup_count: int
filename: Path
log_file: StringIO = None
@@ -41,50 +43,79 @@ class SessionFileHandler(StreamHandler):
"""Create the log dir if needed"""
self.filename.parent.mkdir(exist_ok=True, parents=True)
def path_is_logfile(self, path: Path) -> bool:
return path.is_file() and path.name.startswith(self.filename.stem)
def path_has_number(self, path: Path) -> bool:
try:
int(path.suffixes[self.NUMBER_SUFFIX_POSITION][1:])
except (ValueError, IndexError):
return False
return True
def get_path_number(self, path: Path) -> int:
"""Get the number extension in the filename as an int"""
suffixes = path.suffixes
number = (
0
if not self.path_has_number(path)
else int(suffixes[self.NUMBER_SUFFIX_POSITION][1:])
)
return number
def set_path_number(self, path: Path, number: int) -> str:
"""Set or add the number extension in the filename"""
suffixes = path.suffixes
if self.path_has_number(path):
suffixes.pop(self.NUMBER_SUFFIX_POSITION)
suffixes.insert(self.NUMBER_SUFFIX_POSITION, f".{number}")
stem = path.name.split(".", maxsplit=1)[0]
new_name = stem + "".join(suffixes)
return new_name
def file_sort_key(self, path: Path) -> int:
"""Key function used to sort files"""
return self.get_path_number(path) if self.path_has_number(path) else 0
def get_logfiles(self) -> list[Path]:
"""Get the log files"""
logfiles = list(filter(self.path_is_logfile, self.filename.parent.iterdir()))
logfiles.sort(key=self.file_sort_key, reverse=True)
return logfiles
def rotate_file(self, path: Path):
"""Rotate a file's number suffix and remove it if it's too old"""
# Skip non interesting dir entries
if not (path.is_file() and path.name.startswith(self.filename.name)):
return
# Compute the new number suffix
suffixes = path.suffixes
has_number = len(suffixes) != len(self.filename.suffixes)
current_number = 0 if not has_number else int(suffixes[-1][1:])
new_number = current_number + 1
# If uncompressed, compress
if not path.name.endswith(".xz"):
compressed_path = path.with_suffix(path.suffix + ".xz")
with (
lzma.open(
compressed_path,
"wt",
format=FORMAT_XZ,
preset=PRESET_DEFAULT,
encoding="utf-8",
) as lzma_file,
open(path, "r", encoding="utf-8") as original_file,
):
lzma_file.write(original_file.read())
path.unlink()
path = compressed_path
# Rename with new number suffix
if has_number:
suffixes.pop()
suffixes.append(f".{new_number}")
stem = path.name.split(".", maxsplit=1)[0]
new_name = stem + "".join(suffixes)
path = path.rename(path.with_name(new_name))
new_number = self.get_path_number(path) + 1
new_path_name = self.set_path_number(path, new_number)
path = path.rename(path.with_name(new_path_name))
# Remove older files
if new_number > self.backup_count:
path.unlink()
return
def file_sort_key(self, path: Path) -> int:
"""Key function used to sort files"""
if not path.name.startswith(self.filename.name):
# First all files that aren't logs
return -1
if path.name == self.filename.name:
# Then the latest log file
return 0
# Then in order the other log files
return int(path.suffixes[-1][1:])
def get_files(self) -> list[Path]:
return list(self.filename.parent.iterdir())
def rotate(self) -> None:
"""Rotate the numbered suffix on the log files and remove old ones"""
(files := self.get_files()).sort(key=self.file_sort_key, reverse=True)
for path in files:
for path in self.get_logfiles():
self.rotate_file(path)
def __init__(self, filename: PathLike, backup_count: int = 2) -> None:
@@ -92,10 +123,8 @@ class SessionFileHandler(StreamHandler):
self.backup_count = backup_count
self.create_dir()
self.rotate()
shared.log_files = self.get_files()
self.log_file = lzma.open(
self.filename, "at", format=FORMAT_XZ, preset=PRESET_DEFAULT
)
self.log_file = open(self.filename, "w", encoding="utf-8")
shared.log_files = self.get_logfiles()
super().__init__(self.log_file)
def close(self) -> None:

View File

@@ -20,6 +20,7 @@
import logging
import logging.config as logging_dot_config
import os
import platform
import subprocess
import sys
@@ -35,13 +36,14 @@ def setup_logging():
app_log_level = os.environ.get("LOGLEVEL", profile_app_log_level).upper()
lib_log_level = os.environ.get("LIBLOGLEVEL", profile_lib_log_level).upper()
log_filename = shared.cache_dir / "cartridges" / "logs" / "cartridges.log.xz"
log_filename = shared.cache_dir / "cartridges" / "logs" / "cartridges.log"
config = {
"version": 1,
"formatters": {
"file_formatter": {
"format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
"format": "%(asctime)s - %(levelname)s: %(message)s",
"datefmt": "%M:%S",
},
"console_formatter": {
"format": "%(name)s %(levelname)s - %(message)s",
@@ -91,9 +93,8 @@ def log_system_info():
"""Log system debug information"""
logging.debug("Starting %s v%s (%s)", shared.APP_ID, shared.VERSION, shared.PROFILE)
logging.debug("System: %s", sys.platform)
logging.debug("Python version: %s", sys.version)
if os.getenv("FLATPAK_ID"):
if os.getenv("FLATPAK_ID") == shared.APP_ID:
process = subprocess.run(
("flatpak-spawn", "--host", "flatpak", "--version"),
capture_output=True,
@@ -101,11 +102,8 @@ def log_system_info():
check=False,
)
logging.debug("Flatpak version: %s", process.stdout.rstrip())
logging.debug("Platform: %s", platform.platform())
if os.name == "posix":
uname = os.uname()
logging.debug("Uname info:")
logging.debug("\tsysname: %s", uname.sysname)
logging.debug("\trelease: %s", uname.release)
logging.debug("\tversion: %s", uname.version)
logging.debug("\tmachine: %s", uname.machine)
logging.debug("-" * 80)
for key, value in platform.uname()._asdict().items():
logging.debug("\t%s: %s", key.title(), value)
logging.debug("" * 37)

View File

@@ -17,7 +17,6 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import lzma
import sys
@@ -31,14 +30,7 @@ from gi.repository import Adw, Gio, GLib, Gtk
from src import shared
from src.details_window import DetailsWindow
from src.game import Game
from src.importer.importer import Importer
from src.importer.sources.bottles_source import BottlesSource
from src.importer.sources.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource
from src.importer.sources.lutris_source import LutrisSource
from src.importer.sources.steam_source import SteamSource
from src.logging.setup import log_system_info, setup_logging
from src.preferences import PreferencesWindow
from src.store.managers.display_manager import DisplayManager
@@ -55,6 +47,7 @@ class CartridgesApplication(Adw.Application):
win = None
def __init__(self):
shared.store = Store()
super().__init__(
application_id=shared.APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE
)
@@ -81,20 +74,16 @@ class CartridgesApplication(Adw.Application):
"is-maximized", self.win, "maximized", Gio.SettingsBindFlags.DEFAULT
)
# Create the games store ready to load games from disk
if not shared.store:
shared.store = Store()
shared.store.add_manager(FileManager(), False)
shared.store.add_manager(DisplayManager())
self.load_games_from_disk()
# Add rest of the managers for game imports
# Add managers to the store for game imports
shared.store.add_manager(DisplayManager())
shared.store.add_manager(FileManager())
shared.store.add_manager(LocalCoverManager())
shared.store.add_manager(SteamAPIManager())
shared.store.add_manager(OnlineCoverManager())
shared.store.add_manager(SGDBManager())
shared.store.enable_manager_in_pipelines(FileManager)
# Load games from disk
Importer().load_games_from_disk()
# Create actions
self.create_actions(
@@ -135,18 +124,21 @@ class CartridgesApplication(Adw.Application):
self.win.present()
def load_games_from_disk(self):
if shared.games_dir.is_dir():
for game_file in shared.games_dir.iterdir():
data = json.load(game_file.open())
game = Game(data, allow_side_effects=False)
shared.store.add_game(game, {"skip_save": True})
def on_about_action(self, *_args):
# Get the debug info from the log files
debug_str = ""
for path in shared.log_files:
log_file = lzma.open(path, "rt", encoding="utf-8")
for i, path in enumerate(shared.log_files):
# Add a horizontal line between runs
if i > 0:
debug_str += "" * 37 + "\n"
# Add the run's logs
log_file = (
lzma.open(path, "rt", encoding="utf-8")
if path.name.endswith(".xz")
else open(path, "r", encoding="utf-8")
)
debug_str += log_file.read()
log_file.close()
about = Adw.AboutWindow(
transient_for=self.win,
@@ -198,27 +190,7 @@ class CartridgesApplication(Adw.Application):
DetailsWindow()
def on_import_action(self, *_args):
importer = Importer()
if shared.schema.get_boolean("lutris"):
importer.add_source(LutrisSource())
if shared.schema.get_boolean("steam"):
importer.add_source(SteamSource())
if shared.schema.get_boolean("heroic"):
importer.add_source(HeroicSource())
if shared.schema.get_boolean("bottles"):
importer.add_source(BottlesSource())
if shared.schema.get_boolean("itch"):
importer.add_source(ItchSource())
if shared.schema.get_boolean("legendary"):
importer.add_source(LegendarySource())
importer.run()
Importer().run()
def on_remove_game_action(self, *_args):
self.win.active_game.remove_game()

View File

@@ -26,9 +26,11 @@ from gi.repository import Adw, Gio, GLib, Gtk
from src import shared
from src.importer.sources.bottles_source import BottlesSource
from src.importer.sources.flatpak_source import FlatpakSource
from src.importer.sources.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource
from src.importer.sources.location import UnresolvableLocationError
from src.importer.sources.lutris_source import LutrisSource
from src.importer.sources.source import Source
from src.importer.sources.steam_source import SteamSource
@@ -59,6 +61,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
lutris_cache_action_row = Gtk.Template.Child()
lutris_cache_file_chooser_button = Gtk.Template.Child()
lutris_import_steam_switch = Gtk.Template.Child()
lutris_import_flatpak_switch = Gtk.Template.Child()
heroic_expander_row = Gtk.Template.Child()
heroic_config_action_row = Gtk.Template.Child()
@@ -79,6 +82,11 @@ class PreferencesWindow(Adw.PreferencesWindow):
legendary_config_action_row = Gtk.Template.Child()
legendary_config_file_chooser_button = Gtk.Template.Child()
flatpak_expander_row = Gtk.Template.Child()
flatpak_data_action_row = Gtk.Template.Child()
flatpak_data_file_chooser_button = Gtk.Template.Child()
flatpak_import_launchers_switch = Gtk.Template.Child()
sgdb_key_group = Gtk.Template.Child()
sgdb_key_entry_row = Gtk.Template.Child()
sgdb_switch = Gtk.Template.Child()
@@ -92,6 +100,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
remove_all_games_button = Gtk.Template.Child()
removed_games = set()
warning_menu_buttons = {}
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -124,6 +133,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
# Sources settings
for source_class in (
BottlesSource,
FlatpakSource,
HeroicSource,
ItchSource,
LegendarySource,
@@ -133,7 +143,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
source = source_class()
if not source.is_available:
expander_row = getattr(self, f"{source.id}_expander_row")
expander_row.remove()
expander_row.set_visible(False)
else:
self.init_source_row(source)
@@ -168,9 +178,11 @@ class PreferencesWindow(Adw.PreferencesWindow):
"cover-launches-game",
"high-quality-images",
"lutris-import-steam",
"lutris-import-flatpak",
"heroic-import-epic",
"heroic-import-gog",
"heroic-import-sideload",
"flatpak-import-launchers",
"sgdb",
"sgdb-prefer",
"sgdb-animated",
@@ -202,7 +214,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
self.toast.dismiss()
def remove_all_games(self, *_args):
for game in self.win.games.values():
for game in shared.store.games.values():
if not game.removed:
self.removed_games.add(game)
@@ -215,7 +227,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
self.add_toast(self.toast)
def reset_app(*_args):
def reset_app(self, *_args):
rmtree(shared.data_dir / "cartridges", True)
rmtree(shared.config_dir / "cartridges", True)
rmtree(shared.cache_dir / "cartridges", True)
@@ -241,15 +253,65 @@ class PreferencesWindow(Adw.PreferencesWindow):
if not action_row:
continue
# Historically "location" meant data or config, so the key stays shared
infix = "-cache" if location == "cache" else ""
key = f"{source.id}{infix}-location"
path = Path(shared.schema.get_string(key)).expanduser()
# Remove the path if the dir is picked via the Flatpak portal
# Remove the path prefix if picked via Flatpak portal
subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path))
action_row.set_subtitle(subtitle)
def resolve_locations(self, source):
"""Resolve locations and add a warning if location cannot be found"""
def clear_warning_selection(_widget, label):
label.select_region(-1, -1)
for location_name in ("data", "config", "cache"):
action_row = getattr(self, f"{source.id}_{location_name}_action_row", None)
if not action_row:
continue
try:
getattr(source, f"{location_name}_location", None).resolve()
except UnresolvableLocationError:
popover = Gtk.Popover(
child=(
label := Gtk.Label(
label=(
'<span rise="12pt"><b><big>'
+ _("Installation Not Found")
+ "</big></b></span>\n"
+ _("Select a valid directory.")
),
use_markup=True,
wrap=True,
max_width_chars=50,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
justify=Gtk.Justification.CENTER,
margin_top=9,
margin_bottom=9,
margin_start=12,
margin_end=12,
selectable=True,
)
)
)
popover.connect("show", clear_warning_selection, label)
menu_button = Gtk.MenuButton(
icon_name="dialog-warning-symbolic",
valign=Gtk.Align.CENTER,
popover=popover,
)
menu_button.add_css_class("warning")
action_row.add_prefix(menu_button)
self.warning_menu_buttons[source.id] = menu_button
def init_source_row(self, source: Source):
"""Initialize a preference row for a source class"""
@@ -271,20 +333,30 @@ class PreferencesWindow(Adw.PreferencesWindow):
shared.schema.set_string(key, value)
# Update the row
self.update_source_action_row_paths(source)
if self.warning_menu_buttons.get(source.id):
action_row = getattr(
self, f"{source.id}_{location_name}_action_row", None
)
action_row.remove(self.warning_menu_buttons[source.id])
self.warning_menu_buttons.pop(source.id)
logging.debug("User-set value for schema key %s: %s", key, value)
# Bad picked location, inform user
else:
if location_name == "cache":
title = "Cache directory not found"
subtitle_format = "Select the {} cache directory."
title = _("Invalid Directory")
# The variable is the name of the source
subtitle_format = _("Select the {} cache directory.")
else:
title = "Installation directory not found"
subtitle_format = "Select the {} installation directory."
title = _("Invalid Directory")
# The variable is the name of the source
subtitle_format = _("Select the {} installation directory.")
dialog = create_dialog(
self,
_(title),
_(subtitle_format).format(source.name),
title,
subtitle_format.format(source.name),
"choose_folder",
_("Set Location"),
)
@@ -311,4 +383,5 @@ class PreferencesWindow(Adw.PreferencesWindow):
button.connect("clicked", self.choose_folder, set_dir, location)
# Set the source row subtitles
self.resolve_locations(source)
self.update_source_action_row_paths(source)

View File

@@ -20,7 +20,7 @@
import os
from pathlib import Path
from gi.repository import Gdk, Gio
from gi.repository import Gdk, Gio, GLib
APP_ID = "@APP_ID@"
VERSION = "@VERSION@"
@@ -31,25 +31,19 @@ SPEC_VERSION = 1.5 # The version of the game_id.json spec
schema = Gio.Settings.new(APP_ID)
state_schema = Gio.Settings.new(APP_ID + ".State")
data_dir = (
Path(os.getenv("XDG_DATA_HOME"))
if "XDG_DATA_HOME" in os.environ
else Path.home() / ".local" / "share"
)
config_dir = (
Path(os.getenv("XDG_CONFIG_HOME"))
if "XDG_CONFIG_HOME" in os.environ
else Path.home() / ".config"
)
cache_dir = (
Path(os.getenv("XDG_CACHE_HOME"))
if "XDG_CACHE_HOME" in os.environ
else Path.home() / ".cache"
)
home = Path.home()
data_dir = Path(GLib.get_user_data_dir())
config_dir = Path(GLib.get_user_config_dir())
cache_dir = Path(GLib.get_user_cache_dir())
flatpak_dir = home / ".var" / "app"
games_dir = data_dir / "cartridges" / "games"
covers_dir = data_dir / "cartridges" / "covers"
backup_dir = cache_dir / "cartridges" / "backup"
backup_games_dir = backup_dir / games_dir.name
backup_covers_dir = backup_dir / covers_dir.name
appdata_dir = Path(os.getenv("appdata") or "C:\\Users\\Default\\AppData\\Roaming")
programfiles32_dir = Path(os.getenv("programfiles(x86)") or "C:\\Program Files (x86)")

View File

@@ -17,6 +17,8 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from src import shared
from src.game import Game
from src.game_cover import GameCover
@@ -32,7 +34,6 @@ class DisplayManager(Manager):
signals = {"update-ready"}
def manager_logic(self, game: Game, _additional_data: dict) -> None:
shared.win.games[game.game_id] = game
if game.get_parent():
game.get_parent().get_parent().remove(game)
if game.get_parent():
@@ -48,27 +49,28 @@ class DisplayManager(Manager):
"notify::visible", game.toggle_play, None
)
game.menu_button.get_popover().connect(
"notify::visible", game.win.set_active_game, game
"notify::visible", shared.win.set_active_game, game
)
if game.game_id in game.win.game_covers:
game.game_cover = game.win.game_covers[game.game_id]
if game.game_id in shared.win.game_covers:
game.game_cover = shared.win.game_covers[game.game_id]
game.game_cover.add_picture(game.cover)
else:
game.game_cover = GameCover({game.cover}, game.get_cover_path())
game.win.game_covers[game.game_id] = game.game_cover
shared.win.game_covers[game.game_id] = game.game_cover
if (
game.win.stack.get_visible_child() == game.win.details_view
and game.win.active_game == game
shared.win.stack.get_visible_child() == shared.win.details_view
and shared.win.active_game == game
):
game.win.show_details_view(game)
shared.win.show_details_view(game)
if not game.removed and not game.blacklisted:
logging.debug("Adding %s (%s) to the UI", game.name, game.game_id)
if game.hidden:
game.win.hidden_library.append(game)
shared.win.hidden_library.append(game)
else:
game.win.library.append(game)
shared.win.library.append(game)
game.get_parent().set_focusable(False)
game.win.set_library_child()
shared.win.set_library_child()

View File

@@ -1,6 +1,7 @@
# local_cover_manager.py
#
# Copyright 2023 Geoffrey Coulaud
# Copyright 2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -17,12 +18,13 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from gi.repository import GdkPixbuf
from src import shared
from src.game import Game
from src.store.managers.manager import Manager
from src.store.managers.steam_api_manager import SteamAPIManager
from src.utils.save_cover import save_cover, resize_cover
from src.utils.save_cover import resize_cover, save_cover
class LocalCoverManager(Manager):
@@ -31,12 +33,39 @@ class LocalCoverManager(Manager):
run_after = (SteamAPIManager,)
def manager_logic(self, game: Game, additional_data: dict) -> None:
# Ensure that the cover path is in the additional data
try:
image_path: Path = additional_data["local_image_path"]
except KeyError:
return
if not image_path.is_file():
return
# Save the image
save_cover(game.game_id, resize_cover(image_path))
if image_path := additional_data.get("local_image_path"):
if not image_path.is_file():
return
save_cover(game.game_id, resize_cover(image_path))
elif icon_path := additional_data.get("local_icon_path"):
cover_width, cover_height = shared.image_size
dest_width = cover_width * 0.7
dest_height = cover_width * 0.7
dest_x = cover_width * 0.15
dest_y = (cover_height - dest_height) / 2
image = GdkPixbuf.Pixbuf.new_from_file(str(icon_path)).scale_simple(
dest_width, dest_height, GdkPixbuf.InterpType.BILINEAR
)
cover = image.scale_simple(
1, 2, GdkPixbuf.InterpType.BILINEAR
).scale_simple(cover_width, cover_height, GdkPixbuf.InterpType.BILINEAR)
image.composite(
cover,
dest_x,
dest_y,
dest_width,
dest_height,
dest_x,
dest_y,
1,
1,
GdkPixbuf.InterpType.BILINEAR,
255,
)
save_cover(game.game_id, resize_cover(pixbuf=cover))

View File

@@ -110,6 +110,7 @@ class Manager(ErrorProducer):
except Exception as error: # pylint: disable=broad-exception-caught
handle_error(error)
logging.debug("Running %s for %s (%s)", self.name, game.name, game.game_id)
try_manager_logic()
def process_game(

View File

@@ -36,7 +36,7 @@ class OnlineCoverManager(Manager):
"""Manager that downloads game covers from URLs"""
run_after = (LocalCoverManager,)
retryable_on = (HTTPError, SSLError)
retryable_on = (HTTPError, SSLError, ConnectionError)
def save_composited_cover(
self,

View File

@@ -32,7 +32,7 @@ from src.utils.steam import (
class SteamAPIManager(AsyncManager):
"""Manager in charge of completing a game's data from the Steam API"""
retryable_on = (HTTPError, SSLError)
retryable_on = (HTTPError, SSLError, ConnectionError)
steam_api_helper: SteamAPIHelper = None
steam_rate_limiter: SteamRateLimiter = None

View File

@@ -21,7 +21,7 @@
from pathlib import Path
from shutil import copyfile
from gi.repository import Gio
from gi.repository import Gdk, Gio, GLib
from PIL import Image, ImageSequence, UnidentifiedImageError
from src import shared
@@ -63,7 +63,13 @@ def resize_cover(cover_path=None, pixbuf=None):
else "webp",
)
except UnidentifiedImageError:
return None
try:
Gdk.Texture.new_from_filename(str(cover_path)).save_to_tiff(
tmp_path := Gio.File.new_tmp("XXXXXX.tiff")[0].get_path()
)
return resize_cover(tmp_path)
except GLib.GError:
return None
return tmp_path

View File

@@ -57,7 +57,7 @@ class SteamManifestData(TypedDict):
class SteamAPIData(TypedDict):
"""Dict returned by SteamAPIHelper.get_api_data"""
developers: str
developer: str
class SteamRateLimiter(RateLimiter):
@@ -148,5 +148,5 @@ class SteamAPIHelper:
raise SteamNotAGameError()
# Return API values we're interested in
values = SteamAPIData(developers=", ".join(data["data"]["developers"]))
values = SteamAPIData(developer=", ".join(data["data"]["developers"]))
return values

View File

@@ -64,7 +64,6 @@ class CartridgesWindow(Adw.ApplicationWindow):
hidden_search_entry = Gtk.Template.Child()
hidden_search_button = Gtk.Template.Child()
games = {}
game_covers = {}
toasts = {}
active_game = None
@@ -100,6 +99,9 @@ class CartridgesWindow(Adw.ApplicationWindow):
self.search_entry.connect("search-changed", self.search_changed, False)
self.hidden_search_entry.connect("search-changed", self.search_changed, True)
self.search_entry.connect("activate", self.show_details_view_search)
self.hidden_search_entry.connect("activate", self.show_details_view_search)
back_mouse_button = Gtk.GestureClick(button=8)
(back_mouse_button).connect("pressed", self.on_go_back_action)
self.add_controller(back_mouse_button)
@@ -115,7 +117,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
def set_library_child(self):
child, hidden_child = self.notice_empty, self.hidden_notice_empty
for game in self.games.values():
for game in shared.store.games.values():
if game.removed or game.blacklisted:
continue
if game.hidden:
@@ -303,13 +305,29 @@ class CartridgesWindow(Adw.ApplicationWindow):
search_entry.set_text("")
def on_escape_action(self, *_args):
if self.stack.get_visible_child() == self.details_view:
self.on_go_back_action()
elif (
if (
self.get_focus() == self.search_entry.get_focus_child()
or self.hidden_search_entry.get_focus_child()
):
self.on_toggle_search_action()
else:
self.on_go_back_action()
def show_details_view_search(self, widget):
library = (
self.hidden_library if widget == self.hidden_search_entry else self.library
)
index = 0
while True:
if not (child := library.get_child_at_index(index)):
break
if self.filter_func(child):
self.show_details_view(child.get_child())
break
index += 1
def on_undo_action(self, _widget, game=None, undo=None):
if not game: # If the action was activated via Ctrl + Z