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(