Compare commits

..

27 Commits

Author SHA1 Message Date
GeoffreyCoulaud
755b733023 🚧 Very unfinished initial work on yuzu source 2023-07-01 03:52:52 +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
26 changed files with 558 additions and 395 deletions

View File

@@ -132,6 +132,15 @@ template $PreferencesWindow : Adw.PreferencesWindow {
valign: center; 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 { 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"> <key name="lutris-import-steam" type="b">
<default>false</default> <default>false</default>
</key> </key>
<key name="lutris-import-flatpak" type="b">
<default>false</default>
</key>
<key name="heroic" type="b"> <key name="heroic" type="b">
<default>true</default> <default>true</default>
</key> </key>
@@ -61,6 +64,15 @@
<key name="legendary-location" type="s"> <key name="legendary-location" type="s">
<default>"~/.config/legendary/"</default> <default>"~/.config/legendary/"</default>
</key> </key>
<key name="flatpak" type="b">
<default>true</default>
</key>
<key name="flatpak-location" type="s">
<default>"/var/lib/flatpak/exports/"</default>
</key>
<key name="flatpak-import-launchers" type="b">
<default>false</default>
</key>
<key name="sgdb-key" type="s"> <key name="sgdb-key" type="s">
<default>""</default> <default>""</default>
</key> </key>

View File

@@ -16,7 +16,8 @@
"--filesystem=~/.var/app/net.lutris.Lutris/:ro", "--filesystem=~/.var/app/net.lutris.Lutris/:ro",
"--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro", "--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro",
"--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/: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" : [ "cleanup" : [
"/include", "/include",
@@ -98,73 +99,21 @@
] ]
}, },
{ {
"name" : "python3-snegg", "name": "python3-pyxdg",
"buildsystem" : "simple",
"build-commands": [
"cd snegg",
"pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} . --no-build-isolation"
],
"sources": [
{
"type" : "git",
"url": "https://gitlab.freedesktop.org/libinput/snegg.git",
"tag": "main"
}
]
},
{
"name": "python3-attrs",
"buildsystem": "simple", "buildsystem": "simple",
"build-commands": [ "build-commands": [
"pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"attrs\" --no-build-isolation" "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pyxdg\" --no-build-isolation"
], ],
"sources": [ "sources": [
{ {
"type": "file", "type": "file",
"url": "https://files.pythonhosted.org/packages/f0/eb/fcb708c7bf5056045e9e98f62b93bd7467eb718b0202e7698eb11d66416c/attrs-23.1.0-py3-none-any.whl", "url": "https://files.pythonhosted.org/packages/e5/8d/cf41b66a8110670e3ad03dab9b759704eeed07fa96e90fdc0357b2ba70e2/pyxdg-0.28-py2.py3-none-any.whl",
"sha256": "1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04" "sha256": "bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab"
}
]
},
{
"name": "python3-jinja2",
"buildsystem": "simple",
"build-commands": [
"pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"jinja2\" --no-build-isolation"
],
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/bc/c3/f068337a370801f372f2f8f6bad74a5c140f6fda3d9de154052708dd3c65/Jinja2-3.1.2-py3-none-any.whl",
"sha256": "6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/6d/7c/59a3248f411813f8ccba92a55feaac4bf360d29e2ff05ee7d8e1ef2d7dbf/MarkupSafe-2.1.3.tar.gz",
"sha256": "af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"
} }
] ]
} }
] ]
}, },
{
"name" : "libei",
"buildsystem" : "meson",
"config-opts": [
"-Ddocumentation=[]",
"-Dtests=disabled"
],
"sources" : [
{
"type" : "git",
"url" : "https://gitlab.freedesktop.org/libinput/libei.git",
"tag" : "1.0.0"
}
],
"cleanup" : [
"*"
]
},
{ {
"name" : "blueprint-compiler", "name" : "blueprint-compiler",
"buildsystem" : "meson", "buildsystem" : "meson",

View File

@@ -169,8 +169,7 @@ class DetailsWindow(Adw.Window):
"hidden": False, "hidden": False,
"source": "imported", "source": "imported",
"added": int(time()), "added": int(time()),
}, }
allow_side_effects=False,
) )
else: else:

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ from src import shared
from src.errors.error_producer import ErrorProducer from src.errors.error_producer import ErrorProducer
from src.errors.friendly_error import FriendlyError from src.errors.friendly_error import FriendlyError
from src.game import Game from src.game import Game
from src.importer.sources.location import UnresolvableLocationError
from src.importer.sources.source import Source from src.importer.sources.source import Source
from src.store.managers.async_manager import AsyncManager from src.store.managers.async_manager import AsyncManager
from src.store.pipeline import Pipeline from src.store.pipeline import Pipeline
@@ -66,7 +67,15 @@ class Importer(ErrorProducer):
try: try:
progress = progress / len(self.game_pipelines) progress = progress / len(self.game_pipelines)
except ZeroDivisionError: 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 return progress
@property @property
@@ -125,16 +134,18 @@ class Importer(ErrorProducer):
source: Source source: Source
source, *_rest = data source, *_rest = data
# Early exit if not installed # Early exit if not available or not installed
if not source.is_available: 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 return
logging.info("Scanning source %s", source.id)
# Initialize source iteration
iterator = iter(source)
# Get games from source # Get games from source
logging.info("Scanning source %s", source.id)
while True: while True:
# Handle exceptions raised when iterating # Handle exceptions raised when iterating
try: try:
@@ -172,8 +183,11 @@ class Importer(ErrorProducer):
self.game_pipelines.add(pipeline) self.game_pipelines.add(pipeline)
def update_progressbar(self): def update_progressbar(self):
"""Update the progressbar to show the percentage of game pipelines done""" """Update the progressbar to show the overall import progress"""
self.progressbar.set_fraction(self.pipelines_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): def source_callback(self, _obj, _result, data):
"""Callback executed when a source is fully scanned""" """Callback executed when a source is fully scanned"""

