diff --git a/data/gtk/preferences.blp b/data/gtk/preferences.blp index 7aa63b9..9654c29 100644 --- a/data/gtk/preferences.blp +++ b/data/gtk/preferences.blp @@ -235,6 +235,20 @@ template $PreferencesWindow : Adw.PreferencesWindow { } } + Adw.ExpanderRow retroarch_expander_row { + title: _("RetroArch"); + show-enable-switch: true; + + Adw.ActionRow retroarch_config_action_row { + title: _("Install Location"); + + Button retroarch_config_file_chooser_button { + icon-name: "folder-symbolic"; + valign: center; + } + } + } + Adw.ExpanderRow flatpak_expander_row { title: _("Flatpak"); show-enable-switch: true; diff --git a/data/hu.kramo.Cartridges.gschema.xml.in b/data/hu.kramo.Cartridges.gschema.xml.in index b0a614d..d31c2ed 100644 --- a/data/hu.kramo.Cartridges.gschema.xml.in +++ b/data/hu.kramo.Cartridges.gschema.xml.in @@ -67,6 +67,12 @@ "~/.config/legendary/" + + true + + + "~/.var/app/org.libretro.RetroArch/config/retroarch/" + true @@ -113,4 +119,4 @@ "[]" - \ No newline at end of file + diff --git a/flatpak/hu.kramo.Cartridges.Devel.json b/flatpak/hu.kramo.Cartridges.Devel.json index d0dc5b9..a710e65 100644 --- a/flatpak/hu.kramo.Cartridges.Devel.json +++ b/flatpak/hu.kramo.Cartridges.Devel.json @@ -18,6 +18,7 @@ "--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/legendary/:ro", "--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/:ro", "--filesystem=~/.var/app/io.itch.itch/config/itch/:ro", + "--filesystem=~/.var/app/org.libretro.RetroArch/config/retroarch/:ro", "--filesystem=/var/lib/flatpak:ro" ], "cleanup" : [ diff --git a/src/importer/sources/retroarch_source.py b/src/importer/sources/retroarch_source.py new file mode 100644 index 0000000..578d9b9 --- /dev/null +++ b/src/importer/sources/retroarch_source.py @@ -0,0 +1,206 @@ +# retroarch_source.py +# +# Copyright 2023 Rilic +# +# 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 . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import logging +import re +from hashlib import md5 +from json import JSONDecodeError +from pathlib import Path +from time import time +from typing import NamedTuple + +from src import shared +from src.errors.friendly_error import FriendlyError +from src.game import Game +from src.importer.sources.location import ( + Location, + LocationSubPath, + UnresolvableLocationError, +) +from src.importer.sources.source import Source, SourceIterable +from src.importer.sources.steam_source import SteamSource + + +class RetroarchSourceIterable(SourceIterable): + source: "RetroarchSource" + + def get_config_value(self, key: str, config_data: str): + for item in re.findall(f'{key}\\s*=\\s*"(.*)"\n', config_data, re.IGNORECASE): + if item.startswith(":"): + item = item.replace(":", str(self.source.locations.config.root)) + + logging.debug(str(item)) + return item + + raise KeyError(f"Key not found in RetroArch config: {key}") + + def __iter__(self): + added_time = int(time()) + bad_playlists = set() + + config_file = self.source.locations.config["retroarch.cfg"] + with config_file.open(encoding="utf-8") as open_file: + config_data = open_file.read() + + playlist_folder = Path( + self.get_config_value("playlist_directory", config_data) + ).expanduser() + thumbnail_folder = Path( + self.get_config_value("thumbnails_directory", config_data) + ).expanduser() + + # Get all playlist files, ending in .lpl + playlist_files = playlist_folder.glob("*.lpl") + + for playlist_file in playlist_files: + logging.debug(playlist_file) + try: + with playlist_file.open( + encoding="utf-8", + ) as open_file: + playlist_json = json.load(open_file) + except (JSONDecodeError, OSError): + logging.warning("Cannot read playlist file: %s", str(playlist_file)) + continue + + for item in playlist_json["items"]: + # Select the core. + # Try the content's core first, then the playlist's default core. + # If none can be used, warn the user and continue. + for core_path in ( + item["core_path"], + playlist_json["default_core_path"], + ): + if core_path not in ("DETECT", ""): + break + else: + logging.warning("Cannot find core for: %s", str(item["path"])) + bad_playlists.add(playlist_file.stem) + continue + + # Build game + game_id = md5(item["path"].encode("utf-8")).hexdigest() + + values = { + "source": self.source.source_id, + "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"], + core_path=core_path, + ), + } + + game = Game(values) + + # Get boxart + boxart_image_name = item["label"] + ".png" + boxart_image_name = re.sub(r"[&\*\/:`<>\?\\\|]", "_", boxart_image_name) + boxart_folder_name = playlist_file.stem + image_path = ( + thumbnail_folder + / boxart_folder_name + / "Named_Boxarts" + / boxart_image_name + ) + additional_data = {"local_image_path": image_path} + + yield (game, additional_data) + + if bad_playlists: + raise FriendlyError( + _("No RetroArch Core Selected"), + # The variable is a newline separated list of playlists + _("The following playlists have no default core:") + + "\n\n{}\n\n".format("\n".join(bad_playlists)) + + _("Games with no core selected were not imported"), + ) + + +class RetroarchLocations(NamedTuple): + config: Location + + +class RetroarchSource(Source): + name = _("RetroArch") + source_id = "retroarch" + available_on = {"linux"} + iterable_class = RetroarchSourceIterable + + 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, + ) + ) + + @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__() + + try: + self.locations.config.candidates.append(self.get_steam_location()) + except (OSError, KeyError, UnresolvableLocationError): + pass + + def get_steam_location(self) -> str: + # Find steam location + libraryfolders = SteamSource().locations.data["libraryfolders.vdf"] + parse_apps = False + with open(libraryfolders, "r", encoding="utf-8") as open_file: + # Search each line for a library path and store it each time a new one is found. + for line in open_file: + if '"path"' in line: + library_path = re.findall( + '"path"\\s+"(.*)"\n', line, re.IGNORECASE + )[0] + elif '"apps"' in line: + parse_apps = True + elif parse_apps and "}" in line: + parse_apps = False + # Stop searching, as the library path directly above the appid has been found. + elif parse_apps and '"1118310"' in line: + return Path(f"{library_path}/steamapps/common/RetroArch") + else: + logging.debug("Steam RetroArch not installed.") diff --git a/src/main.py b/src/main.py index 1bad1ac..1f2c0ac 100644 --- a/src/main.py +++ b/src/main.py @@ -40,6 +40,7 @@ from src.importer.sources.heroic_source import HeroicSource from src.importer.sources.itch_source import ItchSource from src.importer.sources.legendary_source import LegendarySource from src.importer.sources.lutris_source import LutrisSource +from src.importer.sources.retroarch_source import RetroarchSource from src.importer.sources.steam_source import SteamSource from src.logging.setup import log_system_info, setup_logging from src.preferences import PreferencesWindow @@ -237,6 +238,9 @@ class CartridgesApplication(Adw.Application): if shared.schema.get_boolean("legendary"): importer.add_source(LegendarySource()) + if shared.schema.get_boolean("retroarch"): + importer.add_source(RetroarchSource()) + importer.run() def on_remove_game_action(self, *_args): diff --git a/src/preferences.py b/src/preferences.py index 1a16123..6b7d105 100644 --- a/src/preferences.py +++ b/src/preferences.py @@ -32,6 +32,7 @@ from src.importer.sources.itch_source import ItchSource from src.importer.sources.legendary_source import LegendarySource from src.importer.sources.location import UnresolvableLocationError from src.importer.sources.lutris_source import LutrisSource +from src.importer.sources.retroarch_source import RetroarchSource from src.importer.sources.source import Source from src.importer.sources.steam_source import SteamSource from src.utils.create_dialog import create_dialog @@ -83,6 +84,10 @@ class PreferencesWindow(Adw.PreferencesWindow): legendary_config_action_row = Gtk.Template.Child() legendary_config_file_chooser_button = Gtk.Template.Child() + retroarch_expander_row = Gtk.Template.Child() + retroarch_config_action_row = Gtk.Template.Child() + retroarch_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() @@ -139,6 +144,7 @@ class PreferencesWindow(Adw.PreferencesWindow): ItchSource, LegendarySource, LutrisSource, + RetroarchSource, SteamSource, ): source = source_class() diff --git a/src/shared.py.in b/src/shared.py.in index 92fc574..91e7b6d 100644 --- a/src/shared.py.in +++ b/src/shared.py.in @@ -41,6 +41,7 @@ games_dir = data_dir / "cartridges" / "games" covers_dir = data_dir / "cartridges" / "covers" appdata_dir = Path(os.getenv("appdata") or "C:\\Users\\Default\\AppData\\Roaming") +local_appdata_dir = Path(os.getenv("csidl_local_appdata") or "C:\\Users\\Default\\AppData\\Local") programfiles32_dir = Path(os.getenv("programfiles(x86)") or "C:\\Program Files (x86)") scale_factor = max(