diff --git a/data/gtk/preferences.blp b/data/gtk/preferences.blp index 9714d93..cd1f351 100644 --- a/data/gtk/preferences.blp +++ b/data/gtk/preferences.blp @@ -61,6 +61,22 @@ template $PreferencesWindow : Adw.PreferencesWindow { ] } } + + Adw.ActionRow reset_action_row { + title: _("Reset App"); + subtitle: _("Completely resets and quits Cartridges"); + visible: false; + + Button reset_button { + label: _("Reset"); + valign: center; + + styles [ + "destructive-action", + ] + } + } + } } @@ -76,10 +92,10 @@ template $PreferencesWindow : Adw.PreferencesWindow { title: _("Steam"); show-enable-switch: true; - Adw.ActionRow steam_action_row { + Adw.ActionRow steam_data_action_row { title: _("Install Location"); - Button steam_file_chooser_button { + Button steam_data_file_chooser_button { icon-name: "folder-symbolic"; valign: center; } @@ -90,10 +106,10 @@ template $PreferencesWindow : Adw.PreferencesWindow { title: _("Lutris"); show-enable-switch: true; - Adw.ActionRow lutris_action_row { + Adw.ActionRow lutris_data_action_row { title: _("Install Location"); - Button lutris_file_chooser_button { + Button lutris_data_file_chooser_button { icon-name: "folder-symbolic"; valign: center; } @@ -122,10 +138,10 @@ template $PreferencesWindow : Adw.PreferencesWindow { title: _("Heroic"); show-enable-switch: true; - Adw.ActionRow heroic_action_row { + Adw.ActionRow heroic_config_action_row { title: _("Install Location"); - Button heroic_file_chooser_button { + Button heroic_config_file_chooser_button { icon-name: "folder-symbolic"; valign: center; } @@ -163,10 +179,10 @@ template $PreferencesWindow : Adw.PreferencesWindow { title: _("Bottles"); show-enable-switch: true; - Adw.ActionRow bottles_action_row { + Adw.ActionRow bottles_data_action_row { title: _("Install Location"); - Button bottles_file_chooser_button { + Button bottles_data_file_chooser_button { icon-name: "folder-symbolic"; valign: center; } @@ -177,10 +193,10 @@ template $PreferencesWindow : Adw.PreferencesWindow { title: _("itch"); show-enable-switch: true; - Adw.ActionRow itch_action_row { + Adw.ActionRow itch_config_action_row { title: _("Install Location"); - Button itch_file_chooser_button { + Button itch_config_file_chooser_button { icon-name: "folder-symbolic"; valign: center; } @@ -191,10 +207,10 @@ template $PreferencesWindow : Adw.PreferencesWindow { title: _("Legendary"); show-enable-switch: true; - Adw.ActionRow legendary_action_row { + Adw.ActionRow legendary_config_action_row { title: _("Install Location"); - Button legendary_file_chooser_button { + Button legendary_config_file_chooser_button { icon-name: "folder-symbolic"; valign: center; } diff --git a/src/importer/importer.py b/src/importer/importer.py index e08af72..9cbeeb6 100644 --- a/src/importer/importer.py +++ b/src/importer/importer.py @@ -114,7 +114,7 @@ class Importer: source, *_rest = data # Early exit if not installed - if not source.is_installed: + if not source.is_available: logging.info("Source %s skipped, not installed", source.id) return logging.info("Scanning source %s", source.id) diff --git a/src/importer/sources/bottles_source.py b/src/importer/sources/bottles_source.py index a08c92d..0823c0a 100644 --- a/src/importer/sources/bottles_source.py +++ b/src/importer/sources/bottles_source.py @@ -25,15 +25,12 @@ import yaml from src import shared from src.game import Game +from src.importer.sources.location import Location from src.importer.sources.source import ( SourceIterationResult, SourceIterator, URLExecutableSource, ) -from src.utils.decorators import ( - replaced_by_path, - replaced_by_schema_key, -) class BottlesSourceIterator(SourceIterator): @@ -42,7 +39,7 @@ class BottlesSourceIterator(SourceIterator): def generator_builder(self) -> SourceIterationResult: """Generator method producing games""" - data = (self.source.location / "library.yml").read_text("utf-8") + data = self.source.data_location["library.yml"].read_text("utf-8") library: dict = yaml.safe_load(data) for entry in library.values(): @@ -65,11 +62,11 @@ class BottlesSourceIterator(SourceIterator): # as Cartridges can't access directories picked via Bottles' file picker portal bottles_location = Path( yaml.safe_load( - (self.source.location / "data.yml").read_text("utf-8") + self.source.data_location["data.yml"].read_text("utf-8") )["custom_bottles_path"] ) except (FileNotFoundError, KeyError): - bottles_location = self.source.location / "bottles" + bottles_location = self.source.data_location.root / "bottles" bottle_path = entry["bottle"]["path"] image_name = entry["thumbnail"].split(":")[1] @@ -88,9 +85,14 @@ class BottlesSource(URLExecutableSource): url_format = 'bottles:run/"{bottle_name}"/"{game_name}"' available_on = set(("linux",)) - @property - @replaced_by_schema_key - @replaced_by_path("~/.var/app/com.usebottles.bottles/data/bottles/") - @replaced_by_path(shared.data_dir / "bottles") - def location(self) -> Path: - raise FileNotFoundError() + data_location = Location( + candidates=( + lambda: shared.schema.get_string("bottles-location"), + "~/.var/app/com.usebottles.bottles/data/bottles/", + shared.data_dir / "bottles/", + ), + paths={ + "library.yml": (False, "library.yml"), + "data.yml": (False, "data.yml"), + }, + ) diff --git a/src/importer/sources/heroic_source.py b/src/importer/sources/heroic_source.py index 595afcf..8b828f1 100644 --- a/src/importer/sources/heroic_source.py +++ b/src/importer/sources/heroic_source.py @@ -22,21 +22,17 @@ import json import logging from hashlib import sha256 from json import JSONDecodeError -from pathlib import Path from time import time from typing import Optional, TypedDict from src import shared from src.game import Game +from src.importer.sources.location import Location from src.importer.sources.source import ( URLExecutableSource, SourceIterationResult, SourceIterator, ) -from src.utils.decorators import ( - replaced_by_path, - replaced_by_schema_key, -) class HeroicLibraryEntry(TypedDict): @@ -103,7 +99,7 @@ class HeroicSourceIterator(SourceIterator): if service == "epic": uri += "?h=400&resize=1&w=300" digest = sha256(uri.encode()).hexdigest() - image_path = self.source.location / "images-cache" / digest + image_path = self.source.config_location.root / "images-cache" / digest additional_data = {"local_image_path": image_path} return (game, additional_data) @@ -116,7 +112,7 @@ class HeroicSourceIterator(SourceIterator): if not shared.schema.get_boolean("heroic-import-" + sub_source["service"]): continue # Load games from JSON - file = self.source.location.joinpath(*sub_source["path"]) + file = self.source.config_location.root.joinpath(*sub_source["path"]) try: library = json.load(file.open())["library"] except (JSONDecodeError, OSError, KeyError): @@ -141,15 +137,20 @@ class HeroicSource(URLExecutableSource): url_format = "heroic://launch/{app_name}" available_on = set(("linux", "win32")) + config_location = Location( + candidates=( + lambda: shared.schema.get_string("heroic-location"), + "~/.var/app/com.heroicgameslauncher.hgl/config/heroic/", + shared.config_dir / "heroic/", + "~/.config/heroic/", + shared.appdata_dir / "heroic/", + ), + paths={ + "config.json": (False, "config.json"), + }, + ) + @property def game_id_format(self) -> str: """The string format used to construct game IDs""" return self.name.lower() + "_{service}_{game_id}" - - @property - @replaced_by_schema_key - @replaced_by_path("~/.var/app/com.heroicgameslauncher.hgl/config/heroic/") - @replaced_by_path(shared.config_dir / "heroic") - @replaced_by_path(shared.appdata_dir / "heroic") - def location(self) -> Path: - raise FileNotFoundError() diff --git a/src/importer/sources/itch_source.py b/src/importer/sources/itch_source.py index ddcb2d0..a36aaa1 100644 --- a/src/importer/sources/itch_source.py +++ b/src/importer/sources/itch_source.py @@ -18,19 +18,18 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -from pathlib import Path from shutil import rmtree from sqlite3 import connect from time import time from src import shared from src.game import Game +from src.importer.sources.location import Location from src.importer.sources.source import ( SourceIterationResult, SourceIterator, URLExecutableSource, ) -from src.utils.decorators import replaced_by_path, replaced_by_schema_key from src.utils.sqlite import copy_db @@ -56,7 +55,7 @@ class ItchSourceIterator(SourceIterator): caves.game_id = games.id ; """ - db_path = copy_db(self.source.location / "db" / "butler.db") + db_path = copy_db(self.source.config_location["butler.db"]) connection = connect(db_path) cursor = connection.execute(db_request) @@ -84,10 +83,13 @@ class ItchSource(URLExecutableSource): url_format = "itch://caves/{cave_id}/launch" available_on = set(("linux", "win32")) - @property - @replaced_by_schema_key - @replaced_by_path("~/.var/app/io.itch.itch/config/itch/") - @replaced_by_path(shared.config_dir / "itch") - @replaced_by_path(shared.appdata_dir / "itch") - def location(self) -> Path: - raise FileNotFoundError() + config_location = Location( + candidates=( + lambda: shared.schema.get_string("itch-location"), + "~/.var/app/io.itch.itch/config/itch/", + shared.config_dir / "itch/", + "~/.config/itch/", + shared.appdata_dir / "itch/", + ), + paths={"butler.db": (False, "db/butler.db")}, + ) diff --git a/src/importer/sources/legendary_source.py b/src/importer/sources/legendary_source.py index 64bd606..7fd2f56 100644 --- a/src/importer/sources/legendary_source.py +++ b/src/importer/sources/legendary_source.py @@ -26,8 +26,8 @@ from typing import Generator 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 -from src.utils.decorators import replaced_by_path, replaced_by_schema_key class LegendarySourceIterator(SourceIterator): @@ -51,7 +51,7 @@ class LegendarySourceIterator(SourceIterator): data = {} # Get additional metadata from file (optional) - metadata_file = self.source.location / "metadata" / f"{app_name}.json" + metadata_file = self.source.data_location["metadata"] / f"{app_name}.json" try: metadata = json.load(metadata_file.open()) values["developer"] = metadata["metadata"]["developer"] @@ -67,7 +67,7 @@ class LegendarySourceIterator(SourceIterator): def generator_builder(self) -> Generator[SourceIterationResult, None, None]: # Open library - file = self.source.location / "installed.json" + file = self.source.data_location["installed.json"] try: library: dict = json.load(file.open()) except (JSONDecodeError, OSError): @@ -89,11 +89,17 @@ class LegendarySourceIterator(SourceIterator): class LegendarySource(Source): name = "Legendary" executable_format = "legendary launch {app_name}" - iterator_class = LegendarySourceIterator available_on = set(("linux", "win32")) - @property - @replaced_by_schema_key - @replaced_by_path(shared.config_dir / "legendary") - def location(self) -> Path: - raise FileNotFoundError() + iterator_class = LegendarySourceIterator + data_location: Location = Location( + candidates=( + lambda: shared.schema.get_string("legendary-location"), + shared.config_dir / "legendary/", + "~/.config/legendary", + ), + paths={ + "installed.json": (False, "installed.json"), + "metadata": (True, "metadata"), + }, + ) diff --git a/src/importer/sources/location.py b/src/importer/sources/location.py new file mode 100644 index 0000000..651db7b --- /dev/null +++ b/src/importer/sources/location.py @@ -0,0 +1,64 @@ +from pathlib import Path +from typing import Callable, Mapping, Iterable +from os import PathLike + +PathSegment = str | PathLike | Path +PathSegments = Iterable[PathSegment] +Candidate = PathSegments | Callable[[], PathSegments] + + +class UnresolvableLocationError(Exception): + pass + + +class Location: + """ + Class representing a filesystem location + + * A location may have multiple candidate roots + * From its root, multiple subpaths are named and should exist + """ + + candidates: Iterable[Candidate] + paths: Mapping[str, tuple[bool, PathSegments]] + root: Path = None + + def __init__( + self, + candidates: Iterable[Candidate], + paths: Mapping[str, tuple[bool, PathSegments]], + ) -> None: + super().__init__() + self.candidates = candidates + self.paths = paths + + def check_candidate(self, candidate: Path) -> bool: + """Check if a candidate root has the necessary files and directories""" + for type_is_dir, subpath in self.paths.values(): + subpath = Path(candidate) / Path(subpath) + if type_is_dir: + if not subpath.is_dir(): + return False + else: + if not subpath.is_file(): + return False + return True + + def resolve(self) -> None: + """Choose a root path from the candidates for the location. + If none fits, raise a UnresolvableLocationError""" + if self.root is not None: + return + for candidate in self.candidates: + if callable(candidate): + candidate = candidate() + candidate = Path(candidate).expanduser() + if self.check_candidate(candidate): + self.root = candidate + return + raise UnresolvableLocationError() + + def __getitem__(self, key: str): + """Get the computed path from its key for the location""" + self.resolve() + return self.root / self.paths[key][1] diff --git a/src/importer/sources/lutris_source.py b/src/importer/sources/lutris_source.py index 2830bd8..db05a18 100644 --- a/src/importer/sources/lutris_source.py +++ b/src/importer/sources/lutris_source.py @@ -17,19 +17,18 @@ # along with this program. If not, see . # # SPDX-License-Identifier: GPL-3.0-or-later - from shutil import rmtree from sqlite3 import connect from time import time from src import shared from src.game import Game +from src.importer.sources.location import Location from src.importer.sources.source import ( SourceIterationResult, SourceIterator, URLExecutableSource, ) -from src.utils.decorators import replaced_by_path, replaced_by_schema_key from src.utils.sqlite import copy_db @@ -52,7 +51,7 @@ class LutrisSourceIterator(SourceIterator): ; """ params = {"import_steam": shared.schema.get_boolean("lutris-import-steam")} - db_path = copy_db(self.source.location / "pga.db") + db_path = copy_db(self.source.data_location["pga.db"]) connection = connect(db_path) cursor = connection.execute(request, params) @@ -73,7 +72,7 @@ class LutrisSourceIterator(SourceIterator): game = Game(values, allow_side_effects=False) # Get official image path - image_path = self.source.location / "covers" / "coverart" / f"{row[2]}.jpg" + image_path = self.source.cache_location["coverart"] / f"{row[2]}.jpg" additional_data = {"local_image_path": image_path} # Produce game @@ -91,13 +90,32 @@ class LutrisSource(URLExecutableSource): url_format = "lutris:rungameid/{game_id}" available_on = set(("linux",)) + # FIXME possible bug: location picks ~/.var... and cache_lcoation picks ~/.local... + + data_location = Location( + candidates=( + lambda: shared.schema.get_string("lutris-location"), + "~/.var/app/net.lutris.Lutris/data/lutris/", + shared.data_dir / "lutris/", + "~/.local/share/lutris/", + ), + paths={ + "pga.db": (False, "pga.db"), + }, + ) + + cache_location = Location( + candidates=( + lambda: shared.schema.get_string("lutris-cache-location"), + "~/.var/app/net.lutris.Lutris/cache/lutris/", + shared.cache_dir / "lutris/", + "~/.cache/lutris", + ), + paths={ + "coverart": (True, "coverart"), + }, + ) + @property def game_id_format(self): return super().game_id_format + "_{game_internal_id}" - - @property - @replaced_by_schema_key - @replaced_by_path("~/.var/app/net.lutris.Lutris/data/lutris/") - @replaced_by_path("~/.local/share/lutris/") - def location(self): - raise FileNotFoundError() diff --git a/src/importer/sources/source.py b/src/importer/sources/source.py index 376e5d9..1470764 100644 --- a/src/importer/sources/source.py +++ b/src/importer/sources/source.py @@ -20,10 +20,9 @@ import sys from abc import abstractmethod from collections.abc import Iterable, Iterator -from pathlib import Path -from typing import Generator, Any +from typing import Generator, Any, Optional -from src import shared +from src.importer.sources.location import Location from src.game import Game # Type of the data returned by iterating on a Source @@ -62,9 +61,12 @@ class Source(Iterable): """Source of games. E.g an installed app with a config file that lists game directories""" name: str - iterator_class: type[SourceIterator] variant: str = None available_on: set[str] = set() + data_location: Optional[Location] = None + cache_location: Optional[Location] = None + config_location: Optional[Location] = None + iterator_class: type[SourceIterator] @property def full_name(self) -> str: @@ -88,44 +90,21 @@ class Source(Iterable): return self.name.lower() + "_{game_id}" @property - def is_installed(self): - # pylint: disable=pointless-statement - try: - self.location - except FileNotFoundError: - return False + def is_available(self): return sys.platform in self.available_on - @property - def location_key(self) -> str: - """ - The schema key pointing to the user-set location for the source. - May be overriden by inherinting classes. - """ - return f"{self.name.lower()}-location" - - def update_location_schema_key(self): - """Update the schema value for this source's location if possible""" - try: - location = self.location - except FileNotFoundError: - return - shared.schema.set_string(self.location_key, location) - - def __iter__(self) -> SourceIterator: - """Get an iterator for the source""" - return self.iterator_class(self) - - @property - @abstractmethod - def location(self) -> Path: - """The source's location on disk""" - @property @abstractmethod def executable_format(self) -> str: """The executable format used to construct game executables""" + def __iter__(self) -> SourceIterator: + """Get an iterator for the source""" + for location in (self.data_location, self.cache_location, self.config_location): + if location is not None: + location.resolve() + return self.iterator_class(self) + # pylint: disable=abstract-method class URLExecutableSource(Source): diff --git a/src/importer/sources/steam_source.py b/src/importer/sources/steam_source.py index f416d06..2110692 100644 --- a/src/importer/sources/steam_source.py +++ b/src/importer/sources/steam_source.py @@ -35,6 +35,7 @@ from src.utils.decorators import ( replaced_by_schema_key, ) from src.utils.steam import SteamFileHelper, SteamInvalidManifestError +from src.importer.sources.location import Location class SteamSourceIterator(SourceIterator): @@ -42,7 +43,7 @@ class SteamSourceIterator(SourceIterator): def get_manifest_dirs(self) -> Iterable[Path]: """Get dirs that contain steam app manifests""" - libraryfolders_path = self.source.location / "steamapps" / "libraryfolders.vdf" + libraryfolders_path = self.source.data_location["libraryfolders.vdf"] with open(libraryfolders_path, "r", encoding="utf-8") as file: contents = file.read() return [ @@ -101,9 +102,7 @@ class SteamSourceIterator(SourceIterator): # Add official cover image image_path = ( - self.source.location - / "appcache" - / "librarycache" + self.source.data_location["librarycache"] / f"{appid}_library_600x900.jpg" ) additional_data = {"local_image_path": image_path, "steam_appid": appid} @@ -114,15 +113,20 @@ class SteamSourceIterator(SourceIterator): class SteamSource(URLExecutableSource): name = "Steam" + available_on = set(("linux", "win32")) iterator_class = SteamSourceIterator url_format = "steam://rungameid/{game_id}" - available_on = set(("linux", "win32")) - @property - @replaced_by_schema_key - @replaced_by_path("~/.var/app/com.valvesoftware.Steam/data/Steam/") - @replaced_by_path(shared.data_dir / "Steam") - @replaced_by_path("~/.steam/") - @replaced_by_path(shared.programfiles32_dir / "Steam") - def location(self): - raise FileNotFoundError() + data_location = Location( + candidates=( + lambda: shared.schema.get_string("steam-location"), + "~/.var/app/com.valvesoftware.Steam/data/Steam/", + shared.data_dir / "Steam/", + "~/.steam/", + shared.programfiles32_dir / "Steam", + ), + paths={ + "libraryfolders.vdf": (False, "steamapps/libraryfolders.vdf"), + "librarycache": (True, "appcache/librarycache"), + }, + ) diff --git a/src/preferences.py b/src/preferences.py index e52b87a..7515c2f 100644 --- a/src/preferences.py +++ b/src/preferences.py @@ -24,15 +24,14 @@ from shutil import rmtree from gi.repository import Adw, Gio, GLib, Gtk -# pylint: disable=unused-import from src import shared - -# TODO use the new sources -from src.importers.bottles_importer import bottles_installed -from src.importers.heroic_importer import heroic_installed -from src.importers.itch_importer import itch_installed -from src.importers.lutris_importer import lutris_cache_exists, lutris_installed -from src.importers.steam_importer import steam_installed +from src.importer.sources.bottles_source import BottlesSource +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.source import Source +from src.importer.sources.steam_source import SteamSource from src.utils.create_dialog import create_dialog @@ -49,37 +48,36 @@ class PreferencesWindow(Adw.PreferencesWindow): exit_after_launch_switch = Gtk.Template.Child() cover_launches_game_switch = Gtk.Template.Child() high_quality_images_switch = Gtk.Template.Child() - remove_all_games_button = Gtk.Template.Child() steam_expander_row = Gtk.Template.Child() - steam_action_row = Gtk.Template.Child() - steam_file_chooser_button = Gtk.Template.Child() + steam_data_action_row = Gtk.Template.Child() + steam_data_file_chooser_button = Gtk.Template.Child() lutris_expander_row = Gtk.Template.Child() - lutris_action_row = Gtk.Template.Child() - lutris_file_chooser_button = Gtk.Template.Child() + lutris_data_action_row = Gtk.Template.Child() + lutris_data_file_chooser_button = Gtk.Template.Child() lutris_cache_action_row = Gtk.Template.Child() lutris_cache_file_chooser_button = Gtk.Template.Child() lutris_import_steam_switch = Gtk.Template.Child() heroic_expander_row = Gtk.Template.Child() - heroic_action_row = Gtk.Template.Child() - heroic_file_chooser_button = Gtk.Template.Child() + heroic_config_action_row = Gtk.Template.Child() + heroic_config_file_chooser_button = Gtk.Template.Child() heroic_import_epic_switch = Gtk.Template.Child() heroic_import_gog_switch = Gtk.Template.Child() heroic_import_sideload_switch = Gtk.Template.Child() bottles_expander_row = Gtk.Template.Child() - bottles_action_row = Gtk.Template.Child() - bottles_file_chooser_button = Gtk.Template.Child() + bottles_data_action_row = Gtk.Template.Child() + bottles_data_file_chooser_button = Gtk.Template.Child() itch_expander_row = Gtk.Template.Child() - itch_action_row = Gtk.Template.Child() - itch_file_chooser_button = Gtk.Template.Child() + itch_config_action_row = Gtk.Template.Child() + itch_config_file_chooser_button = Gtk.Template.Child() legendary_expander_row = Gtk.Template.Child() - legendary_action_row = Gtk.Template.Child() - legendary_file_chooser_button = Gtk.Template.Child() + legendary_config_action_row = Gtk.Template.Child() + legendary_config_file_chooser_button = Gtk.Template.Child() sgdb_key_group = Gtk.Template.Child() sgdb_key_entry_row = Gtk.Template.Child() @@ -89,6 +87,9 @@ class PreferencesWindow(Adw.PreferencesWindow): sgdb_animated_switch = Gtk.Template.Child() danger_zone_group = Gtk.Template.Child() + reset_action_row = Gtk.Template.Child() + reset_button = Gtk.Template.Child() + remove_all_games_button = Gtk.Template.Child() removed_games = set() @@ -116,87 +117,25 @@ class PreferencesWindow(Adw.PreferencesWindow): # Debug if shared.PROFILE == "development": - - def reset_app(*_args): - rmtree(shared.data_dir / "cartridges", True) - rmtree(shared.config_dir / "cartridges", True) - rmtree(shared.cache_dir / "cartridges", True) - - for key in ( - (settings_schema_source := Gio.SettingsSchemaSource.get_default()) - .lookup(shared.APP_ID, True) - .list_keys() - ): - shared.schema.reset(key) - for key in settings_schema_source.lookup( - shared.APP_ID + ".State", True - ).list_keys(): - shared.state_schema.reset(key) - - shared.win.get_application().quit() - - reset_button = Gtk.Button.new_with_label("Reset") - reset_button.set_valign(Gtk.Align.CENTER) - reset_button.add_css_class("destructive-action") - reset_button.connect("clicked", reset_app) - - self.danger_zone_group.add( - ( - reset_action_row := Adw.ActionRow( - title="Reset App", - subtitle="Completely resets and quits Cartridges", - ) - ) - ) - - reset_action_row.add_suffix(reset_button) + self.reset_action_row.set_visible(True) + self.reset_button.connect("clicked", self.reset_app) self.set_default_size(-1, 560) - # Steam - self.create_preferences(self, "steam", "Steam") - - # Lutris - self.create_preferences(self, "lutris", "Lutris") - - def set_cache_dir(_source, result, *_args): - try: - path = Path(self.file_chooser.select_folder_finish(result).get_path()) - except GLib.GError: - return - - def response(widget, response): - if response == "choose_folder": - self.choose_folder(widget, set_cache_dir) - - if lutris_cache_exists(path): - self.set_subtitle(self, "lutris-cache") - + # Sources settings + for source_class in ( + BottlesSource, + HeroicSource, + ItchSource, + LegendarySource, + LutrisSource, + SteamSource, + ): + source = source_class() + if not source.is_available: + expander_row = getattr(self, f"{source.id}_expander_row") + expander_row.remove() else: - create_dialog( - self.win, - _("Cache Not Found"), - _("Select the Lutris cache directory."), - "choose_folder", - _("Set Location"), - ).connect("response", response) - - self.set_subtitle(self, "lutris-cache") - - self.lutris_cache_file_chooser_button.connect( - "clicked", self.choose_folder, set_cache_dir - ) - - # Heroic - self.create_preferences(self, "heroic", "Heroic", True) - - # Bottles - self.create_preferences(self, "bottles", "Bottles") - - # itch - self.create_preferences(self, "itch", "itch", True) - - # Legendary - self.create_preferences(self, "legendary", "Legendary", True) + self.init_source_row(source) # SteamGridDB def sgdb_key_changed(*_args): @@ -238,11 +177,6 @@ class PreferencesWindow(Adw.PreferencesWindow): ) ) - # Windows - if os.name == "nt": - self.sources_group.remove(self.lutris_expander_row) - self.sources_group.remove(self.bottles_expander_row) - def get_switch(self, setting): return getattr(self, f'{setting.replace("-", "_")}_switch') @@ -255,8 +189,8 @@ class PreferencesWindow(Adw.PreferencesWindow): Gio.SettingsBindFlags.DEFAULT, ) - def choose_folder(self, _widget, function): - self.file_chooser.select_folder(self.win, None, function, None) + def choose_folder(self, _widget, callback, callback_data=None): + self.file_chooser.select_folder(self.win, None, callback, callback_data) def undo_remove_all(self, *_args): for game in self.removed_games: @@ -281,53 +215,98 @@ class PreferencesWindow(Adw.PreferencesWindow): self.add_toast(self.toast) - def set_subtitle(self, win, source_id): - getattr(win, f'{source_id.replace("-", "_")}_action_row').set_subtitle( - # Remove the path if the dir is picked via the Flatpak portal - re.sub( - "/run/user/\\d*/doc/.*/", - "", - str( - Path(shared.schema.get_string(f"{source_id}-location")).expanduser() - ), - ) - ) + def reset_app(*_args): + rmtree(shared.data_dir / "cartridges", True) + rmtree(shared.config_dir / "cartridges", True) + rmtree(shared.cache_dir / "cartridges", True) + + for key in ( + (settings_schema_source := Gio.SettingsSchemaSource.get_default()) + .lookup(shared.APP_ID, True) + .list_keys() + ): + shared.schema.reset(key) + for key in settings_schema_source.lookup( + shared.APP_ID + ".State", True + ).list_keys(): + shared.state_schema.reset(key) + + shared.win.get_application().quit() + + def update_source_action_row_paths(self, source): + """Set the dir subtitle for a source's action rows""" + for location in ("data", "config", "cache"): + # Get the action row to subtitle + action_row = getattr(self, f"{source.id}_{location}_action_row", None) + if not action_row: + continue + + # Historically "location" meant data or config, so the key stays shared + infix = "-cache" if location == "cache" else "" + key = f"{source.id}{infix}-location" + path = Path(shared.schema.get_string(key)).expanduser() + + # Remove the path if the dir is picked via the Flatpak portal + subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path)) + action_row.set_subtitle(subtitle) + + def init_source_row(self, source: Source): + """Initialize a preference row for a source class""" + + def set_dir(_widget, result, location_name): + """Callback called when a dir picker button is clicked""" - def create_preferences(self, win, source_id, name, config=False): - def set_dir(_source, result, *_args): try: - path = Path(win.file_chooser.select_folder_finish(result).get_path()) + path = Path(self.file_chooser.select_folder_finish(result).get_path()) except GLib.GError: return - def response(widget, response): - if response == "choose_folder": - win.choose_folder(widget, set_dir) - - if globals()[f"{source_id}_installed"](path): - self.set_subtitle(win, source_id) + # Good picked location + location = getattr(source, f"{location_name}_location") + if location.check_candidate(path): + # Set the schema + infix = "-cache" if location == "cache" else "" + key = f"{source.id}{infix}-location" + shared.schema.set_string(key, str(path)) + # Update the row + self.update_source_action_row_paths(source) + # Bad picked location, inform user else: - create_dialog( - win, - _("Installation Not Found"), - # The variable is the name of the game launcher - _("Select the {} configuration directory.").format(name) if config - # The variable is the name of the game launcher - else _("Select the {} data directory.").format(name), + if location_name == "cache": + title = "Cache not found" + subtitle_format = "Select the {} cache directory." + else: + title = "Installation not found" + subtitle_format = "Select the {} installation directory." + dialog = create_dialog( + self, + _(title), + _(subtitle_format).format(source.name), "choose_folder", _("Set Location"), - ).connect("response", response) + ) - self.set_subtitle(win, source_id) + def on_response(widget, response): + if response == "choose_folder": + self.choose_folder(widget, set_dir, location_name) + dialog.connect("response", on_response) + + # Bind expander row activation to source being enabled + expander_row = getattr(self, f"{source.id}_expander_row") shared.schema.bind( - source_id, - getattr(win, f"{source_id}_expander_row"), + source.id, + expander_row, "enable-expansion", Gio.SettingsBindFlags.DEFAULT, ) - getattr(win, f"{source_id}_file_chooser_button").connect( - "clicked", win.choose_folder, set_dir - ) + # Connect dir picker buttons + for location in ("data", "config", "cache"): + button = getattr(self, f"{source.id}_{location}_file_chooser_button", None) + if button is not None: + button.connect("clicked", self.choose_folder, set_dir, location) + + # Set the source row subtitles + self.update_source_action_row_paths(source)