View File

@@ -41,20 +41,20 @@ class BottlesSourceIterator(SourceIterator):
data = self.source.data_location["library.yml"].read_text("utf-8") data = self.source.data_location["library.yml"].read_text("utf-8")
library: dict = yaml.safe_load(data) library: dict = yaml.safe_load(data)
added_time = int(time())
for entry in library.values(): for entry in library.values():
# Build game # Build game
values = { values = {
"version": shared.SPEC_VERSION,
"source": self.source.id, "source": self.source.id,
"added": int(time()), "added": added_time,
"name": entry["name"], "name": entry["name"],
"game_id": self.source.game_id_format.format(game_id=entry["id"]), "game_id": self.source.game_id_format.format(game_id=entry["id"]),
"executable": self.source.executable_format.format( "executable": self.source.executable_format.format(
bottle_name=entry["bottle"]["name"], game_name=entry["name"] bottle_name=entry["bottle"]["name"], game_name=entry["name"]
), ),
} }
game = Game(values, allow_side_effects=False) game = Game(values)
# Get official cover path # Get official cover path
try: try:

View File

@@ -0,0 +1,134 @@
# 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
import re
from pathlib import Path
from time import time
import subprocess
from xdg import IconTheme
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())
IconTheme.icondirs.append(self.source.data_location["icons"])
try:
process = subprocess.run(
("flatpak-spawn", "--host", "flatpak", "list", "--columns=application"),
capture_output=True,
encoding="utf-8",
check=True,
)
flatpak_ids = process.stdout.split("\n")
to_remove = (
{"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 item in to_remove:
if item in flatpak_ids:
flatpak_ids.remove(item)
except subprocess.CalledProcessError:
return
for entry in (self.source.data_location["applications"]).iterdir():
flatpak_id = entry.stem
if flatpak_id not in flatpak_ids:
continue
with entry.open("r", encoding="utf-8") as open_file:
string = open_file.read()
desktop_values = {"Name": None, "Icon": None, "Categories": None}
for key in desktop_values:
if regex := re.findall(f"{key}=(.*)\n", string):
desktop_values[key] = regex[0]
if not desktop_values["Name"]:
continue
if not desktop_values["Categories"]:
continue
if not "Game" in desktop_values["Categories"].split(";"):
continue
values = {
"source": self.source.id,
"added": added_time,
"name": desktop_values["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 = {}
if icon_name := desktop_values["Icon"]:
if icon_path := IconTheme.getIconPath(icon_name, 512):
additional_data = {"local_icon_path": Path(icon_path)}
else:
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 = set(("linux",))
data_location = Location(
schema_key="flatpak-location",
candidates=(
"/var/lib/flatpak/exports/",
shared.data_dir / "flatpak" / "exports",
),
paths={
"applications": (True, "share/applications"),
"icons": (True, "share/icons"),
},
)

View File

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

View File

@@ -59,18 +59,19 @@ class ItchSourceIterator(SourceIterator):
connection = connect(db_path) connection = connect(db_path)
cursor = connection.execute(db_request) cursor = connection.execute(db_request)
added_time = int(time())
# Create games from the db results # Create games from the db results
for row in cursor: for row in cursor:
values = { values = {
"version": shared.SPEC_VERSION, "added": added_time,
"added": int(time()),
"source": self.source.id, "source": self.source.id,
"name": row[1], "name": row[1],
"game_id": self.source.game_id_format.format(game_id=row[0]), "game_id": self.source.game_id_format.format(game_id=row[0]),
"executable": self.source.executable_format.format(cave_id=row[4]), "executable": self.source.executable_format.format(cave_id=row[4]),
} }
additional_data = {"online_cover_url": row[3] or row[2]} additional_data = {"online_cover_url": row[3] or row[2]}
game = Game(values, allow_side_effects=False) game = Game(values)
yield (game, additional_data) yield (game, additional_data)
# Cleanup # Cleanup
@@ -87,9 +88,8 @@ class ItchSource(URLExecutableSource):
schema_key="itch-location", schema_key="itch-location",
candidates=( candidates=(
"~/.var/app/io.itch.itch/config/itch/", "~/.var/app/io.itch.itch/config/itch/",
shared.config_dir / "itch/", shared.config_dir / "itch",
"~/.config/itch/", shared.appdata_dir / "itch",
shared.appdata_dir / "itch/",
), ),
paths={"butler.db": (False, "db/butler.db")}, 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): class LegendarySourceIterator(SourceIterator):
source: "LegendarySource" 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 # Skip non-games
if entry["is_dlc"]: if entry["is_dlc"]:
return None return None
@@ -40,8 +42,7 @@ class LegendarySourceIterator(SourceIterator):
# Build game # Build game
app_name = entry["app_name"] app_name = entry["app_name"]
values = { values = {
"version": shared.SPEC_VERSION, "added": added_time,
"added": int(time()),
"source": self.source.id, "source": self.source.id,
"name": entry["title"], "name": entry["title"],
"game_id": self.source.game_id_format.format(game_id=app_name), "game_id": self.source.game_id_format.format(game_id=app_name),
@@ -61,7 +62,7 @@ class LegendarySourceIterator(SourceIterator):
except (JSONDecodeError, OSError, KeyError): except (JSONDecodeError, OSError, KeyError):
pass pass
game = Game(values, allow_side_effects=False) game = Game(values)
return (game, data) return (game, data)
def generator_builder(self) -> Generator[SourceIterationResult, None, None]: def generator_builder(self) -> Generator[SourceIterationResult, None, None]:
@@ -72,10 +73,13 @@ class LegendarySourceIterator(SourceIterator):
except (JSONDecodeError, OSError): except (JSONDecodeError, OSError):
logging.warning("Couldn't open Legendary file: %s", str(file)) logging.warning("Couldn't open Legendary file: %s", str(file))
return return
added_time = int(time())
# Generate games from library # Generate games from library
for entry in library.values(): for entry in library.values():
try: try:
result = self.game_from_library_entry(entry) result = self.game_from_library_entry(entry, added_time)
except KeyError as error: except KeyError as error:
# Skip invalid games # Skip invalid games
logging.warning( logging.warning(
@@ -93,10 +97,7 @@ class LegendarySource(Source):
iterator_class = LegendarySourceIterator iterator_class = LegendarySourceIterator
data_location: Location = Location( data_location: Location = Location(
schema_key="legendary-location", schema_key="legendary-location",
candidates=( candidates=(shared.config_dir / "legendary",),
shared.config_dir / "legendary/",
"~/.config/legendary",
),
paths={ paths={
"installed.json": (False, "installed.json"), "installed.json": (False, "installed.json"),
"metadata": (True, "metadata"), "metadata": (True, "metadata"),

View File

@@ -48,19 +48,24 @@ class LutrisSourceIterator(SourceIterator):
AND configPath IS NOT NULL AND configPath IS NOT NULL
AND installed AND installed
AND (runner IS NOT "steam" OR :import_steam) 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"]) db_path = copy_db(self.source.data_location["pga.db"])
connection = connect(db_path) connection = connect(db_path)
cursor = connection.execute(request, params) cursor = connection.execute(request, params)
added_time = int(time())
# Create games from the DB results # Create games from the DB results
for row in cursor: for row in cursor:
# Create game # Create game
values = { values = {
"version": shared.SPEC_VERSION, "added": added_time,
"added": int(time()),
"hidden": row[4], "hidden": row[4],
"name": row[1], "name": row[1],
"source": f"{self.source.id}_{row[3]}", "source": f"{self.source.id}_{row[3]}",
@@ -69,7 +74,7 @@ class LutrisSourceIterator(SourceIterator):
), ),
"executable": self.source.executable_format.format(game_id=row[2]), "executable": self.source.executable_format.format(game_id=row[2]),
} }
game = Game(values, allow_side_effects=False) game = Game(values)
# Get official image path # Get official image path
image_path = self.source.cache_location["coverart"] / f"{row[2]}.jpg" image_path = self.source.cache_location["coverart"] / f"{row[2]}.jpg"
@@ -83,7 +88,7 @@ class LutrisSourceIterator(SourceIterator):
class LutrisSource(URLExecutableSource): class LutrisSource(URLExecutableSource):
"""Generic lutris source""" """Generic Lutris source"""
name = "Lutris" name = "Lutris"
iterator_class = LutrisSourceIterator iterator_class = LutrisSourceIterator
@@ -96,8 +101,7 @@ class LutrisSource(URLExecutableSource):
schema_key="lutris-location", schema_key="lutris-location",
candidates=( candidates=(
"~/.var/app/net.lutris.Lutris/data/lutris/", "~/.var/app/net.lutris.Lutris/data/lutris/",
shared.data_dir / "lutris/", shared.data_dir / "lutris",
"~/.local/share/lutris/",
), ),
paths={ paths={
"pga.db": (False, "pga.db"), "pga.db": (False, "pga.db"),
@@ -108,8 +112,7 @@ class LutrisSource(URLExecutableSource):
schema_key="lutris-cache-location", schema_key="lutris-cache-location",
candidates=( candidates=(
"~/.var/app/net.lutris.Lutris/cache/lutris/", "~/.var/app/net.lutris.Lutris/cache/lutris/",
shared.cache_dir / "lutris/", shared.cache_dir / "lutris",
"~/.cache/lutris",
), ),
paths={ paths={
"coverart": (True, "coverart"), "coverart": (True, "coverart"),

View File

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

View File

@@ -66,6 +66,9 @@ class SteamSourceIterator(SourceIterator):
"""Generator method producing games""" """Generator method producing games"""
appid_cache = set() appid_cache = set()
manifests = self.get_manifests() manifests = self.get_manifests()
added_time = int(time())
for manifest in manifests: for manifest in manifests:
# Get metadata from manifest # Get metadata from manifest
steam = SteamFileHelper() steam = SteamFileHelper()
@@ -87,14 +90,13 @@ class SteamSourceIterator(SourceIterator):
# Build game from local data # Build game from local data
values = { values = {
"version": shared.SPEC_VERSION, "added": added_time,
"added": int(time()),
"name": local_data["name"], "name": local_data["name"],
"source": self.source.id, "source": self.source.id,
"game_id": self.source.game_id_format.format(game_id=appid), "game_id": self.source.game_id_format.format(game_id=appid),
"executable": self.source.executable_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 # Add official cover image
image_path = ( image_path = (
@@ -117,8 +119,8 @@ class SteamSource(URLExecutableSource):
schema_key="steam-location", schema_key="steam-location",
candidates=( candidates=(
"~/.var/app/com.valvesoftware.Steam/data/Steam/", "~/.var/app/com.valvesoftware.Steam/data/Steam/",
shared.data_dir / "Steam/", shared.data_dir / "Steam",
"~/.steam/", "~/.steam",
shared.programfiles32_dir / "Steam", shared.programfiles32_dir / "Steam",
), ),
paths={ paths={

View File

@@ -0,0 +1,115 @@
# lutris_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# 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 time import time
from typing import Generator, Iterable
from configparser import ConfigParser
from pathlib import Path
import os
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.source import (
SourceIterationResult,
SourceIterator,
Source,
)
class YuzuSourceIterator(SourceIterator):
source: "YuzuSource"
extensions = (".xci", ".nsp", ".nso", ".nro")
def iter_game_dirs(self) -> Iterable[tuple[bool, Path]]:
"""
Get the rom directories from the parsed config
The returned tuple indicates if the dir should be scanned recursively,
then its path.
"""
# Get the config data
config = ConfigParser()
if not config.read(
self.source.data_location["qt-config.ini"], encoding="utf-8"
):
return
# Iterate through the dirs
n_dirs = config.getint("UI", r"Paths\gamedirs\size", fallback=0)
for i in range(1, n_dirs + 1):
deep = config.getboolean(
"UI", f"Paths\\gamedirs\\{i}\\deep_scan", fallback=False
)
path = Path(config.get("UI", f"Paths\\gamedirs\\{i}\\path", fallback=None))
if path is None:
continue
yield deep, path
def iter_rom_files(
self, root: Path, recursive: bool = False
) -> Generator[Path, None, None]:
"""Generator method to iterate through rom files"""
if not recursive:
for path in root.iterdir():
if not path.is_file():
continue
if not path.suffix in self.extensions:
continue
yield path
else:
for dir_path, _dirs, file_names in os.walk(root):
for filename in file_names:
path = Path(dir_path) / filename
if path.suffix in self.extensions:
continue
yield path
def generator_builder(self) -> Generator[SourceIterationResult, None, None]:
"""Generator method producing games"""
added_time = int(time())
# Get the games
for recursive_search, game_dir in self.iter_game_dirs():
for path in self.iter_rom_files(game_dir, recursive_search):
values = {
# TODO add game_id
"added": added_time,
"source": self.source.id,
"executable": f"yuzu {str(path)}", # HACK change depending on the variant
}
game = Game(values)
additional_data = {}
yield game, additional_data
class YuzuSource(Source):
config_location = Location(
"yuzu-location",
(
"~/.var/app/org.yuzu_emu.yuzu/config/yuzu",
shared.config_dir / "yuzu",
"~/.config/yuzu",
# TODO windows path
),
{"qt-config.ini": (False, "qt-config.ini")},
)

View File

@@ -1,99 +0,0 @@
import logging
import select
from collections import deque
from gi.repository import GLib
from snegg.oeffis import DisconnectedError, Oeffis, SessionClosedError
from snegg.ei import Sender, DeviceCapability, EventType, Seat, Device, Event
class PortalError(Exception):
"""Error raised when a oeffis portal can't be acquired"""
class KeyboardEmulator:
"""
A class that triggers keypresses with libei
Libei docs: https://libinput.pages.freedesktop.org/libei/
Snegg docs: https://libinput.pages.freedesktop.org/snegg/snegg.ei.html
"""
app = None
queue: deque = None
sender: Sender = None
seat: Seat = None
keyboard: Device = None
def __init__(self, app) -> None:
self.app = app
self.queue = deque()
self.app.connect("emulate-key", self.on_emulate_key)
GLib.Thread.new(None, self.thread_func)
def on_emulate_key(self, keyval):
self.queue.append(keyval)
@staticmethod
def get_eis_portal() -> Oeffis:
"""Get a portal to the eis server"""
portal = Oeffis.create()
if portal is None:
raise PortalError()
poll = select.poll()
poll.register(portal.fd)
while poll.poll():
try:
if portal.dispatch():
# We need to keep the portal object alive so we don't get disconnected
return portal
except (SessionClosedError, DisconnectedError) as error:
raise PortalError() from error
def thread_func(self):
"""Daemon thread entry point"""
# Connect to the EIS server
try:
portal = self.get_eis_portal()
except PortalError as error:
logging.error("Can't get EIS portal", exc_info=error)
raise
self.sender = Sender.create_for_fd(fd=portal.eis_fd, name="ei-debug-events")
# Handle sender events
poll = select.poll()
poll.register(self.sender.fd)
while poll.poll():
self.sender.dispatch()
for event in self.sender.events:
self.handle_sender_event(event)
def handle_sender_event(self, event: Event):
"""Handle libei sender (input producer) events"""
match event.event_type:
# The emulated seat is created, we need to specify its capabilities
case EventType.SEAT_ADDED:
if not event.seat:
return
self.seat = event.seat
self.seat.bind(DeviceCapability.KEYBOARD)
# A device was added to the seat (here, we're only doing a keyboard)
case EventType.DEVICE_ADDED:
if not event.device:
return
self.keyboard = event.device
# Input can be processed, send keys
case EventType.DEVICE_RESUMED:
self.keyboard.start_emulating()
keyval = self.queue.popleft()
self.keyboard.keyboard_key(keyval, True)
self.keyboard.frame()
self.keyboard.keyboard_key(keyval, False)
self.keyboard.frame()
self.keyboard.stop_emulating()

View File

@@ -33,6 +33,8 @@ class SessionFileHandler(StreamHandler):
The files are compressed and older sessions logs are kept up to a small limit. The files are compressed and older sessions logs are kept up to a small limit.
""" """
NUMBER_SUFFIX_POSITION = 1
backup_count: int backup_count: int
filename: Path filename: Path
log_file: StringIO = None log_file: StringIO = None
@@ -41,50 +43,75 @@ class SessionFileHandler(StreamHandler):
"""Create the log dir if needed""" """Create the log dir if needed"""
self.filename.parent.mkdir(exist_ok=True, parents=True) 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): def rotate_file(self, path: Path):
"""Rotate a file's number suffix and remove it if it's too old""" """Rotate a file's number suffix and remove it if it's too old"""
# Skip non interesting dir entries # If uncompressed, compress
if not (path.is_file() and path.name.startswith(self.filename.name)): if not path.name.endswith(".xz"):
return new_path = path.with_suffix(path.suffix + ".xz")
with (
# Compute the new number suffix lzma.open(
suffixes = path.suffixes new_path, "wt", format=FORMAT_XZ, preset=PRESET_DEFAULT
has_number = len(suffixes) != len(self.filename.suffixes) ) as lzma_file,
current_number = 0 if not has_number else int(suffixes[-1][1:]) open(path, "r", encoding="utf-8") as original_file,
new_number = current_number + 1 ):
lzma_file.write(original_file.read())
path.unlink()
path = new_path
# Rename with new number suffix # Rename with new number suffix
if has_number: new_number = self.get_path_number(path) + 1
suffixes.pop() new_path_name = self.set_path_number(path, new_number)
suffixes.append(f".{new_number}") path = path.rename(path.with_name(new_path_name))
stem = path.name.split(".", maxsplit=1)[0]
new_name = stem + "".join(suffixes)
path = path.rename(path.with_name(new_name))
# Remove older files # Remove older files
if new_number > self.backup_count: if new_number > self.backup_count:
path.unlink() path.unlink()
return 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: def rotate(self) -> None:
"""Rotate the numbered suffix on the log files and remove old ones""" """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 self.get_logfiles():
for path in files:
self.rotate_file(path) self.rotate_file(path)
def __init__(self, filename: PathLike, backup_count: int = 2) -> None: def __init__(self, filename: PathLike, backup_count: int = 2) -> None:
@@ -92,10 +119,8 @@ class SessionFileHandler(StreamHandler):
self.backup_count = backup_count self.backup_count = backup_count
self.create_dir() self.create_dir()
self.rotate() self.rotate()
shared.log_files = self.get_files() self.log_file = open(self.filename, "w", encoding="utf-8")
self.log_file = lzma.open( shared.log_files = self.get_logfiles()
self.filename, "at", format=FORMAT_XZ, preset=PRESET_DEFAULT
)
super().__init__(self.log_file) super().__init__(self.log_file)
def close(self) -> None: def close(self) -> None:

View File

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

View File

@@ -18,7 +18,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import json import json
import logging
import lzma import lzma
import sys import sys
@@ -26,22 +25,21 @@ import gi
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1") gi.require_version("Adw", "1")
gi.require_version("Manette", "0.2")
# pylint: disable=wrong-import-position # pylint: disable=wrong-import-position
from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, Manette from gi.repository import Adw, Gio, GLib, Gtk
from src import shared from src import shared
from src.details_window import DetailsWindow from src.details_window import DetailsWindow
from src.game import Game from src.game import Game
from src.importer.importer import Importer from src.importer.importer import Importer
from src.importer.sources.bottles_source import BottlesSource 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.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource from src.importer.sources.legendary_source import LegendarySource
from src.importer.sources.lutris_source import LutrisSource from src.importer.sources.lutris_source import LutrisSource
from src.importer.sources.steam_source import SteamSource from src.importer.sources.steam_source import SteamSource
from src.keyboard_emulator import KeyboardEmulator
from src.logging.setup import log_system_info, setup_logging from src.logging.setup import log_system_info, setup_logging
from src.preferences import PreferencesWindow from src.preferences import PreferencesWindow
from src.store.managers.display_manager import DisplayManager from src.store.managers.display_manager import DisplayManager
@@ -56,81 +54,16 @@ from src.window import CartridgesWindow
class CartridgesApplication(Adw.Application): class CartridgesApplication(Adw.Application):
win = None win = None
window_controller = None
keyboard_emulator = None
def __init__(self): def __init__(self):
shared.store = Store()
super().__init__( super().__init__(
application_id=shared.APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE application_id=shared.APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE
) )
@GObject.Signal(name="emulate-key", arg_types=[int])
def emulate_key(self, keyval) -> None:
"""Signal emitted when the app wants to emulate a keypress"""
def gamepad_abs_axis(self, _device, event):
logging.debug(event.get_absolute())
def gamepad_hat_axis(self, device, event):
device.rumble(1000, 1000, 50)
logging.debug(
"Gamepad: hat axis: %s, value: %s", *(hat := event.get_hat())[1:3]
)
if hat[2] != 0:
self.navigate(hat[1] + hat[2])
def navigate(self, direction: int):
match direction:
case 16:
self.emit("emulate-key", Gdk.KEY_Up)
print("up")
case 18:
print("down")
case 15:
print("left")
case 17:
print("right")
def print_args(self, *args):
print(*args)
def gamepad_button_pressed(self, _device, event):
logging.debug("Gamepad: %s pressed", (button := event.get_button()[1]))
match button:
case 304:
print("A button pressed")
case 305:
print("B button pressed")
case 307:
print("X button pressed")
case 308:
print("Y button pressed")
case 314:
self.win.on_show_hidden_action()
def gamepad_listen(self, device, *_args):
device.connect("button-press-event", self.gamepad_button_pressed)
device.connect("hat-axis-event", self.gamepad_hat_axis)
def log_connected():
logging.debug("%s connected", device.get_name())
GLib.timeout_add_seconds(60, log_connected)
log_connected()
def do_activate(self): # pylint: disable=arguments-differ def do_activate(self): # pylint: disable=arguments-differ
"""Called on app creation""" """Called on app creation"""
# Setup gamepads
manette_monitor = Manette.Monitor.new()
manette_iter = manette_monitor.iterate()
while (device := manette_iter.next())[0]:
self.gamepad_listen(device[1])
self.keyboard_emulator = KeyboardEmulator(self)
# Set fallback icon-name # Set fallback icon-name
Gtk.Window.set_default_icon_name(shared.APP_ID) Gtk.Window.set_default_icon_name(shared.APP_ID)
@@ -139,16 +72,6 @@ class CartridgesApplication(Adw.Application):
if not self.win: if not self.win:
shared.win = self.win = CartridgesWindow(application=self) shared.win = self.win = CartridgesWindow(application=self)
for index in range(
(list_model := self.win.observe_controllers()).get_n_items()
):
if isinstance((item := list_model.get_item(index)), Gtk.EventControllerKey):
self.window_controller = item
break
self.window_controller.connect("key-pressed", self.print_args)
self.window_controller.connect("key-released", self.print_args)
# Save window geometry # Save window geometry
shared.state_schema.bind( shared.state_schema.bind(
"width", self.win, "default-width", Gio.SettingsBindFlags.DEFAULT "width", self.win, "default-width", Gio.SettingsBindFlags.DEFAULT
@@ -160,12 +83,9 @@ class CartridgesApplication(Adw.Application):
"is-maximized", self.win, "maximized", Gio.SettingsBindFlags.DEFAULT "is-maximized", self.win, "maximized", Gio.SettingsBindFlags.DEFAULT
) )
# Create the games store ready to load games from disk # Load games from disk
if not shared.store: shared.store.add_manager(FileManager(), False)
shared.store = Store() shared.store.add_manager(DisplayManager())
shared.store.add_manager(FileManager(), False)
shared.store.add_manager(DisplayManager())
self.load_games_from_disk() self.load_games_from_disk()
# Add rest of the managers for game imports # Add rest of the managers for game imports
@@ -218,14 +138,24 @@ class CartridgesApplication(Adw.Application):
if shared.games_dir.is_dir(): if shared.games_dir.is_dir():
for game_file in shared.games_dir.iterdir(): for game_file in shared.games_dir.iterdir():
data = json.load(game_file.open()) data = json.load(game_file.open())
game = Game(data, allow_side_effects=False) game = Game(data)
shared.store.add_game(game, {"skip_save": True}) shared.store.add_game(game, {"skip_save": True})
def on_about_action(self, *_args): def on_about_action(self, *_args):
# Get the debug info from the log files
debug_str = "" debug_str = ""
for path in shared.log_files: for i, path in enumerate(shared.log_files):
log_file = lzma.open(path, "rt", encoding="utf-8") # 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() debug_str += log_file.read()
log_file.close()
about = Adw.AboutWindow( about = Adw.AboutWindow(
transient_for=self.win, transient_for=self.win,
@@ -291,6 +221,9 @@ class CartridgesApplication(Adw.Application):
if shared.schema.get_boolean("bottles"): if shared.schema.get_boolean("bottles"):
importer.add_source(BottlesSource()) importer.add_source(BottlesSource())
if shared.schema.get_boolean("flatpak"):
importer.add_source(FlatpakSource())
if shared.schema.get_boolean("itch"): if shared.schema.get_boolean("itch"):
importer.add_source(ItchSource()) importer.add_source(ItchSource())

View File

@@ -21,7 +21,6 @@ install_data(
'details_window.py', 'details_window.py',
'game.py', 'game.py',
'game_cover.py', 'game_cover.py',
'keyboard_emulator.py',
configure_file( configure_file(
input: 'shared.py.in', input: 'shared.py.in',
output: 'shared.py', output: 'shared.py',

View File

@@ -26,6 +26,7 @@ from gi.repository import Adw, Gio, GLib, Gtk
from src import shared from src import shared
from src.importer.sources.bottles_source import BottlesSource 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.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource from src.importer.sources.legendary_source import LegendarySource
@@ -59,6 +60,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
lutris_cache_action_row = Gtk.Template.Child() lutris_cache_action_row = Gtk.Template.Child()
lutris_cache_file_chooser_button = Gtk.Template.Child() lutris_cache_file_chooser_button = Gtk.Template.Child()
lutris_import_steam_switch = Gtk.Template.Child() lutris_import_steam_switch = Gtk.Template.Child()
lutris_import_flatpak_switch = Gtk.Template.Child()
heroic_expander_row = Gtk.Template.Child() heroic_expander_row = Gtk.Template.Child()
heroic_config_action_row = Gtk.Template.Child() heroic_config_action_row = Gtk.Template.Child()
@@ -79,6 +81,11 @@ class PreferencesWindow(Adw.PreferencesWindow):
legendary_config_action_row = Gtk.Template.Child() legendary_config_action_row = Gtk.Template.Child()
legendary_config_file_chooser_button = 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_group = Gtk.Template.Child()
sgdb_key_entry_row = Gtk.Template.Child() sgdb_key_entry_row = Gtk.Template.Child()
sgdb_switch = Gtk.Template.Child() sgdb_switch = Gtk.Template.Child()
@@ -124,6 +131,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
# Sources settings # Sources settings
for source_class in ( for source_class in (
BottlesSource, BottlesSource,
FlatpakSource,
HeroicSource, HeroicSource,
ItchSource, ItchSource,
LegendarySource, LegendarySource,
@@ -168,9 +176,11 @@ class PreferencesWindow(Adw.PreferencesWindow):
"cover-launches-game", "cover-launches-game",
"high-quality-images", "high-quality-images",
"lutris-import-steam", "lutris-import-steam",
"lutris-import-flatpak",
"heroic-import-epic", "heroic-import-epic",
"heroic-import-gog", "heroic-import-gog",
"heroic-import-sideload", "heroic-import-sideload",
"flatpak-import-launchers",
"sgdb", "sgdb",
"sgdb-prefer", "sgdb-prefer",
"sgdb-animated", "sgdb-animated",
@@ -202,7 +212,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
self.toast.dismiss() self.toast.dismiss()
def remove_all_games(self, *_args): def remove_all_games(self, *_args):
for game in self.win.games.values(): for game in shared.store.games.values():
if not game.removed: if not game.removed:
self.removed_games.add(game) self.removed_games.add(game)
@@ -215,7 +225,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
self.add_toast(self.toast) self.add_toast(self.toast)
def reset_app(*_args): def reset_app(self, *_args):
rmtree(shared.data_dir / "cartridges", True) rmtree(shared.data_dir / "cartridges", True)
rmtree(shared.config_dir / "cartridges", True) rmtree(shared.config_dir / "cartridges", True)
rmtree(shared.cache_dir / "cartridges", True) rmtree(shared.cache_dir / "cartridges", True)

View File

@@ -17,7 +17,6 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from src import shared
from src.game import Game from src.game import Game
from src.game_cover import GameCover from src.game_cover import GameCover
from src.store.managers.manager import Manager from src.store.managers.manager import Manager
@@ -32,7 +31,6 @@ class DisplayManager(Manager):
signals = {"update-ready"} signals = {"update-ready"}
def manager_logic(self, game: Game, _additional_data: dict) -> None: def manager_logic(self, game: Game, _additional_data: dict) -> None:
shared.win.games[game.game_id] = game
if game.get_parent(): if game.get_parent():
game.get_parent().get_parent().remove(game) game.get_parent().get_parent().remove(game)
if game.get_parent(): if game.get_parent():

View File

@@ -1,6 +1,7 @@
# local_cover_manager.py # local_cover_manager.py
# #
# Copyright 2023 Geoffrey Coulaud # Copyright 2023 Geoffrey Coulaud
# Copyright 2023 kramo
# #
# This program is free software: you can redistribute it and/or modify # 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 # 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 # 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.game import Game
from src.store.managers.manager import Manager from src.store.managers.manager import Manager
from src.store.managers.steam_api_manager import SteamAPIManager 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): class LocalCoverManager(Manager):
@@ -31,12 +33,39 @@ class LocalCoverManager(Manager):
run_after = (SteamAPIManager,) run_after = (SteamAPIManager,)
def manager_logic(self, game: Game, additional_data: dict) -> None: def manager_logic(self, game: Game, additional_data: dict) -> None:
# Ensure that the cover path is in the additional data if image_path := additional_data.get("local_image_path"):
try: if not image_path.is_file():
image_path: Path = additional_data["local_image_path"] return
except KeyError: save_cover(game.game_id, resize_cover(image_path))
return elif icon_path := additional_data.get("local_icon_path"):
if not image_path.is_file(): cover_width, cover_height = shared.image_size
return
# Save the image dest_width = cover_width * 0.7
save_cover(game.game_id, resize_cover(image_path)) 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

@@ -21,7 +21,7 @@
from pathlib import Path from pathlib import Path
from shutil import copyfile from shutil import copyfile
from gi.repository import Gio from gi.repository import Gdk, Gio, GLib
from PIL import Image, ImageSequence, UnidentifiedImageError from PIL import Image, ImageSequence, UnidentifiedImageError
from src import shared from src import shared
@@ -63,7 +63,13 @@ def resize_cover(cover_path=None, pixbuf=None):
else "webp", else "webp",
) )
except UnidentifiedImageError: 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 return tmp_path

View File

@@ -64,7 +64,6 @@ class CartridgesWindow(Adw.ApplicationWindow):
hidden_search_entry = Gtk.Template.Child() hidden_search_entry = Gtk.Template.Child()
hidden_search_button = Gtk.Template.Child() hidden_search_button = Gtk.Template.Child()
games = {}
game_covers = {} game_covers = {}
toasts = {} toasts = {}
active_game = None active_game = None
@@ -100,6 +99,9 @@ class CartridgesWindow(Adw.ApplicationWindow):
self.search_entry.connect("search-changed", self.search_changed, False) self.search_entry.connect("search-changed", self.search_changed, False)
self.hidden_search_entry.connect("search-changed", self.search_changed, True) 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 = Gtk.GestureClick(button=8)
(back_mouse_button).connect("pressed", self.on_go_back_action) (back_mouse_button).connect("pressed", self.on_go_back_action)
self.add_controller(back_mouse_button) self.add_controller(back_mouse_button)
@@ -115,7 +117,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
def set_library_child(self): def set_library_child(self):
child, hidden_child = self.notice_empty, self.hidden_notice_empty 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: if game.removed or game.blacklisted:
continue continue
if game.hidden: if game.hidden:
@@ -303,13 +305,29 @@ class CartridgesWindow(Adw.ApplicationWindow):
search_entry.set_text("") search_entry.set_text("")
def on_escape_action(self, *_args): def on_escape_action(self, *_args):
if self.stack.get_visible_child() == self.details_view: if (
self.on_go_back_action()
elif (
self.get_focus() == self.search_entry.get_focus_child() self.get_focus() == self.search_entry.get_focus_child()
or self.hidden_search_entry.get_focus_child() or self.hidden_search_entry.get_focus_child()
): ):
self.on_toggle_search_action() 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): def on_undo_action(self, _widget, game=None, undo=None):
if not game: # If the action was activated via Ctrl + Z if not game: # If the action was activated via Ctrl + Z