Merge pull request #158 from kra-mo/locations-improvements

Locations improvements
This commit is contained in:
kramo
2023-07-26 16:01:32 +02:00
committed by GitHub
10 changed files with 216 additions and 146 deletions

View File

@@ -20,12 +20,13 @@
from pathlib import Path
from time import time
from typing import NamedTuple
import yaml
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import SourceIterable, URLExecutableSource
@@ -35,7 +36,7 @@ class BottlesSourceIterable(SourceIterable):
def __iter__(self):
"""Generator method producing games"""
data = self.source.data_location["library.yml"].read_text("utf-8")
data = self.source.locations.data["library.yml"].read_text("utf-8")
library: dict = yaml.safe_load(data)
added_time = int(time())
@@ -58,11 +59,11 @@ class BottlesSourceIterable(SourceIterable):
# as Cartridges can't access directories picked via Bottles' file picker portal
bottles_location = Path(
yaml.safe_load(
self.source.data_location["data.yml"].read_text("utf-8")
self.source.locations.data["data.yml"].read_text("utf-8")
)["custom_bottles_path"]
)
except (FileNotFoundError, KeyError):
bottles_location = self.source.data_location.root / "bottles"
bottles_location = self.source.locations.data.root / "bottles"
bottle_path = entry["bottle"]["path"]
@@ -76,6 +77,10 @@ class BottlesSourceIterable(SourceIterable):
yield (game, additional_data)
class BottlesLocations(NamedTuple):
data: Location
class BottlesSource(URLExecutableSource):
"""Generic Bottles source"""
@@ -84,7 +89,8 @@ class BottlesSource(URLExecutableSource):
url_format = 'bottles:run/"{bottle_name}"/"{game_name}"'
available_on = {"linux"}
data_location = Location(
locations = BottlesLocations(
Location(
schema_key="bottles-location",
candidates=(
shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles",
@@ -92,7 +98,9 @@ class BottlesSource(URLExecutableSource):
shared.home / ".local" / "share" / "bottles",
),
paths={
"library.yml": (False, "library.yml"),
"data.yml": (False, "data.yml"),
"library.yml": LocationSubPath("library.yml"),
"data.yml": LocationSubPath("data.yml"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
)

View File

@@ -19,12 +19,13 @@
from pathlib import Path
from time import time
from typing import NamedTuple
from gi.repository import GLib, Gtk
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import Source, SourceIterable
@@ -37,7 +38,7 @@ class FlatpakSourceIterable(SourceIterable):
added_time = int(time())
icon_theme = Gtk.IconTheme.new()
icon_theme.add_search_path(str(self.source.data_location["icons"]))
icon_theme.add_search_path(str(self.source.locations.data["icons"]))
blacklist = (
{"hu.kramo.Cartridges", "hu.kramo.Cartridges.Devel"}
@@ -53,7 +54,7 @@ class FlatpakSourceIterable(SourceIterable):
}
)
for entry in (self.source.data_location["applications"]).iterdir():
for entry in (self.source.locations.data["applications"]).iterdir():
if entry.suffix != ".desktop":
continue
@@ -111,6 +112,10 @@ class FlatpakSourceIterable(SourceIterable):
yield (game, additional_data)
class FlatpakLocations(NamedTuple):
data: Location
class FlatpakSource(Source):
"""Generic Flatpak source"""
@@ -119,14 +124,17 @@ class FlatpakSource(Source):
executable_format = "flatpak run {flatpak_id}"
available_on = {"linux"}
data_location = Location(
locations = FlatpakLocations(
Location(
schema_key="flatpak-location",
candidates=(
"/var/lib/flatpak/",
shared.data_dir / "flatpak",
),
paths={
"applications": (True, "exports/share/applications"),
"icons": (True, "exports/share/icons"),
"applications": LocationSubPath("exports/share/applications", True),
"icons": LocationSubPath("exports/share/icons", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
)

View File

@@ -25,12 +25,12 @@ from hashlib import sha256
from json import JSONDecodeError
from pathlib import Path
from time import time
from typing import Iterable, Optional, TypedDict
from typing import Iterable, NamedTuple, Optional, TypedDict
from functools import cached_property
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import (
SourceIterable,
SourceIterationResult,
@@ -87,7 +87,7 @@ class SubSourceIterable(Iterable):
@cached_property
def library_path(self) -> Path:
path = self.source.config_location.root / self.relative_library_path
path = self.source.locations.config.root / self.relative_library_path
logging.debug("Using Heroic %s library.json path %s", self.name, path)
return path
@@ -116,7 +116,7 @@ class SubSourceIterable(Iterable):
# Filenames are derived from the URL that heroic used to get the file
uri: str = entry["art_square"] + self.image_uri_params
digest = sha256(uri.encode()).hexdigest()
image_path = self.source.config_location.root / "images-cache" / digest
image_path = self.source.locations.config.root / "images-cache" / digest
additional_data = {"local_image_path": image_path}
return (game, additional_data)
@@ -159,7 +159,7 @@ class StoreSubSourceIterable(SubSourceIterable):
@cached_property
def installed_path(self) -> Path:
path = self.source.config_location.root / self.relative_installed_path
path = self.source.locations.config.root / self.relative_installed_path
logging.debug("Using Heroic %s installed.json path %s", self.name, path)
return path
@@ -226,7 +226,7 @@ class LegendaryIterable(StoreSubSourceIterable):
and remove this property override.
"""
heroic_config_path = self.source.config_location.root
heroic_config_path = self.source.locations.config.root
# Heroic >= 2.9
if (path := heroic_config_path / "legendaryConfig").is_dir():
logging.debug("Using Heroic >= 2.9 legendary file")
@@ -308,7 +308,7 @@ class HeroicSourceIterable(SourceIterable):
"""
try:
store = path_json_load(self.source.config_location["store_config.json"])
store = path_json_load(self.source.locations.config["store_config.json"])
self.hidden_app_names = {
app_name
for game in store["games"]["hidden"]
@@ -349,6 +349,10 @@ class HeroicSourceIterable(SourceIterable):
continue
class HeroicLocations(NamedTuple):
config: Location
class HeroicSource(URLExecutableSource):
"""Generic Heroic Games Launcher source"""
@@ -357,18 +361,24 @@ class HeroicSource(URLExecutableSource):
url_format = "heroic://launch/{app_name}"
available_on = {"linux", "win32"}
config_location = Location(
locations = HeroicLocations(
Location(
schema_key="heroic-location",
candidates=(
shared.config_dir / "heroic",
shared.home / ".config" / "heroic",
shared.flatpak_dir / "com.heroicgameslauncher.hgl" / "config" / "heroic",
shared.flatpak_dir
/ "com.heroicgameslauncher.hgl"
/ "config"
/ "heroic",
shared.appdata_dir / "heroic",
),
paths={
"config.json": (False, "config.json"),
"store_config.json": (False, Path("store") / "config.json"),
"config.json": LocationSubPath("config.json"),
"store_config.json": LocationSubPath("store/config.json"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
@property

View File

@@ -21,10 +21,11 @@
from shutil import rmtree
from sqlite3 import connect
from time import time
from typing import NamedTuple
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import SourceIterable, URLExecutableSource
from src.utils.sqlite import copy_db
@@ -51,7 +52,7 @@ class ItchSourceIterable(SourceIterable):
caves.game_id = games.id
;
"""
db_path = copy_db(self.source.config_location["butler.db"])
db_path = copy_db(self.source.locations.config["butler.db"])
connection = connect(db_path)
cursor = connection.execute(db_request)
@@ -74,13 +75,18 @@ class ItchSourceIterable(SourceIterable):
rmtree(str(db_path.parent))
class ItchLocations(NamedTuple):
config: Location
class ItchSource(URLExecutableSource):
name = _("itch")
iterable_class = ItchSourceIterable
url_format = "itch://caves/{cave_id}/launch"
available_on = {"linux", "win32"}
config_location = Location(
locations = ItchLocations(
Location(
schema_key="itch-location",
candidates=(
shared.flatpak_dir / "io.itch.itch" / "config" / "itch",
@@ -88,5 +94,9 @@ class ItchSource(URLExecutableSource):
shared.home / ".config" / "itch",
shared.appdata_dir / "itch",
),
paths={"butler.db": (False, "db/butler.db")},
paths={
"butler.db": LocationSubPath("db/butler.db"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)

View File

@@ -21,10 +21,11 @@ import json
import logging
from json import JSONDecodeError
from time import time
from typing import NamedTuple
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import Source, SourceIterationResult, SourceIterable
@@ -50,7 +51,7 @@ class LegendarySourceIterable(SourceIterable):
data = {}
# Get additional metadata from file (optional)
metadata_file = self.source.config_location["metadata"] / f"{app_name}.json"
metadata_file = self.source.locations.config["metadata"] / f"{app_name}.json"
try:
metadata = json.load(metadata_file.open())
values["developer"] = metadata["metadata"]["developer"]
@@ -66,7 +67,7 @@ class LegendarySourceIterable(SourceIterable):
def __iter__(self):
# Open library
file = self.source.config_location["installed.json"]
file = self.source.locations.config["installed.json"]
try:
library: dict = json.load(file.open())
except (JSONDecodeError, OSError):
@@ -88,20 +89,27 @@ class LegendarySourceIterable(SourceIterable):
yield result
class LegendaryLocations(NamedTuple):
config: Location
class LegendarySource(Source):
name = _("Legendary")
executable_format = "legendary launch {app_name}"
available_on = {"linux"}
iterable_class = LegendarySourceIterable
config_location: Location = Location(
locations = LegendaryLocations(
Location(
schema_key="legendary-location",
candidates=(
shared.config_dir / "legendary",
shared.home / ".config" / "legendary",
),
paths={
"installed.json": (False, "installed.json"),
"metadata": (True, "metadata"),
"installed.json": LocationSubPath("installed.json"),
"metadata": LocationSubPath("metadata", True),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)

View File

@@ -1,13 +1,18 @@
import logging
from pathlib import Path
from typing import Callable, Mapping, Iterable
from typing import Mapping, Iterable, NamedTuple
from os import PathLike
from src import shared
PathSegment = str | PathLike | Path
PathSegments = Iterable[PathSegment]
Candidate = PathSegments | Callable[[], PathSegments]
Candidate = PathSegments
class LocationSubPath(NamedTuple):
segment: PathSegment
is_directory: bool = False
class UnresolvableLocationError(Exception):
@@ -24,31 +29,42 @@ class Location:
* When resolved, the schema is updated with the picked chosen
"""
# The variable is the name of the source
CACHE_INVALID_SUBTITLE = _("Select the {} cache directory.")
# The variable is the name of the source
CONFIG_INVALID_SUBTITLE = _("Select the {} configuration directory.")
# The variable is the name of the source
DATA_INVALID_SUBTITLE = _("Select the {} data directory.")
schema_key: str
candidates: Iterable[Candidate]
paths: Mapping[str, tuple[bool, PathSegments]]
paths: Mapping[str, LocationSubPath]
invalid_subtitle: str
root: Path = None
def __init__(
self,
schema_key: str,
candidates: Iterable[Candidate],
paths: Mapping[str, tuple[bool, PathSegments]],
paths: Mapping[str, LocationSubPath],
invalid_subtitle: str,
) -> None:
super().__init__()
self.schema_key = schema_key
self.candidates = candidates
self.paths = paths
self.invalid_subtitle = invalid_subtitle
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():
for segment, is_directory in self.paths.values():
path = Path(candidate) / segment
if is_directory:
if not path.is_dir():
return False
else:
if not subpath.is_file():
if not path.is_file():
return False
return True
@@ -81,4 +97,4 @@ class Location:
def __getitem__(self, key: str):
"""Get the computed path from its key for the location"""
self.resolve()
return self.root / self.paths[key][1]
return self.root / self.paths[key].segment

View File

@@ -20,10 +20,11 @@
from shutil import rmtree
from sqlite3 import connect
from time import time
from typing import NamedTuple
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import SourceIterable, URLExecutableSource
from src.utils.sqlite import copy_db
@@ -51,7 +52,7 @@ class LutrisSourceIterable(SourceIterable):
"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.locations.config["pga.db"])
connection = connect(db_path)
cursor = connection.execute(request, params)
@@ -73,7 +74,7 @@ class LutrisSourceIterable(SourceIterable):
game = Game(values)
# Get official image path
image_path = self.source.cache_location["coverart"] / f"{row[2]}.jpg"
image_path = self.source.locations.cache["coverart"] / f"{row[2]}.jpg"
additional_data = {"local_image_path": image_path}
# Produce game
@@ -83,6 +84,11 @@ class LutrisSourceIterable(SourceIterable):
rmtree(str(db_path.parent))
class LutrisLocations(NamedTuple):
config: Location
cache: Location
class LutrisSource(URLExecutableSource):
"""Generic Lutris source"""
@@ -91,9 +97,10 @@ class LutrisSource(URLExecutableSource):
url_format = "lutris:rungameid/{game_id}"
available_on = {"linux"}
# FIXME possible bug: location picks ~/.var... and cache_lcoation picks ~/.local...
# FIXME possible bug: config picks ~/.var... and cache picks ~/.local...
data_location = Location(
locations = LutrisLocations(
Location(
schema_key="lutris-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris",
@@ -103,9 +110,9 @@ class LutrisSource(URLExecutableSource):
paths={
"pga.db": (False, "pga.db"),
},
)
cache_location = Location(
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
),
Location(
schema_key="lutris-cache-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "cache" / "lutris",
@@ -113,8 +120,10 @@ class LutrisSource(URLExecutableSource):
shared.home / ".cache" / "lutris",
),
paths={
"coverart": (True, "coverart"),
"coverart": LocationSubPath("coverart", True),
},
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
),
)
@property

View File

@@ -19,8 +19,8 @@
import sys
from abc import abstractmethod
from collections.abc import Iterable, Iterator
from typing import Any, Generator, Optional
from collections.abc import Iterable
from typing import Any, Generator, Collection
from src.game import Game
from src.importer.sources.location import Location
@@ -54,10 +54,8 @@ class Source(Iterable):
name: str
variant: str = None
available_on: set[str] = set()
data_location: Optional[Location] = None
cache_location: Optional[Location] = None
config_location: Optional[Location] = None
iterable_class: type[SourceIterable]
locations: Collection[Location]
@property
def full_name(self) -> str:

View File

@@ -22,11 +22,11 @@ import logging
import re
from pathlib import Path
from time import time
from typing import Iterable
from typing import Iterable, NamedTuple
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import SourceIterable, URLExecutableSource
from src.utils.steam import SteamFileHelper, SteamInvalidManifestError
@@ -36,7 +36,7 @@ class SteamSourceIterable(SourceIterable):
def get_manifest_dirs(self) -> Iterable[Path]:
"""Get dirs that contain steam app manifests"""
libraryfolders_path = self.source.data_location["libraryfolders.vdf"]
libraryfolders_path = self.source.locations.data["libraryfolders.vdf"]
with open(libraryfolders_path, "r", encoding="utf-8") as file:
contents = file.read()
return [
@@ -100,7 +100,7 @@ class SteamSourceIterable(SourceIterable):
# Add official cover image
image_path = (
self.source.data_location["librarycache"]
self.source.locations.data["librarycache"]
/ f"{appid}_library_600x900.jpg"
)
additional_data = {"local_image_path": image_path, "steam_appid": appid}
@@ -109,13 +109,18 @@ class SteamSourceIterable(SourceIterable):
yield (game, additional_data)
class SteamLocations(NamedTuple):
data: Location
class SteamSource(URLExecutableSource):
name = _("Steam")
available_on = {"linux", "win32"}
iterable_class = SteamSourceIterable
url_format = "steam://rungameid/{game_id}"
data_location = Location(
locations = SteamLocations(
Location(
schema_key="steam-location",
candidates=(
shared.home / ".steam" / "steam",
@@ -124,7 +129,9 @@ class SteamSource(URLExecutableSource):
shared.programfiles32_dir / "Steam",
),
paths={
"libraryfolders.vdf": (False, "steamapps/libraryfolders.vdf"),
"librarycache": (True, "appcache/librarycache"),
"libraryfolders.vdf": LocationSubPath("steamapps/libraryfolders.vdf"),
"librarycache": LocationSubPath("appcache/librarycache", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
)

View File

@@ -262,19 +262,19 @@ class PreferencesWindow(Adw.PreferencesWindow):
subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path))
action_row.set_subtitle(subtitle)
def resolve_locations(self, source):
def resolve_locations(self, source: Source):
"""Resolve locations and add a warning if location cannot be found"""
def clear_warning_selection(_widget, label):
label.select_region(-1, -1)
for location_name in ("data", "config", "cache"):
for location_name, location in source.locations._asdict().items():
action_row = getattr(self, f"{source.id}_{location_name}_action_row", None)
if not action_row:
continue
try:
getattr(source, f"{location_name}_location", None).resolve()
location.resolve()
except UnresolvableLocationError:
popover = Gtk.Popover(
@@ -325,10 +325,14 @@ class PreferencesWindow(Adw.PreferencesWindow):
return
# Good picked location
location = getattr(source, f"{location_name}_location")
location = getattr(source.locations, location_name)
if location.check_candidate(path):
# Set the schema
infix = "-cache" if location_name == "cache" else ""
match location_name:
case "config" | "data":
infix = ""
case _:
infix = f"-{location_name}"
key = f"{source.id}{infix}-location"
value = str(path)
shared.schema.set_string(key, value)
@@ -347,20 +351,10 @@ class PreferencesWindow(Adw.PreferencesWindow):
# Bad picked location, inform user
else:
title = _("Invalid Directory")
match location_name:
case "cache":
# The variable is the name of the source
subtitle_format = _("Select the {} cache directory.")
case "config":
# The variable is the name of the source
subtitle_format = _("Select the {} configuration directory.")
case "data":
# The variable is the name of the source
subtitle_format = _("Select the {} data directory.")
dialog = create_dialog(
self,
title,
subtitle_format.format(source.name),
location.invalid_subtitle.format(source.name),
"choose_folder",
_("Set Location"),
)
@@ -381,10 +375,12 @@ class PreferencesWindow(Adw.PreferencesWindow):
)
# Connect dir picker buttons
for location in ("data", "config", "cache"):
button = getattr(self, f"{source.id}_{location}_file_chooser_button", None)
for location_name in source.locations._asdict():
button = getattr(
self, f"{source.id}_{location_name}_file_chooser_button", None
)
if button is not None:
button.connect("clicked", self.choose_folder, set_dir, location)
button.connect("clicked", self.choose_folder, set_dir, location_name)
# Set the source row subtitles
self.resolve_locations(source)