diff --git a/src/importer/sources/flatpak_source.py b/src/importer/sources/flatpak_source.py index 6bb982e..fe4d643 100644 --- a/src/importer/sources/flatpak_source.py +++ b/src/importer/sources/flatpak_source.py @@ -26,7 +26,7 @@ from gi.repository import GLib, Gtk from src import shared from src.game import Game from src.importer.sources.location import Location, LocationSubPath -from src.importer.sources.source import Source, SourceIterable +from src.importer.sources.source import ExecutableFormatSource, SourceIterable class FlatpakSourceIterable(SourceIterable): @@ -116,7 +116,7 @@ class FlatpakLocations(NamedTuple): data: Location -class FlatpakSource(Source): +class FlatpakSource(ExecutableFormatSource): """Generic Flatpak source""" source_id = "flatpak" diff --git a/src/importer/sources/legendary_source.py b/src/importer/sources/legendary_source.py index 9fdb611..bfaaa66 100644 --- a/src/importer/sources/legendary_source.py +++ b/src/importer/sources/legendary_source.py @@ -26,7 +26,11 @@ from typing import NamedTuple from src import shared from src.game import Game from src.importer.sources.location import Location, LocationSubPath -from src.importer.sources.source import Source, SourceIterable, SourceIterationResult +from src.importer.sources.source import ( + ExecutableFormatSource, + SourceIterationResult, + SourceIterable, +) class LegendarySourceIterable(SourceIterable): @@ -93,7 +97,7 @@ class LegendaryLocations(NamedTuple): config: Location -class LegendarySource(Source): +class LegendarySource(ExecutableFormatSource): source_id = "legendary" name = _("Legendary") executable_format = "legendary launch {app_name}" diff --git a/src/importer/sources/retroarch_source.py b/src/importer/sources/retroarch_source.py index d69970b..7e6beea 100644 --- a/src/importer/sources/retroarch_source.py +++ b/src/importer/sources/retroarch_source.py @@ -23,8 +23,10 @@ import re from hashlib import md5 from json import JSONDecodeError from pathlib import Path +from shlex import quote as shell_quote from time import time from typing import NamedTuple +from urllib.parse import quote as url_quote from src import shared from src.errors.friendly_error import FriendlyError @@ -103,9 +105,9 @@ class RetroarchSourceIterable(SourceIterable): "added": added_time, "name": item["label"], "game_id": self.source.game_id_format.format(game_id=game_id), - "executable": self.source.executable_format.format( - rom_path=item["path"], + "executable": self.source.make_executable( core_path=core_path, + rom_path=item["path"], ), } @@ -147,13 +149,44 @@ class RetroarchSource(Source): locations: RetroarchLocations - @property - def executable_format(self): - self.locations.config.resolve() - is_flatpak = self.locations.config.root.is_relative_to(shared.flatpak_dir) - base = "flatpak run org.libretro.RetroArch" if is_flatpak else "retroarch" - args = '-L "{core_path}" "{rom_path}"' - return f"{base} {args}" + def __init__(self) -> None: + super().__init__() + self.locations = RetroarchLocations( + Location( + schema_key="retroarch-location", + candidates=[ + shared.flatpak_dir + / "org.libretro.RetroArch" + / "config" + / "retroarch", + shared.config_dir / "retroarch", + shared.home / ".config" / "retroarch", + # TODO: Windows support, waiting for executable path setting improvement + # Path("C:\\RetroArch-Win64"), + # Path("C:\\RetroArch-Win32"), + # TODO: UWP support (URL handler - https://github.com/libretro/RetroArch/pull/13563) + # shared.local_appdata_dir + # / "Packages" + # / "1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_8ngdn9a6dx1ma" + # / "LocalState", + ], + paths={ + "retroarch.cfg": LocationSubPath("retroarch.cfg"), + }, + invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE, + ) + ) + # TODO enable when we get the Steam RetroArch games work + # self.add_steam_location_candidate() + + def add_steam_location_candidate(self) -> None: + """Add the Steam RetroAcrh location to the config candidates""" + try: + self.locations.config.candidates.append(self.get_steam_location()) + except (OSError, KeyError, UnresolvableLocationError): + logging.debug("Steam isn't installed") + except ValueError as error: + logging.debug("RetroArch Steam location candiate not found", exc_info=error) def get_steam_location(self) -> str: """ @@ -185,36 +218,41 @@ class RetroarchSource(Source): # Not found raise ValueError("RetroArch not found in Steam library") - def __init__(self) -> None: - super().__init__() - self.locations = RetroarchLocations( - Location( - schema_key="retroarch-location", - candidates=[ - shared.flatpak_dir - / "org.libretro.RetroArch" - / "config" - / "retroarch", - shared.config_dir / "retroarch", - shared.home / ".config" / "retroarch", - # TODO: Windows support, waiting for executable path setting improvement - # Path("C:\\RetroArch-Win64"), - # Path("C:\\RetroArch-Win32"), - # TODO: UWP support (URL handler - https://github.com/libretro/RetroArch/pull/13563) - # shared.local_appdata_dir - # / "Packages" - # / "1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_8ngdn9a6dx1ma" - # / "LocalState", - ], - paths={ - "retroarch.cfg": LocationSubPath("retroarch.cfg"), - }, - invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE, - ) - ) - try: - self.locations.config.candidates.append(self.get_steam_location()) - except (OSError, KeyError, UnresolvableLocationError): - logging.debug("Steam isn't installed") - except ValueError as error: - logging.debug("RetroArch Steam location candiate not found", exc_info=error) + def make_executable(self, core_path: Path, rom_path: Path) -> str: + """ + Generate an executable command from the rom path and core path, + depending on the source's location. + + The format depends on RetroArch's installation method, + detected from the source config location + + :param Path rom_path: the game's rom path + :param Path core_path: the game's core path + :return str: an executable command + """ + + self.locations.config.resolve() + args = ("-L", core_path, rom_path) + + # Steam RetroArch + # (Must check before Flatpak, because Steam itself can be installed as one) + # TODO enable when we get Steam RetroArch executable to work + # if self.locations.config.root.parent.parent.name == "steamapps": + # # steam://run exepects args to be url-encoded and separated by spaces. + # args = map(lambda s: url_quote(str(s), safe=""), args) + # args_str = " ".join(args) + # uri = f"steam://run/1118310//{args_str}/" + # return f"xdg-open {shell_quote(uri)}" + + # Flatpak RetroArch + args = map(lambda s: shell_quote(str(s)), args) + args_str = " ".join(args) + if self.locations.config.root.is_relative_to(shared.flatpak_dir): + return f"flatpak run org.libretro.RetroArch {args_str}" + + # TODO executable override for non-sandboxed sources + + # Linux native RetroArch + return f"retroarch {args_str}" + + # TODO implement for windows (needs override) diff --git a/src/importer/sources/source.py b/src/importer/sources/source.py index 74a13eb..e19b4dd 100644 --- a/src/importer/sources/source.py +++ b/src/importer/sources/source.py @@ -78,10 +78,12 @@ class Source(Iterable): def is_available(self) -> bool: return sys.platform in self.available_on - @property @abstractmethod - def executable_format(self) -> str: - """The executable format used to construct game executables""" + def make_executable(self, *args, **kwargs) -> str: + """ + Create a game executable command. + Should be implemented by child classes. + """ def __iter__(self) -> Generator[SourceIterationResult, None, None]: """ @@ -93,8 +95,21 @@ class Source(Iterable): return iter(self.iterable_class(self)) +class ExecutableFormatSource(Source): + """Source class that uses a simple executable format to start games""" + + @property + @abstractmethod + def executable_format(self) -> str: + """The executable format used to construct game executables""" + + def make_executable(self, *args, **kwargs) -> str: + """Use the executable format to""" + return self.executable_format.format(args, kwargs) + + # pylint: disable=abstract-method -class URLExecutableSource(Source): +class URLExecutableSource(ExecutableFormatSource): """Source class that use custom URLs to start games""" url_format: str