Compare commits

..

2 Commits

Author SHA1 Message Date
Geoffrey Coulaud
3df85e9443 Merge pull request #120 from kra-mo/initial-gamepad-support
initial work on controller support
2023-06-29 13:24:31 +02:00
GeoffreyCoulaud
2d72f22bbf 🚧 initial work on controller support 2023-06-29 12:48:51 +02:00
26 changed files with 395 additions and 558 deletions

View File

@@ -132,15 +132,6 @@ 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 {
@@ -225,29 +216,6 @@ 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,9 +28,6 @@
<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>
@@ -64,15 +61,6 @@
<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,8 +16,7 @@
"--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",
@@ -99,21 +98,73 @@
] ]
}, },
{ {
"name": "python3-pyxdg", "name" : "python3-snegg",
"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} \"pyxdg\" --no-build-isolation" "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"attrs\" --no-build-isolation"
], ],
"sources": [ "sources": [
{ {
"type": "file", "type": "file",
"url": "https://files.pythonhosted.org/packages/e5/8d/cf41b66a8110670e3ad03dab9b759704eeed07fa96e90fdc0357b2ba70e2/pyxdg-0.28-py2.py3-none-any.whl", "url": "https://files.pythonhosted.org/packages/f0/eb/fcb708c7bf5056045e9e98f62b93bd7467eb718b0202e7698eb11d66416c/attrs-23.1.0-py3-none-any.whl",
"sha256": "bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab" "sha256": "1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"
}
]
},
{
"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,7 +169,8 @@ 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, **kwargs): def __init__(self, data, allow_side_effects=True, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.win = shared.win self.win = shared.win
@@ -70,6 +70,9 @@ 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 = (
min((stat.mean[0] + stat.extrema[0][0]) / 510, 0.7), (stat.mean[0] + stat.extrema[0][0]) / 510,
max((stat.mean[0] + stat.extrema[0][1]) / 510, 0.3), (stat.mean[0] + stat.extrema[0][1]) / 510,
] )
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,7 +26,6 @@ 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
@@ -67,15 +66,7 @@ class Importer(ErrorProducer):
try: try:
progress = progress / len(self.game_pipelines) progress = progress / len(self.game_pipelines)
except ZeroDivisionError: except ZeroDivisionError:
progress = 0 progress = 1
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
@@ -134,18 +125,16 @@ class Importer(ErrorProducer):
source: Source source: Source
source, *_rest = data source, *_rest = data
# Early exit if not available or not installed # Early exit if not installed
if not source.is_available: if not source.is_available:
logging.info("Source %s skipped, not available", source.id) logging.info("Source %s skipped, not installed", 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:
@@ -183,11 +172,8 @@ 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 overall import progress""" """Update the progressbar to show the percentage of game pipelines done"""
# Reserve 10% for the sources discovery, the rest is the pipelines self.progressbar.set_fraction(self.pipelines_progress)
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": added_time, "added": int(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) game = Game(values, allow_side_effects=False)
# Get official cover path # Get official cover path
try: try:

View File

@@ -1,134 +0,0 @@
# 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, added_time: int self, entry: HeroicLibraryEntry
) -> 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,8 +81,9 @@ 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": added_time, "added": int(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(
@@ -90,7 +91,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) game = Game(values, allow_side_effects=False)
# 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
@@ -118,12 +119,9 @@ 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, added_time) result = self.game_from_library_entry(entry)
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))
@@ -132,7 +130,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
@@ -143,8 +141,9 @@ 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/",
shared.appdata_dir / "heroic", "~/.config/heroic/",
shared.appdata_dir / "heroic/",
), ),
paths={ paths={
"config.json": (False, "config.json"), "config.json": (False, "config.json"),

View File

@@ -59,19 +59,18 @@ 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 = {
"added": added_time, "version": shared.SPEC_VERSION,
"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) game = Game(values, allow_side_effects=False)
yield (game, additional_data) yield (game, additional_data)
# Cleanup # Cleanup
@@ -88,8 +87,9 @@ 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/",
shared.appdata_dir / "itch", "~/.config/itch/",
shared.appdata_dir / "itch/",
), ),
paths={"butler.db": (False, "db/butler.db")}, paths={"butler.db": (False, "db/butler.db")},
) )

View File

@@ -32,9 +32,7 @@ from src.importer.sources.source import Source, SourceIterationResult, SourceIte
class LegendarySourceIterator(SourceIterator): class LegendarySourceIterator(SourceIterator):
source: "LegendarySource" source: "LegendarySource"
def game_from_library_entry( def game_from_library_entry(self, entry: dict) -> SourceIterationResult:
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
@@ -42,7 +40,8 @@ class LegendarySourceIterator(SourceIterator):
# Build game # Build game
app_name = entry["app_name"] app_name = entry["app_name"]
values = { values = {
"added": added_time, "version": shared.SPEC_VERSION,
"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),
@@ -62,7 +61,7 @@ class LegendarySourceIterator(SourceIterator):
except (JSONDecodeError, OSError, KeyError): except (JSONDecodeError, OSError, KeyError):
pass pass
game = Game(values) game = Game(values, allow_side_effects=False)
return (game, data) return (game, data)
def generator_builder(self) -> Generator[SourceIterationResult, None, None]: def generator_builder(self) -> Generator[SourceIterationResult, None, None]:
@@ -73,13 +72,10 @@ 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, added_time) result = self.game_from_library_entry(entry)
except KeyError as error: except KeyError as error:
# Skip invalid games # Skip invalid games
logging.warning( logging.warning(
@@ -97,7 +93,10 @@ 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=(shared.config_dir / "legendary",), candidates=(
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,24 +48,19 @@ 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 = { params = {"import_steam": shared.schema.get_boolean("lutris-import-steam")}
"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 = {
"added": added_time, "version": shared.SPEC_VERSION,
"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]}",
@@ -74,7 +69,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) game = Game(values, allow_side_effects=False)
# 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"
@@ -88,7 +83,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
@@ -101,7 +96,8 @@ 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"),
@@ -112,7 +108,8 @@ 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,8 +22,9 @@ 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 from src.importer.sources.location import Location, UnresolvableLocationError
# 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]]
@@ -87,7 +88,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.id + "_{game_id}" return self.name.lower() + "_{game_id}"
@property @property
def is_available(self): def is_available(self):
@@ -99,15 +100,27 @@ 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"""
Get an iterator for the source for location_name in (
:raises UnresolvableLocationError: Not iterable if any of the locations are unresolvable locations := {
""" "data": "Data",
for location_name in ("data", "cache", "config"): "cache": "Cache",
"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
location.resolve() 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
return self.iterator_class(self) return self.iterator_class(self)

View File

@@ -66,9 +66,6 @@ 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()
@@ -90,13 +87,14 @@ class SteamSourceIterator(SourceIterator):
# Build game from local data # Build game from local data
values = { values = {
"added": added_time, "version": shared.SPEC_VERSION,
"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) game = Game(values, allow_side_effects=False)
# Add official cover image # Add official cover image
image_path = ( image_path = (
@@ -119,8 +117,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

@@ -1,115 +0,0 @@
# 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")},
)

99
src/keyboard_emulator.py Normal file
View File

@@ -0,0 +1,99 @@
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,8 +33,6 @@ 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
@@ -43,75 +41,50 @@ 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"""
# If uncompressed, compress # Skip non interesting dir entries
if not path.name.endswith(".xz"): if not (path.is_file() and path.name.startswith(self.filename.name)):
new_path = path.with_suffix(path.suffix + ".xz") return
with (
lzma.open( # Compute the new number suffix
new_path, "wt", format=FORMAT_XZ, preset=PRESET_DEFAULT suffixes = path.suffixes
) as lzma_file, has_number = len(suffixes) != len(self.filename.suffixes)
open(path, "r", encoding="utf-8") as original_file, current_number = 0 if not has_number else int(suffixes[-1][1:])
): 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
new_number = self.get_path_number(path) + 1 if has_number:
new_path_name = self.set_path_number(path, new_number) suffixes.pop()
path = path.rename(path.with_name(new_path_name)) 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))
# 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"""
for path in self.get_logfiles(): (files := self.get_files()).sort(key=self.file_sort_key, reverse=True)
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:
@@ -119,8 +92,10 @@ class SessionFileHandler(StreamHandler):
self.backup_count = backup_count self.backup_count = backup_count
self.create_dir() self.create_dir()
self.rotate() self.rotate()
self.log_file = open(self.filename, "w", encoding="utf-8") shared.log_files = self.get_files()
shared.log_files = self.get_logfiles() self.log_file = lzma.open(
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,7 +20,6 @@
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
@@ -36,14 +35,13 @@ 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" log_filename = shared.cache_dir / "cartridges" / "logs" / "cartridges.log.xz"
config = { config = {
"version": 1, "version": 1,
"formatters": { "formatters": {
"file_formatter": { "file_formatter": {
"format": "%(asctime)s - %(levelname)s: %(message)s", "format": "%(asctime)s | %(name)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",
@@ -93,8 +91,9 @@ 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") == shared.APP_ID: if os.getenv("FLATPAK_ID"):
process = subprocess.run( process = subprocess.run(
("flatpak-spawn", "--host", "flatpak", "--version"), ("flatpak-spawn", "--host", "flatpak", "--version"),
capture_output=True, capture_output=True,
@@ -102,8 +101,11 @@ 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":
for key, value in platform.uname()._asdict().items(): uname = os.uname()
logging.debug("\t%s: %s", key.title(), value) logging.debug("Uname info:")
logging.debug("" * 37) 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)

View File

@@ -18,6 +18,7 @@
# 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
@@ -25,21 +26,22 @@ 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, Gio, GLib, Gtk from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, Manette
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
@@ -54,16 +56,81 @@ 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)
@@ -72,6 +139,16 @@ 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
@@ -83,9 +160,12 @@ class CartridgesApplication(Adw.Application):
"is-maximized", self.win, "maximized", Gio.SettingsBindFlags.DEFAULT "is-maximized", self.win, "maximized", Gio.SettingsBindFlags.DEFAULT
) )
# Load games from disk # Create the games store ready to load games from disk
shared.store.add_manager(FileManager(), False) if not shared.store:
shared.store.add_manager(DisplayManager()) shared.store = Store()
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
@@ -138,24 +218,14 @@ 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) game = Game(data, allow_side_effects=False)
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 i, path in enumerate(shared.log_files): for path in shared.log_files:
# Add a horizontal line between runs log_file = lzma.open(path, "rt", encoding="utf-8")
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,
@@ -221,9 +291,6 @@ 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,6 +21,7 @@ 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,7 +26,6 @@ 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
@@ -60,7 +59,6 @@ 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()
@@ -81,11 +79,6 @@ 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()
@@ -131,7 +124,6 @@ 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,
@@ -176,11 +168,9 @@ 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",
@@ -212,7 +202,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 shared.store.games.values(): for game in self.win.games.values():
if not game.removed: if not game.removed:
self.removed_games.add(game) self.removed_games.add(game)
@@ -225,7 +215,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
self.add_toast(self.toast) self.add_toast(self.toast)
def reset_app(self, *_args): def reset_app(*_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,6 +17,7 @@
# #
# 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
@@ -31,6 +32,7 @@ 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,7 +1,6 @@
# 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
@@ -18,13 +17,12 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from gi.repository import GdkPixbuf from pathlib import Path
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 resize_cover, save_cover from src.utils.save_cover import save_cover, resize_cover
class LocalCoverManager(Manager): class LocalCoverManager(Manager):
@@ -33,39 +31,12 @@ 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:
if image_path := additional_data.get("local_image_path"): # Ensure that the cover path is in the additional data
if not image_path.is_file(): try:
return image_path: Path = additional_data["local_image_path"]
save_cover(game.game_id, resize_cover(image_path)) except KeyError:
elif icon_path := additional_data.get("local_icon_path"): return
cover_width, cover_height = shared.image_size if not image_path.is_file():
return
dest_width = cover_width * 0.7 # Save the image
dest_height = cover_width * 0.7 save_cover(game.game_id, resize_cover(image_path))
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 Gdk, Gio, GLib from gi.repository import Gio
from PIL import Image, ImageSequence, UnidentifiedImageError from PIL import Image, ImageSequence, UnidentifiedImageError
from src import shared from src import shared
@@ -63,13 +63,7 @@ def resize_cover(cover_path=None, pixbuf=None):
else "webp", else "webp",
) )
except UnidentifiedImageError: except UnidentifiedImageError:
try: return None
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,6 +64,7 @@ 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
@@ -99,9 +100,6 @@ 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)
@@ -117,7 +115,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 shared.store.games.values(): for game in self.games.values():
if game.removed or game.blacklisted: if game.removed or game.blacklisted:
continue continue
if game.hidden: if game.hidden:
@@ -305,29 +303,13 @@ 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 ( if self.stack.get_visible_child() == self.details_view:
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